Skip to content

Poly 41 image uploader#43

Merged
jaydonkc merged 1 commit intodevfrom
POLY-41-image-uploader
Mar 2, 2026
Merged

Poly 41 image uploader#43
jaydonkc merged 1 commit intodevfrom
POLY-41-image-uploader

Conversation

@Taye-Staats
Copy link
Copy Markdown
Collaborator

@Taye-Staats Taye-Staats commented Feb 19, 2026

Linked Issues

Closes #
Linear: (e.g., POLY-141)

Summary

Briefly explain the change and why.
Implemented a reusable image upload flow to replace URL-based image inputs.

What changed:

Added Convex storage endpoints in backend/convex/listings.ts:
generateListingImageUploadUrl (returns one-time upload URL)
getListingImageUrl (resolves storage ID to display URL)
Built ImageUploader in frontend/components/ImageUploader.tsx with:
image picker (camera/library/web file input fallback)
image compression/resizing (max 1200px, JPEG 80%)
per-image upload progress
error state with retry
remove image
Integrated uploader into listing forms:
frontend/app/listings/new.tsx
frontend/app/listings/[id]/edit.tsx
Updated listing detail page to render images from storage IDs:
frontend/app/listings/[id].tsx
Added route wiring/navigation for create/edit listing pages.
Why:

Better UX: users upload images directly instead of pasting links.
Cross-platform support for iOS, Android, and Web.
Stores stable storage IDs in DB instead of brittle raw URLs.
Reusable component architecture for future image upload features.

How to Test

Steps to verify locally:

  • npm run lint
  • npm run typecheck
  • npm test
  • Manual flow:
    1. npm run dev:backend (in terminal A)
    2. npm run dev (in terminal B)
    3. Verify the change: <describe expected behavior/screens>

Checklist

  • Tests added/updated (if applicable)
  • Lint/tests pass locally (npm run lint)
  • Docs updated (README/ADR/changelog if needed)
  • Follows conventional commit format
  • No merge conflicts with dev

Screenshots / Demos

(if UI or visible behavior - attach images, videos, or GIFs)

Summary by CodeRabbit

  • New Features
    • Create Listing screen with seller email validation, tags, and image uploads.
    • Edit Listing screen for updating listings and images.
    • Image uploader with compression, size checks, progress, retry, and concurrent uploads.
    • Listing detail pages show image galleries and seller contact; new Create Listing navigation.
    • Messaging: paginated conversation list and send-message flow.
    • Listing image upload/retrieval and client-side URL resolution hook.
  • Tests
    • Added messaging and schema tests.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 19, 2026

Warning

Rate limit exceeded

@jaydonkc has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 32 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 4bdbc93 and 5c5576b.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • backend/convex/listings.ts
  • frontend/app/listings/[id]/edit.tsx
  • frontend/app/listings/new.tsx
  • frontend/components/ImageUploader.tsx
  • frontend/hooks/useResolvedImageUrls.ts
  • frontend/package.json
📝 Walkthrough

Walkthrough

Adds messaging data model and APIs, listing image upload and tags support, frontend create/edit listing screens with image upload UI and URL resolution hook, Jest testing/mocks and config, and Babel/TypeScript tooling updates.

Changes

Cohort / File(s) Summary
Convex schema & messaging
backend/convex/schema.ts, backend/convex/messages.ts
Introduce conversations, messages, conversationParticipants, users tables; add sendMessage mutation and listUserConversations query/handler with pagination, unread counts, and participant sync.
Listings backend (images & tags)
backend/convex/listings.ts
Add generateListingImageUploadUrl, getListingImageUrl, getCurrentUserId; extend createListing/updateListing to accept/persist tags and sellerEmail.
Tests & mocks / Jest config
backend/convex/__mocks__/server.js, backend/convex/__tests__/messages.test.ts, backend/convex/__tests__/schema.test.ts, backend/convex/__tests__/tsconfig.json, backend/jest.config.js, jest.config.js, backend/package.json
Add Convex server mock, comprehensive messaging tests, test tsconfig, backend Jest config, update top-level Jest transform/mapping and add backend test script/devDeps.
Frontend listing screens & routing
frontend/app/listings/new.tsx, frontend/app/listings/[id]/edit.tsx, frontend/app/_layout.tsx, frontend/app/index.tsx, frontend/app/listings/[id].tsx
Add Create and Edit listing screens with validation and image management, integrate routes and UI (Create Listing button, Edit button), and show image gallery and seller email on listing detail.
Image uploader & URL resolution
frontend/components/ImageUploader.tsx, frontend/hooks/useResolvedImageUrls.ts, frontend/package.json
Add ImageUploader (web + native) with compression, XHR upload to Convex storage URLs, upload progress/error handling; add hook to resolve storage IDs to URLs; add expo image deps.
Tooling & Babel/TypeScript
babel.config.js, package.json, backend/convex/tsconfig.json
Add @babel/preset-typescript (allowDeclareFields), bump babel deps, and remove explicit "types": [] from backend tsconfig.
Misc small edits
package.json, jest.config.js, backend/package.json
DevDependency and script additions for testing; Jest transform changed to use babel-jest for TS/JS and transformIgnorePatterns adjusted for specific ESM packages.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client (App)
    participant Server as Convex Server (API)
    participant DB as Database
    participant Storage as Storage Service
    Client->>Server: listUserConversations(limit, cursor)
    Server->>DB: Query conversationParticipants by userId ordered by lastActivityAt
    DB-->>Server: participant rows
    Server->>DB: Fetch conversations & lastMessage(s)
    DB-->>Server: conversation metadata + messages
    Server->>DB: Aggregate unread counts per participant
    DB-->>Server: unread counts
    Server-->>Client: Paginated conversation list with previews & unread counts
Loading
sequenceDiagram
    participant Client as Client (App)
    participant Server as Convex Server (API)
    participant Storage as Storage Service
    participant DB as Database
    Client->>Server: generateListingImageUploadUrl()
    Server->>Storage: Request signed upload URL
    Storage-->>Server: Signed upload URL
    Server-->>Client: Return upload URL
    Client->>Client: Compress & upload image to Storage (XHR, progress)
    Storage-->>Client: Return storageId
    Client->>Server: createListing(...imageStorageIds, tags, sellerEmail)
    Server->>DB: Persist listing with images, tags, sellerEmail
    DB-->>Server: Confirm persistence
    Server-->>Client: Success response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • jaydonkc
  • evan-taylor
  • SamanSP1386

Poem

🐰 I hopped through schemas, tests, and screens,

compressed bright images and stitched up means.
Conversations hum and listings bloom,
A rabbit cheers in code — room after room. ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Poly 41 image uploader' is vague and uses generic phrasing that doesn't clearly convey what the PR accomplishes beyond a feature reference number. Consider a more descriptive title such as 'Add reusable image upload component with Convex storage integration' to better summarize the main changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description covers the key requirements from the template including linked issues, a comprehensive summary of changes, testing instructions, and a checklist, though some checklist items remain unchecked.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch POLY-41-image-uploader

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (9)
frontend/app/index.tsx (1)

29-44: FlatList card duplicates ListingCard and silently drops tag rendering.

The inline renderItem re-implements the same title/price/description card that already exists in frontend/components/ListingCard.tsx, but without the tag chips. Users will see tags on the detail page but not on the home list.

♻️ Proposed refactor to use ListingCard
+import ListingCard from '../components/ListingCard';

 <FlatList
   data={listings}
   keyExtractor={(item) => item._id}
-  renderItem={({ item }) => (
-    <TouchableOpacity
-      style={styles.listingCard}
-      onPress={() => router.push(`/listings/${item._id}`)}
-    >
-      <Text style={styles.listingTitle}>{item.title}</Text>
-      <Text style={styles.listingPrice}>${item.price}</Text>
-      <Text style={styles.listingDescription}>{item.description}</Text>
-    </TouchableOpacity>
-  )}
+  renderItem={({ item }) => <ListingCard listing={item} />}
   contentContainerStyle={styles.listContainer}
 />

The listingCard, listingTitle, listingPrice, and listingDescription styles can then be removed from this file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/index.tsx` around lines 29 - 44, Replace the inline renderItem
in the FlatList with the existing ListingCard component so the list reuses
centralized rendering (use FlatList's renderItem={({item}) => <ListingCard
listing={item} onPress={() => router.push(`/listings/${item._id}`)} />} ),
ensuring ListingCard renders tag chips so tags appear on the home list as well;
after switching, remove the now-unused styles listingCard, listingTitle,
listingPrice, and listingDescription from this file to avoid duplication.
backend/convex/tsconfig.json (1)

14-14: Jest types leak into production Convex function scope.

Because "exclude" already contains ./__tests__, this tsconfig.json does not compile test files — the "jest" entry in types doesn't help the test suite get typed, but it does inject Jest globals (describe, expect, test, jest, etc.) into the type namespace of every production Convex function. A stray expect(…) call in a mutation would typecheck silently.

The idiomatic fix is a dedicated tsconfig.json inside __tests__/ that extends the root Convex config and scopes the Jest types to test code only.

♻️ Proposed fix

Revert the change in backend/convex/tsconfig.json:

- "types": ["jest"],
+ "types": [],

Create backend/convex/__tests__/tsconfig.json:

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "types": ["jest"]
  },
  "include": ["./**/*"]
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/tsconfig.json` at line 14, Remove the "types": ["jest"] entry
from backend/convex/tsconfig.json so Jest globals are no longer injected into
production Convex functions, and instead add a new tsconfig at
backend/convex/__tests__/tsconfig.json that extends the root Convex tsconfig
(extends: "../tsconfig.json"), sets compilerOptions.types to ["jest"], and
includes the test files (include: ["./**/*"]) so Jest types are scoped only to
the test folder.
backend/convex/__tests__/messages.test.ts (2)

57-134: Tests cover the happy path and auth, but lack pagination and sendMessage coverage.

Consider adding tests for:

  • Cursor-based pagination (verifying nextCursor is populated when results exceed limit)
  • sendMessage mutation (message insertion, participant row creation/updates, auth failures)
  • Conversations where the referenced document is null (deletion scenario)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/__tests__/messages.test.ts` around lines 57 - 134, Tests for
listUserConversationsHandler miss pagination and sendMessage mutation coverage;
add unit tests that (1) verify cursor-based pagination by seeding
participantRows/dummy conversations exceeding the default limit and asserting
result.nextCursor is non-null and subsequent call with that cursor returns the
next page, referencing listUserConversationsHandler and nextCursor; (2) exercise
sendMessage mutation to assert message document insertion, creation/update of
participantRows, and auth failures by calling sendMessage with valid ctx.userId
and with null userId and checking participantRows and message docs (refer to
sendMessage and participantRows); and (3) include a test where a conversation's
referenced doc is null (simulate deleted conversation in docsById) and assert
the handler skips or handles it gracefully (reference
listUserConversationsHandler and docsById).

105-134: Unread count test only verifies passthrough of mock data, not actual computation.

The unreadCount is set to 1 in the fixture and asserted as 1 in the result. This doesn't exercise the increment logic in sendMessage. Consider testing sendMessage with a mock context to verify that unreadCount is incremented for non-sender participants and unchanged for the sender.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/__tests__/messages.test.ts` around lines 105 - 134, Current
test only asserts passthrough of fixture unreadCount; update the test to
exercise sendMessage logic: use buildCtx to create a context with multiple
participants (including the sender and at least one other), call
sendMessage(...) with the sender as the actor, then call
listUserConversationsHandler(...) (or read the conversation via the same read
path used in production) and assert that the non-sender participant's
conversation.unreadCount increased while the sender's unreadCount stayed the
same; reference sendMessage, listUserConversationsHandler, and buildCtx to
locate and change the test setup and assertions.
backend/convex/messages.ts (1)

80-81: Handler uses any for both ctx and args, losing all type safety.

Extracting the handler for testability is fine, but consider typing the parameters (e.g., using QueryCtx from Convex and an explicit args interface) and adjusting the test mocks to conform. This avoids silent breakage when the handler's expectations evolve.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/messages.ts` around lines 80 - 81, The exported testable
handler listUserConversationsHandler currently types both parameters as any;
change its signature to use Convex's QueryCtx (or the appropriate ctx type) for
the first parameter and define an explicit args interface (e.g.,
ListUserConversationsArgs) describing expected fields for the second parameter,
then update any tests/mocks to implement those shapes so TypeScript can verify
calls and prevent silent breakage when the handler's expectations change.
backend/convex/schema.ts (1)

48-55: Consider using a union type for messages.type instead of open v.string().

Currently type: v.string() allows any string, but the codebase only uses 'text' (see messages.ts line 35). A union (e.g., v.union(v.literal('text'), v.literal('image'))) would give you schema-level validation and prevent invalid message types from being persisted.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/schema.ts` around lines 48 - 55, The messages table currently
defines type as an open string (messages: defineTable ... type: v.string())
which permits any value; replace that with a union of allowed literals to
enforce schema-level validation (for example change the type validator in
messages to a union of v.literal('text') and any other supported types like
v.literal('image') or just v.literal('text') if only text messages are used) so
invalid message types cannot be persisted; update the validator in the messages
table definition (and adjust any imports if needed) to use v.union and v.literal
accordingly.
backend/jest.config.js (1)

13-14: Consider removing .js from extensionsToTreatAsEsm for clarity.

The configuration extensionsToTreatAsEsm: ['.ts', '.js'] tells Jest to treat .js files as ES modules, but backend/convex/__mocks__/server.js uses CommonJS syntax (module.exports). While ts-jest's esModuleInterop: true setting should handle the interop, including .js in this array is non-standard and creates unnecessary fragility. Best practice is to only include .ts files here. Either remove .js from the array or convert the mock to ESM syntax for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/jest.config.js` around lines 13 - 14, Update the Jest config so we
only treat TypeScript files as ESM: remove '.js' from the extensionsToTreatAsEsm
array in jest.config.js (symbol: extensionsToTreatAsEsm) or alternatively
convert the CommonJS mock backend/convex/__mocks__/server.js to ESM (replace
module.exports with export default) so the setting and mock format are
consistent; pick one approach and apply it across the repo to avoid mixed
ESM/CommonJS behavior.
frontend/app/listings/[id].tsx (2)

3-3: Duplicate expo-router import — merge into one

useLocalSearchParams and useRouter are both from expo-router but split across two import statements.

♻️ Proposed fix
-import { useLocalSearchParams } from 'expo-router';
+import { useLocalSearchParams, useRouter } from 'expo-router';
 import { useConvex, useQuery } from 'convex/react';
 import { api } from 'convex/_generated/api';
 import { Id } from 'convex/_generated/dataModel';
-import { useRouter } from 'expo-router';

Also applies to: 7-7

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/listings/`[id].tsx at line 3, There are duplicate imports from
'expo-router' — merge the separate imports of useLocalSearchParams and useRouter
into a single import statement (e.g., import { useLocalSearchParams, useRouter }
from 'expo-router') so both hooks are imported together; update any other
occurrences (e.g., the other import on line with useRouter) to remove the
redundant import and keep only the unified import.

12-12: Duplicated image-URL resolution logic — consider a shared hook

ImageUploader already resolves storage IDs to URLs via useQuery(api.listings.getListingImageUrl, ...). This component reimplements the same mapping imperatively using useConvex + Promise.all + useEffect. A small shared hook (e.g., useResolvedImageUrls(ids: string[])) would eliminate the duplication and give both components Convex's reactive cache for free.

Also applies to: 26-40

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/listings/`[id].tsx at line 12, The component duplicates
image-URL resolution logic by using useConvex + Promise.all + useEffect instead
of reusing ImageUploader's query; extract the shared logic into a new hook
(e.g., useResolvedImageUrls(ids: string[])) that internally calls
useQuery(api.listings.getListingImageUrl, ...) or the Convex query used by
ImageUploader and returns the mapped URLs/reactive results; update this file to
replace the manual useConvex/Promise.all/useEffect sequence with
useResolvedImageUrls(listingImageIds) and update ImageUploader to consume the
same hook so both components share the same reactive cache and behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/convex/listings.ts`:
- Line 96: Add a nullable array-of-strings `tags` field to the Convex listings
table schema so writes from createListing and updateListing succeed: edit your
schema.ts listings table definition to include tags:
v.optional(v.array(v.string())), ensuring the key name matches the field written
by createListing and updateListing; after adding the field run any schema
validation/compile steps to pick up the change.

In `@backend/convex/messages.ts`:
- Around line 92-106: The current pagination uses a single timestamp cursor
(args.cursor → parsedCursor → cursorValue) and filters with
withIndex('by_user_lastActivityAt') using lt('lastActivityAt', cursorValue),
which can drop rows with identical lastActivityAt; change to a stable compound
cursor (e.g., encode/decode { lastActivityAt, _id } from the conversation row)
and replace the single lt filter with a compound comparison (first compare
lastActivityAt, then _id as tie-breaker) when querying conversationParticipants,
or switch to Convex's built-in pagination helpers to return deterministic pages;
update cursor parsing/serialization where args.cursor is read and adjust the
take(limit + 1) logic to use the new cursor format.

In `@backend/package.json`:
- Line 17: The dependency entry "@types/jest": "^30.0.0" in backend/package.json
is incompatible with the runtime jest ^29.7.0 and the root workspace `@types/jest`
^29.5.14; change the version to a ^29.x release (e.g., align with ^29.5.14) so
type declarations match the Jest runtime and workspace, then reinstall/update
lockfile (npm/yarn install) and rebuild or re-run type checks to ensure no
remaining type mismatches.

In `@frontend/app/listings/`[id].tsx:
- Around line 77-82: Wrap the Edit button Pressable render in a guard that
checks ownership by comparing the current viewer's id (e.g., currentUserId or
session?.user?.id) against listing.sellerId (or other owner field) before
rendering; only render the Pressable (styles.editButton,
router.push(`/listings/${listing._id}/edit`), Text "Edit Listing") when the ids
match, otherwise omit the button to prevent non-owners from seeing the edit
action.
- Around line 31-33: The `(api as any)` cast is used because getListingImageUrl
isn't present in the generated Convex types; re-run the Convex codegen (npx
convex dev) to regenerate api.d.ts so getListingImageUrl appears, then remove
the cast and use convex.query((api).listings.getListingImageUrl, { storageId:
imageId as Id<'_storage'> }); also verify the function is correctly exported
from backend/convex/listings.ts (named getListingImageUrl) if codegen still
doesn't pick it up.

In `@frontend/app/listings/`[id]/edit.tsx:
- Around line 26-36: The useEffect that repopulates form fields on every change
to listing (the effect that calls setTitle, setDescription, setPrice,
setCategory, setCondition, setImages) overwrites in-progress user edits; add a
local "hasInitialized" (or similar) useState flag and only populate the form
from listing when hasInitialized is false and listing exists (or when listing.id
first becomes available), then set hasInitialized to true so subsequent listing
updates don't clobber user input; alternatively, gate population by checking
each field for empty/untouched before calling the corresponding setter.

In `@frontend/app/listings/new.tsx`:
- Around line 25-27: The submit guard uses the render-captured state
isSubmitting, which can allow rare double submissions on rapid taps; change to a
synchronous ref-based guard by adding a useRef like submittingRef and set it
true before invoking createListing and false on completion or error, replacing
checks of isSubmitting in the submit handler (and the other occurrence around
line 46) with checks against submittingRef.current so back-to-back presses
cannot bypass the guard.
- Around line 40-43: The form currently checks only the committed images array
(`images`) in new.tsx, which ignores ImageUploader's in-flight uploads and
allows submissions that orphan pending uploads; update ImageUploader to expose
pending state (e.g., add a hasPendingUploads prop or an onPendingChange
callback) and then use that signal in the parent (new.tsx) to either disable the
"Create Listing" submit button or show an Alert when hasPendingUploads is true;
locate the ImageUploader component and its usage in new.tsx and wire the new
prop/callback so submission is blocked or a warning displayed until
pendingUploads === 0.
- Around line 29-32: The current validation only checks sellerEmail for
emptiness; update the form validation (the block that checks title, description,
sellerEmail in new.tsx where sellerEmail is referenced) to also verify
sellerEmail matches a basic email regex and endsWith('@calpoly.edu') (use
sellerEmail.trim().toLowerCase() before checking), and if it fails call
Alert.alert with a clear message and return to block submission; ensure you
validate both general RFC-5322–like pattern and the exact '@calpoly.edu' domain
so malformed or wrong-domain emails are rejected before saving.
- Around line 56-57: Replace the non-blocking navigation call to avoid racing
with the native Alert and leaving the form on the back stack: change
router.push('/') to router.replace('/') in the success flow where
Alert.alert('Success', 'Listing created.') is called (or optionally invoke
router.replace('/') from the Alert.alert callback) so the submitted form is
removed from history and navigation doesn't race the alert overlay.
- Line 18: The frontend is currently accepting a user-controlled sellerEmail via
the sellerEmail state (const [sellerEmail, setSellerEmail]) which can be
spoofed; either stop sending sellerEmail from the client and let the server
derive the seller email from the authenticated identity.subject/email when
creating a listing, or if you must send it, add server-side validation that the
provided sellerEmail matches the authenticated user's email (identity) before
storing sellerId/email. Locate the sellerEmail state and the request payload
assembly in the New Listing component and remove sellerEmail from the payload or
update server-side createListing logic to ignore client-provided sellerEmail and
use identity.subject/email (or validate equality) to prevent impersonation.

In `@frontend/components/ImageUploader.tsx`:
- Around line 92-110: pickUsingWebInput currently calls
URL.createObjectURL(file) and returns the URI but never revokes it; update it so
the created blob URL is revoked when no longer needed by either (a) returning an
object that includes both uri and a revoke() callback that calls
URL.revokeObjectURL(uri) so callers (upload completion or cancel handlers) can
reuse it, or (b) storing the objectUrl in your upload/pending state and ensuring
URL.revokeObjectURL(objectUrl) is invoked when the upload finishes or the
pending upload is removed; reference pickUsingWebInput, URL.createObjectURL and
URL.revokeObjectURL and ensure any created input event handlers are cleaned up
after use.
- Around line 41-83: The effect is repeatedly re-triggering for storage IDs that
resolved to null because the unresolved filter uses !resolvedUrls[imageId]
(treats null same as undefined); update the unresolved calculation in the
useEffect so it only treats truly-unseen IDs as unresolved (e.g., check
resolvedUrls[storageId] === undefined), leaving the logic in resolveImageUrls
and setResolvedUrls unchanged; reference the useEffect/resolvedUrls/images
variables and the resolveImageUrls and setResolvedUrls functions so failed
(null) resolutions are not retried on every render.
- Around line 214-224: The startUpload function uses the outer-scoped images
value when calling onImagesChange which causes a stale-closure bug when multiple
uploads finish concurrently; update the call site in startUpload to append using
an updater callback (e.g. call onImagesChange(prev => [...prev, storageId]))
and, if necessary, update the onImagesChange prop type to accept a
callback-style updater; alternatively, maintain a ref that tracks the latest
images and use that ref when appending — reference startUpload, onImagesChange,
images, and setPendingUploads to locate and change the code.
- Around line 119-123: Replace the deprecated MediaTypeOptions usage in the
ImageUploader component by passing the new mediaTypes string array to
ImagePicker.launchImageLibraryAsync; specifically update the call in
ImageUploader where ImagePicker.launchImageLibraryAsync is invoked (the const
result = await ImagePicker.launchImageLibraryAsync(...) line) to use mediaTypes:
['images'] instead of ImagePicker.MediaTypeOptions.Images, leaving quality and
allowsEditing as-is.

---

Nitpick comments:
In `@backend/convex/__tests__/messages.test.ts`:
- Around line 57-134: Tests for listUserConversationsHandler miss pagination and
sendMessage mutation coverage; add unit tests that (1) verify cursor-based
pagination by seeding participantRows/dummy conversations exceeding the default
limit and asserting result.nextCursor is non-null and subsequent call with that
cursor returns the next page, referencing listUserConversationsHandler and
nextCursor; (2) exercise sendMessage mutation to assert message document
insertion, creation/update of participantRows, and auth failures by calling
sendMessage with valid ctx.userId and with null userId and checking
participantRows and message docs (refer to sendMessage and participantRows); and
(3) include a test where a conversation's referenced doc is null (simulate
deleted conversation in docsById) and assert the handler skips or handles it
gracefully (reference listUserConversationsHandler and docsById).
- Around line 105-134: Current test only asserts passthrough of fixture
unreadCount; update the test to exercise sendMessage logic: use buildCtx to
create a context with multiple participants (including the sender and at least
one other), call sendMessage(...) with the sender as the actor, then call
listUserConversationsHandler(...) (or read the conversation via the same read
path used in production) and assert that the non-sender participant's
conversation.unreadCount increased while the sender's unreadCount stayed the
same; reference sendMessage, listUserConversationsHandler, and buildCtx to
locate and change the test setup and assertions.

In `@backend/convex/messages.ts`:
- Around line 80-81: The exported testable handler listUserConversationsHandler
currently types both parameters as any; change its signature to use Convex's
QueryCtx (or the appropriate ctx type) for the first parameter and define an
explicit args interface (e.g., ListUserConversationsArgs) describing expected
fields for the second parameter, then update any tests/mocks to implement those
shapes so TypeScript can verify calls and prevent silent breakage when the
handler's expectations change.

In `@backend/convex/schema.ts`:
- Around line 48-55: The messages table currently defines type as an open string
(messages: defineTable ... type: v.string()) which permits any value; replace
that with a union of allowed literals to enforce schema-level validation (for
example change the type validator in messages to a union of v.literal('text')
and any other supported types like v.literal('image') or just v.literal('text')
if only text messages are used) so invalid message types cannot be persisted;
update the validator in the messages table definition (and adjust any imports if
needed) to use v.union and v.literal accordingly.

In `@backend/convex/tsconfig.json`:
- Line 14: Remove the "types": ["jest"] entry from backend/convex/tsconfig.json
so Jest globals are no longer injected into production Convex functions, and
instead add a new tsconfig at backend/convex/__tests__/tsconfig.json that
extends the root Convex tsconfig (extends: "../tsconfig.json"), sets
compilerOptions.types to ["jest"], and includes the test files (include:
["./**/*"]) so Jest types are scoped only to the test folder.

In `@backend/jest.config.js`:
- Around line 13-14: Update the Jest config so we only treat TypeScript files as
ESM: remove '.js' from the extensionsToTreatAsEsm array in jest.config.js
(symbol: extensionsToTreatAsEsm) or alternatively convert the CommonJS mock
backend/convex/__mocks__/server.js to ESM (replace module.exports with export
default) so the setting and mock format are consistent; pick one approach and
apply it across the repo to avoid mixed ESM/CommonJS behavior.

In `@frontend/app/index.tsx`:
- Around line 29-44: Replace the inline renderItem in the FlatList with the
existing ListingCard component so the list reuses centralized rendering (use
FlatList's renderItem={({item}) => <ListingCard listing={item} onPress={() =>
router.push(`/listings/${item._id}`)} />} ), ensuring ListingCard renders tag
chips so tags appear on the home list as well; after switching, remove the
now-unused styles listingCard, listingTitle, listingPrice, and
listingDescription from this file to avoid duplication.

In `@frontend/app/listings/`[id].tsx:
- Line 3: There are duplicate imports from 'expo-router' — merge the separate
imports of useLocalSearchParams and useRouter into a single import statement
(e.g., import { useLocalSearchParams, useRouter } from 'expo-router') so both
hooks are imported together; update any other occurrences (e.g., the other
import on line with useRouter) to remove the redundant import and keep only the
unified import.
- Line 12: The component duplicates image-URL resolution logic by using
useConvex + Promise.all + useEffect instead of reusing ImageUploader's query;
extract the shared logic into a new hook (e.g., useResolvedImageUrls(ids:
string[])) that internally calls useQuery(api.listings.getListingImageUrl, ...)
or the Convex query used by ImageUploader and returns the mapped URLs/reactive
results; update this file to replace the manual useConvex/Promise.all/useEffect
sequence with useResolvedImageUrls(listingImageIds) and update ImageUploader to
consume the same hook so both components share the same reactive cache and
behavior.

Comment thread backend/convex/messages.ts Outdated
Comment thread backend/package.json Outdated
Comment thread frontend/app/listings/[id].tsx Outdated
Comment thread frontend/app/listings/[id].tsx Outdated
Comment thread frontend/app/listings/[id]/edit.tsx Outdated
Comment thread frontend/app/listings/new.tsx Outdated
Comment thread frontend/components/ImageUploader.tsx Outdated
Comment thread frontend/components/ImageUploader.tsx
Comment thread frontend/components/ImageUploader.tsx
Comment thread frontend/components/ImageUploader.tsx Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (6)
frontend/components/ImageUploader.tsx (1)

248-295: Duplicated upload-enqueue logic across Camera / Photo Library branches.

The Camera and Photo Library onPress handlers (lines 252–268 and 274–290) are nearly identical — they pick an image, generate a localId, enqueue a pending upload, and start the upload. Consider extracting a shared helper:

Suggested refactor
+  async function pickAndUpload(picker: () => Promise<{ uri: string } | null>) {
+    const picked = await picker();
+    if (!picked) return;
+    const localId = `pending-${Date.now()}`;
+    setPendingUploads((prev) => [
+      ...prev,
+      { localId, uri: picked.uri, progress: 0, status: 'uploading' as const },
+    ]);
+    await startUpload(picked.uri, localId);
+  }

   Alert.alert('Add image', 'Choose image source', [
     {
       text: 'Camera',
-      onPress: () => {
-        void (async () => {
-          const picked = await pickFromCamera();
-          if (!picked) return;
-          const localId = `pending-${Date.now()}`;
-          setPendingUploads((prev) => [
-            ...prev,
-            { localId, uri: picked.uri, progress: 0, status: 'uploading' },
-          ]);
-          await startUpload(picked.uri, localId);
-        })();
-      },
+      onPress: () => void pickAndUpload(pickFromCamera),
     },
     {
       text: 'Photo Library',
-      onPress: () => {
-        void (async () => {
-          const picked = await pickFromLibrary();
-          if (!picked) return;
-          const localId = `pending-${Date.now()}`;
-          setPendingUploads((prev) => [
-            ...prev,
-            { localId, uri: picked.uri, progress: 0, status: 'uploading' },
-          ]);
-          await startUpload(picked.uri, localId);
-        })();
-      },
+      onPress: () => void pickAndUpload(pickFromLibrary),
     },
     { text: 'Cancel', style: 'cancel' },
   ]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ImageUploader.tsx` around lines 248 - 295, Extract the
duplicated logic in the Camera and Photo Library onPress handlers into a
reusable helper (e.g., handlePickAndUpload) that takes the picker function as an
argument (pickFromCamera or pickFromLibrary); the helper should await the
picker, return early if no result, create the localId (`pending-${Date.now()}`),
call setPendingUploads to add the pending item with uri, progress 0 and status
'uploading', then call startUpload with the picked.uri and localId. Replace both
onPress anonymous async blocks to call this new helper with the appropriate
picker to remove duplication.
backend/convex/messages.ts (2)

163-174: .collect() loads all participant rows into memory, defeating cursor-based pagination.

The query fetches every conversationParticipants row for the user, then filters/sorts in-memory. For users with many conversations this becomes an O(n) scan on every page request. The compound cursor approach is correct for stable pagination, but consider at minimum adding a Convex .take() with a generous upper bound or implementing an approximation using the index range (.filter with lt on lastActivityAt from the cursor) before collecting, then applying the compound tiebreaker only on the boundary.

For a college marketplace this is likely fine in the near term, but worth keeping in mind as usage grows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/messages.ts` around lines 163 - 174, The current code calls
ctx.db.query(...).collect() (allParticipantRows) which loads every
conversationParticipants row into memory and then filters/sorts in JS
(isRowAfterCursor, compareRowsDescByActivityThenId), defeating cursor
pagination; change the query to limit results server-side by using .take(limit +
1) on the Convex query when no complex tie-breaking is required, or if a
compound cursor exists (parsedCursor) add a .withIndex(...).filter/.range using
lastActivityAt < parsedCursor.lastActivityAt (and handle equals case via id
boundary) before .collect(), then apply the existing
isRowAfterCursor/compareRowsDescByActivityThenId only to the fetched page
boundary to preserve correct ordering and return stable participantRows without
loading all rows.

50-53: Remove (q: any) cast — the index callback should be properly typed.

The Convex-generated types should infer the correct callback parameter type for withIndex. The any cast suppresses type checking on the index field name and equality filter. If this was added to work around a type error, it likely indicates the generated types are stale — re-run npx convex dev to regenerate.

Suggested fix
     const participantRows = await ctx.db
       .query('conversationParticipants')
-      .withIndex('by_conversationId', (q: any) => q.eq('conversationId', args.conversationId))
+      .withIndex('by_conversationId', (q) => q.eq('conversationId', args.conversationId))
       .collect();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/messages.ts` around lines 50 - 53, Remove the unnecessary any
cast on the withIndex callback so TypeScript uses Convex's generated types:
replace .withIndex('by_conversationId', (q: any) => q.eq('conversationId',
args.conversationId)) with .withIndex('by_conversationId', q =>
q.eq('conversationId', args.conversationId)) in the code that creates
participantRows (ctx.db.query('conversationParticipants')...); if this caused a
type error, regenerate the Convex types by running npx convex dev and then
re-run the build so the correct callback type for withIndex is available.
frontend/app/listings/[id].tsx (1)

40-44: Consider using a stable key instead of url for image rendering.

key={url} works if URLs are unique, but Convex storage URLs are short-lived signed URLs that may change between renders (causing unnecessary remounts). Using the original storage ID as key would be more stable:

Suggested fix
       <View style={styles.imageRow}>
-        {imageUrls.map((url) => (
-          <Image key={url} source={{ uri: url }} style={styles.image} />
+        {imageUrls.map((url, index) => (
+          <Image key={listing.images[index] ?? url} source={{ uri: url }} style={styles.image} />
         ))}
       </View>

However, the imageUrls array has already been filtered (removing nulls), so the index no longer maps 1:1 to listing.images. A cleaner approach would be to keep the paired data together before filtering:

Alternative: pair IDs with URLs before filtering
-  const imageUrls = useMemo(
-    () => mappedUrls.filter((url): url is string => typeof url === 'string' && url.length > 0),
-    [mappedUrls]
-  );
+  const resolvedImages = useMemo(
+    () =>
+      (listing?.images ?? [])
+        .map((id, i) => ({ id, url: mappedUrls[i] }))
+        .filter((entry): entry is { id: string; url: string } =>
+          typeof entry.url === 'string' && entry.url.length > 0
+        ),
+    [listing?.images, mappedUrls]
+  );

   // In JSX:
-  {imageUrls.map((url) => (
-    <Image key={url} source={{ uri: url }} style={styles.image} />
+  {resolvedImages.map(({ id, url }) => (
+    <Image key={id} source={{ uri: url }} style={styles.image} />
   ))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/listings/`[id].tsx around lines 40 - 44, The current Image list
uses key={url} which is unstable for short-lived signed URLs; instead build a
paired array of {id, url} from the original listing.images (preserving the
storage ID), filter out null/undefined URLs, and then map that paired array in
the render (replace imageUrls.map(...) with something like
pairedImages.map(({id, url}) => <Image key={id} .../>)) so the Image components
use the stable storage ID as the key; update the variable used in the JSX inside
the component that renders styles.imageRow and adjust any upstream code that
creates imageUrls to instead produce the paired id+url structure.
frontend/hooks/useResolvedImageUrls.ts (1)

14-56: resolvedUrls in the dependency array causes an extra no-op effect cycle after each batch resolves.

When the effect resolves URLs and calls setResolvedUrls, the state change triggers the effect again. The second run finds nothing unresolved and returns early — so it's safe, not an infinite loop. But it's an unnecessary re-run.

Consider removing resolvedUrls from the dependency array and instead reading it via a ref, or moving the unresolved-check logic so the effect only depends on imageIds:

Suggested approach
+  const resolvedUrlsRef = useRef(resolvedUrls);
+  resolvedUrlsRef.current = resolvedUrls;
+
   useEffect(() => {
     let cancelled = false;

     async function resolveImageUrls() {
       const unresolved = imageIds.filter(
-        (storageId) => !isRemoteUrl(storageId) && resolvedUrls[storageId] === undefined
+        (storageId) => !isRemoteUrl(storageId) && resolvedUrlsRef.current[storageId] === undefined
       );
       ...
     }

     void resolveImageUrls();

     return () => {
       cancelled = true;
     };
-  }, [convex, imageIds, resolvedUrls]);
+  }, [convex, imageIds]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/hooks/useResolvedImageUrls.ts` around lines 14 - 56, The effect in
useResolvedImageUrls re-runs unnecessarily because resolvedUrls is in the
dependency array; when resolveImageUrls updates state the effect runs again only
to find nothing unresolved. Remove resolvedUrls from the dependency list and
instead capture the latest resolvedUrls via a ref or compute unresolved solely
from imageIds inside resolveImageUrls (use the current state via the ref before
calling convex), keeping convex, imageIds in the dependency array; keep
setResolvedUrls usage as-is in resolveImageUrls so updates still merge new
entries.
backend/convex/listings.ts (1)

125-138: Consider checking args.tags !== undefined in createListing for consistency with updateListing.

The code currently passes tags: args.tags directly to ctx.db.insert(). While Convex safely strips undefined values from insert() operations (leaving the field absent), updateListing explicitly checks args.tags !== undefined before patching (lines 189–191). This is because ctx.db.patch() treats undefined values as field removals, requiring the check. For clarity and consistency, applying the same check in createListing would align both functions:

     const listingId = await ctx.db.insert('listings', {
       title: args.title,
       description: args.description,
       price: args.price,
       category: args.category,
       condition: args.condition,
-      tags: args.tags,
+      ...(args.tags !== undefined && { tags: args.tags }),
       images: args.images,
       sellerEmail,
       sellerId: identity.subject,
       status: 'active',
       createdAt: now,
       postedOn: now,
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/listings.ts` around lines 125 - 138, In createListing the code
unconditionally passes tags: args.tags into ctx.db.insert while updateListing
guards with args.tags !== undefined before patching; change createListing to
only include the tags property when args.tags !== undefined (i.e., conditionally
add tags to the insert payload) so behavior matches updateListing and avoids
accidentally sending an undefined that could be interpreted differently by DB
APIs; locate createListing and modify the object passed to ctx.db.insert
accordingly, keeping the guard for args.tags !== undefined.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/components/ImageUploader.tsx`:
- Around line 130-148: compressAndValidate currently always resizes to width:
1200 which upscales small images; change it to accept the original image
dimensions (from the picker result) and cap the target width to
Math.min(originalWidth, 1200) before calling manipulateAsync so you never
upscale; also handle potential Android orientation/dimension swap by normalizing
dimensions (e.g., run a no-op manipulateAsync pass if needed) so
compressAndValidate uses the correct originalWidth and originalHeight when
deciding the resize.

---

Duplicate comments:
In `@backend/convex/listings.ts`:
- Line 104: The review flagged a duplicate comment about the tags field; there's
no code change required because schema.ts already defines tags as
v.optional(v.array(v.string())) matching the mutation argument—please
resolve/close the duplicate review thread and remove the redundant comment so
only one resolved note remains referencing the tags definition in schema.ts.

---

Nitpick comments:
In `@backend/convex/listings.ts`:
- Around line 125-138: In createListing the code unconditionally passes tags:
args.tags into ctx.db.insert while updateListing guards with args.tags !==
undefined before patching; change createListing to only include the tags
property when args.tags !== undefined (i.e., conditionally add tags to the
insert payload) so behavior matches updateListing and avoids accidentally
sending an undefined that could be interpreted differently by DB APIs; locate
createListing and modify the object passed to ctx.db.insert accordingly, keeping
the guard for args.tags !== undefined.

In `@backend/convex/messages.ts`:
- Around line 163-174: The current code calls ctx.db.query(...).collect()
(allParticipantRows) which loads every conversationParticipants row into memory
and then filters/sorts in JS (isRowAfterCursor,
compareRowsDescByActivityThenId), defeating cursor pagination; change the query
to limit results server-side by using .take(limit + 1) on the Convex query when
no complex tie-breaking is required, or if a compound cursor exists
(parsedCursor) add a .withIndex(...).filter/.range using lastActivityAt <
parsedCursor.lastActivityAt (and handle equals case via id boundary) before
.collect(), then apply the existing
isRowAfterCursor/compareRowsDescByActivityThenId only to the fetched page
boundary to preserve correct ordering and return stable participantRows without
loading all rows.
- Around line 50-53: Remove the unnecessary any cast on the withIndex callback
so TypeScript uses Convex's generated types: replace
.withIndex('by_conversationId', (q: any) => q.eq('conversationId',
args.conversationId)) with .withIndex('by_conversationId', q =>
q.eq('conversationId', args.conversationId)) in the code that creates
participantRows (ctx.db.query('conversationParticipants')...); if this caused a
type error, regenerate the Convex types by running npx convex dev and then
re-run the build so the correct callback type for withIndex is available.

In `@frontend/app/listings/`[id].tsx:
- Around line 40-44: The current Image list uses key={url} which is unstable for
short-lived signed URLs; instead build a paired array of {id, url} from the
original listing.images (preserving the storage ID), filter out null/undefined
URLs, and then map that paired array in the render (replace imageUrls.map(...)
with something like pairedImages.map(({id, url}) => <Image key={id} .../>)) so
the Image components use the stable storage ID as the key; update the variable
used in the JSX inside the component that renders styles.imageRow and adjust any
upstream code that creates imageUrls to instead produce the paired id+url
structure.

In `@frontend/components/ImageUploader.tsx`:
- Around line 248-295: Extract the duplicated logic in the Camera and Photo
Library onPress handlers into a reusable helper (e.g., handlePickAndUpload) that
takes the picker function as an argument (pickFromCamera or pickFromLibrary);
the helper should await the picker, return early if no result, create the
localId (`pending-${Date.now()}`), call setPendingUploads to add the pending
item with uri, progress 0 and status 'uploading', then call startUpload with the
picked.uri and localId. Replace both onPress anonymous async blocks to call this
new helper with the appropriate picker to remove duplication.

In `@frontend/hooks/useResolvedImageUrls.ts`:
- Around line 14-56: The effect in useResolvedImageUrls re-runs unnecessarily
because resolvedUrls is in the dependency array; when resolveImageUrls updates
state the effect runs again only to find nothing unresolved. Remove resolvedUrls
from the dependency list and instead capture the latest resolvedUrls via a ref
or compute unresolved solely from imageIds inside resolveImageUrls (use the
current state via the ref before calling convex), keeping convex, imageIds in
the dependency array; keep setResolvedUrls usage as-is in resolveImageUrls so
updates still merge new entries.

Comment thread frontend/components/ImageUploader.tsx Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
backend/convex/messages.ts (1)

149-152: Consider adding an explicit return type to listUserConversationsHandler.

The function body builds conversationList as an untyped array, so the return type is inferred as Promise<any>. Declaring a concrete return interface would propagate type safety to the listUserConversations query and any future consumers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/messages.ts` around lines 149 - 152, The handler
listUserConversationsHandler currently returns an untyped Promise
(conversationList is inferred as any); add a concrete return type interface
(e.g., Promise<ListUserConversationsResult> or similar) and apply it to
listUserConversationsHandler signature so the array elements (conversationList)
are typed; define the ListUserConversationsResult (or appropriate shape)
describing each conversation item and use that type for conversationList and the
function return to ensure type safety propagates to the listUserConversations
query and consumers.
backend/convex/__tests__/messages.test.ts (2)

187-236: Missing coverage for sendMessage error paths.

Two guard-rail branches in sendMessage have no tests:

  • Conversation not found (thrown when ctx.db.get(conversationId) returns null)
  • User is not a participant in this conversation (thrown when senderId is absent from participantIds)
✅ Suggested additions
+  it('sendMessage throws when conversation does not exist', async () => {
+    const ctx = buildCtx({ userId: 'user_sender', docsById: {} });
+    await expect(
+      sendMessage(ctx as any, { conversationId: 'missing_conv', body: 'hi' })
+    ).rejects.toThrow('Conversation not found');
+  });
+
+  it('sendMessage throws when sender is not a participant', async () => {
+    const ctx = buildCtx({
+      userId: 'outsider',
+      docsById: {
+        conv_1: {
+          _id: 'conv_1',
+          listingId: 'l1',
+          buyerId: 'user_a',
+          sellerId: 'user_b',
+          participantIds: ['user_a', 'user_b'],
+          createdAt: 100,
+        },
+      },
+    });
+    await expect(
+      sendMessage(ctx as any, { conversationId: 'conv_1', body: 'hi' })
+    ).rejects.toThrow('User is not a participant');
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/__tests__/messages.test.ts` around lines 187 - 236, Add two
unit tests for sendMessage to cover its error paths: one where
ctx.db.get(conversationId) returns null to assert the "Conversation not found"
error is thrown, and another where the conversation exists but its
participantIds does not include the senderId to assert the "User is not a
participant in this conversation" error is thrown; reuse buildCtx to construct
ctx (override docsById or participantRows accordingly) and call sendMessage with
the same args as the happy-path test, then expect the appropriate exception
messages to be thrown and no message to be inserted/updated.

121-127: order() mock silently ignores its direction argument.

The pre-sorted filtered array happens to match desc order for the current handler, but any future test using .order('asc') will return wrong results without any indication.

♻️ Proposed fix
-            return {
-              order: () => ({
-                collect: async () => filtered,
-                take: async (count: number) => filtered.slice(0, count),
-              }),
-              collect: async () => filtered,
-            };
+            return {
+              order: (direction: 'asc' | 'desc' = 'asc') => {
+                const ordered =
+                  direction === 'desc' ? filtered : filtered.slice().reverse();
+                return {
+                  collect: async () => ordered,
+                  take: async (count: number) => ordered.slice(0, count),
+                };
+              },
+              collect: async () => filtered,
+            };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/__tests__/messages.test.ts` around lines 121 - 127, The mock
implementation of order() ignores its direction argument, so calls like
order('asc') incorrectly return the same pre-sorted filtered array; update the
mock for order(direction?: string) to respect the direction param by producing a
local array (e.g., sorted = direction === 'asc' ? [...filtered].reverse() :
filtered) and return the object with collect and take using that sorted array
(ensure take slices sorted, and collect returns sorted). Replace the existing
order/collect/take mock to use the direction-aware sorted array so future tests
using order('asc') behave correctly.
frontend/hooks/useResolvedImageUrls.ts (2)

33-34: Silent error swallowing makes URL resolution failures invisible

Any error from convex.query(api.listings.getListingImageUrl, …) is silently converted to null. A console.warn here makes transient network/auth issues visible during development without changing observable behavior.

♻️ Proposed fix
         } catch {
-          return [storageId, null] as const;
+          // eslint-disable-next-line no-console
+          console.warn(`[useResolvedImageUrls] Failed to resolve URL for storageId: ${storageId}`);
+          return [storageId, null] as const;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/hooks/useResolvedImageUrls.ts` around lines 33 - 34, In
useResolvedImageUrls, the catch block that swallows errors for the
convex.query(api.listings.getListingImageUrl, ...) call should log the error so
failures are visible during development; update the catch to call console.warn
(including the error and the storageId being resolved) and then continue to
return [storageId, null] as before so behavior is unchanged while making
transient network/auth issues visible.

15-51: No in-flight guard — the same storage IDs can be fetched more than once

resolvedUrlsRef.current[storageId] remains undefined until the setResolvedUrls callback executes. If imageIds gets a new object identity (e.g., the parent re-renders and passes listing?.images ?? [] while the first fetch batch is in-flight), the effect re-runs after cleanup (cancelled = true), sees all IDs as still-unresolved, and fires another full fetch round. The first round's state update is suppressed by cancelled, so the second round wins—but both rounds hit the network.

Eagerly marking IDs as pending in the ref before await prevents the duplicate work:

♻️ Proposed fix — mark IDs in-flight before the async calls
     const unresolved = imageIds.filter(
       (storageId) => !isRemoteUrl(storageId) && resolvedUrlsRef.current[storageId] === undefined
     );
     if (unresolved.length === 0) {
       return;
     }

+    // Mark in-flight so concurrent effect runs skip these IDs.
+    for (const storageId of unresolved) {
+      resolvedUrlsRef.current[storageId] = null;
+    }
+
     const nextEntries = await Promise.all(

Note: Using null as the in-flight sentinel means a failed fetch and a pending fetch are indistinguishable — retries won't happen automatically. If retry-on-failure is needed later, introduce a distinct sentinel (e.g., a "pending" string or a Set).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/hooks/useResolvedImageUrls.ts` around lines 15 - 51, The effect can
start duplicate fetches because resolvedUrlsRef.current[storageId] stays
undefined until setResolvedUrls runs; in resolveImageUrls mark each unresolved
storageId as in-flight in resolvedUrlsRef (and in the state via setResolvedUrls)
before doing the Promise.all so subsequent runs see them as pending and skip
refetching; use a distinct sentinel (e.g., "pending" or a symbol) instead of
null so pending vs failed can be distinguished, and ensure the cleanup/cancel
logic still prevents applying results from cancelled batches.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/convex/listings.ts`:
- Line 104: The tags schema currently uses v.optional(v.array(v.string())) with
no limits, allowing arbitrarily many or long tags; create a reusable tags
validator (e.g., tagsSchema or validateTags) that enforces a maximum number of
tags (e.g., maxItems) and a maximum tag length (e.g., maxLength per string),
then replace the loose v.optional(v.array(v.string())) in the createListing and
updateListing handlers (and the other schema occurrences referenced near the
tags lines) with this new tagsSchema; on validation failure return a clear
validation error rather than accepting/truncating silently.
- Around line 113-121: The current logic allows a caller-supplied
args.sellerEmail to be used when identity.email is absent, enabling spoofing;
change the code in the listing creation/update flow to require a verified
identity email instead of falling back to args.sellerEmail: obtain
authenticatedEmail from identity.email, and if it's missing or empty throw an
Error (e.g., "Authenticated email required"), never accept providedEmail as the
canonical sellerEmail; update any uses of sellerEmail (the variable assigned
from authenticatedEmail ?? providedEmail) so they always use the
authenticatedEmail and stop trusting args.sellerEmail in
getListings()/getListing() visible fields.

In `@backend/convex/messages.ts`:
- Line 161: Replace the boolean-coercing default operator in the assignment to
preserve explicit zero values: change the `limit` initialization from using
`args.limit || 20` to the nullish-coalescing form so that `args.limit` (which
can be 0 per the validator) is respected; locate the `const limit = args.limit
|| 20;` line in messages.ts and update it to use `??` (i.e., `const limit =
args.limit ?? 20;`).

---

Nitpick comments:
In `@backend/convex/__tests__/messages.test.ts`:
- Around line 187-236: Add two unit tests for sendMessage to cover its error
paths: one where ctx.db.get(conversationId) returns null to assert the
"Conversation not found" error is thrown, and another where the conversation
exists but its participantIds does not include the senderId to assert the "User
is not a participant in this conversation" error is thrown; reuse buildCtx to
construct ctx (override docsById or participantRows accordingly) and call
sendMessage with the same args as the happy-path test, then expect the
appropriate exception messages to be thrown and no message to be
inserted/updated.
- Around line 121-127: The mock implementation of order() ignores its direction
argument, so calls like order('asc') incorrectly return the same pre-sorted
filtered array; update the mock for order(direction?: string) to respect the
direction param by producing a local array (e.g., sorted = direction === 'asc' ?
[...filtered].reverse() : filtered) and return the object with collect and take
using that sorted array (ensure take slices sorted, and collect returns sorted).
Replace the existing order/collect/take mock to use the direction-aware sorted
array so future tests using order('asc') behave correctly.

In `@backend/convex/messages.ts`:
- Around line 149-152: The handler listUserConversationsHandler currently
returns an untyped Promise (conversationList is inferred as any); add a concrete
return type interface (e.g., Promise<ListUserConversationsResult> or similar)
and apply it to listUserConversationsHandler signature so the array elements
(conversationList) are typed; define the ListUserConversationsResult (or
appropriate shape) describing each conversation item and use that type for
conversationList and the function return to ensure type safety propagates to the
listUserConversations query and consumers.

In `@frontend/hooks/useResolvedImageUrls.ts`:
- Around line 33-34: In useResolvedImageUrls, the catch block that swallows
errors for the convex.query(api.listings.getListingImageUrl, ...) call should
log the error so failures are visible during development; update the catch to
call console.warn (including the error and the storageId being resolved) and
then continue to return [storageId, null] as before so behavior is unchanged
while making transient network/auth issues visible.
- Around line 15-51: The effect can start duplicate fetches because
resolvedUrlsRef.current[storageId] stays undefined until setResolvedUrls runs;
in resolveImageUrls mark each unresolved storageId as in-flight in
resolvedUrlsRef (and in the state via setResolvedUrls) before doing the
Promise.all so subsequent runs see them as pending and skip refetching; use a
distinct sentinel (e.g., "pending" or a symbol) instead of null so pending vs
failed can be distinguished, and ensure the cleanup/cancel logic still prevents
applying results from cancelled batches.

Comment thread backend/convex/listings.ts
Comment thread backend/convex/listings.ts Outdated
Comment thread backend/convex/messages.ts Outdated
const userId = identity.subject;

// Step 2: Query participant rows by indexed userId + activity timestamp.
const limit = args.limit || 20;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use ?? instead of || for the limit default.

args.limit || 20 coerces 0 to 20. The validator (v.optional(v.number())) allows 0, and ?? correctly distinguishes "not provided" from "explicitly zero".

🐛 Proposed fix
-  const limit = args.limit || 20;
+  const limit = args.limit ?? 20;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/messages.ts` at line 161, Replace the boolean-coercing default
operator in the assignment to preserve explicit zero values: change the `limit`
initialization from using `args.limit || 20` to the nullish-coalescing form so
that `args.limit` (which can be 0 per the validator) is respected; locate the
`const limit = args.limit || 20;` line in messages.ts and update it to use `??`
(i.e., `const limit = args.limit ?? 20;`).

@jaydonkc
Copy link
Copy Markdown
Collaborator

jaydonkc commented Mar 2, 2026

Created clean companion branch jaydon/clean-pr-43.

Feedback:

  • Branch has broad conflict surface; please keep uploader scoped (uploader UI + storage flow only).
  • Avoid mixing generated artifacts and unrelated schema churn where possible.
  • Recommend splitting into smaller follow-ups if CI remains noisy.

@jaydonkc jaydonkc force-pushed the POLY-41-image-uploader branch from 4bdbc93 to 5c5576b Compare March 2, 2026 22:13
@jaydonkc jaydonkc merged commit a3a5ecc into dev Mar 2, 2026
3 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Mar 11, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants