diff --git a/convex/CLAUDE.md b/convex/CLAUDE.md new file mode 100644 index 0000000..5c515aa --- /dev/null +++ b/convex/CLAUDE.md @@ -0,0 +1,240 @@ +# Convex Backend — Implementation Rules + +Applies to all files inside `convex/`. These rules govern how to write, organize, +and call Convex functions in this project. + +--- + +## 1. Function Syntax + +Always use the **new object syntax** with explicit `args` and `handler`: + +```typescript +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const myFunction = query({ + args: { id: v.id("products") }, + handler: async (ctx, args) => { + // ... + }, +}); +``` + +--- + +## 2. Function Registration + +| Visibility | Decorators to use | +|------------|------------------| +| Public API | `query`, `mutation`, `action` | +| Private / internal | `internalQuery`, `internalMutation`, `internalAction` | + +- **Always include argument validators** on every function — public and internal alike. +- Do NOT register functions through the `api` or `internal` objects — those are for calling, not registering. +- If a function returns nothing, it implicitly returns `null`. Do not return `undefined`. + +--- + +## 3. HTTP Endpoints + +Define all HTTP endpoints in `convex/http.ts` using `httpAction`: + +```typescript +import { httpRouter } from "convex/server"; +import { httpAction } from "./_generated/server"; + +const http = httpRouter(); + +http.route({ + path: "/api/some-route", + method: "POST", + handler: httpAction(async (ctx, req) => { + const body = await req.bytes(); + return new Response(body, { status: 200 }); + }), +}); +``` + +Endpoints are registered at the exact path specified — no automatic prefix is added. + +--- + +## 4. Function Calling + +- `ctx.runQuery` — call a query from a query, mutation, or action +- `ctx.runMutation` — call a mutation from a mutation or action +- `ctx.runAction` — call an action from an action (only cross-runtime: V8 → Node) +- All calls take a **FunctionReference** — never pass the function directly +- Minimize query/mutation calls from actions; splitting logic creates race conditions +- When calling a function **in the same file**, add a type annotation on the return value to avoid TypeScript circularity errors: + +```typescript +const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); +``` + +--- + +## 5. Function References + +- Public functions → `api` object from `convex/_generated/api.ts` +- Internal functions → `internal` object from `convex/_generated/api.ts` +- File-based routing: `convex/products.ts` → `api.products.myFn`; `convex/model/users.ts` → `api.model.users.myFn` + +--- + +## 6. Validators + +Use `v` from `convex/values`. Valid types: + +| Convex Type | Validator | Notes | +|-------------|-----------|-------| +| Id | `v.id("tableName")` | | +| String | `v.string()` | UTF-8, max 1 MB | +| Number | `v.number()` | IEEE-754 float64 | +| Boolean | `v.boolean()` | | +| Null | `v.null()` | Use instead of `undefined` | +| Int64 | `v.int64()` | BigInt between -2^63 and 2^63-1 | +| Bytes | `v.bytes()` | ArrayBuffer, max 1 MB | +| Array | `v.array(values)` | Max 8192 elements | +| Object | `v.object({...})` | Max 1024 entries; keys cannot start with `$` or `_` | +| Record | `v.record(keys, values)` | Dynamic keys; ASCII only, no `$`/`_` prefix | +| Union | `v.union(...)` | Discriminated unions supported | +| Optional | `v.optional(...)` | | + +- `v.bigint()` is **deprecated** — use `v.int64()` instead +- `v.map()` and `v.set()` are **not supported** — use `v.record()` instead + +--- + +## 7. Schema + +- Define all tables in `convex/schema.ts` +- Import from `convex/server`: `defineSchema`, `defineTable` +- System fields (`_id`, `_creationTime`) are added automatically — do not define them +- Index naming: always include all indexed fields — e.g. `["field1", "field2"]` → name `"by_field1_and_field2"` +- Index fields must be queried in the same order they are defined; create separate indexes for different orderings + +--- + +## 8. TypeScript + +- Use `Id<"tableName">` from `./_generated/dataModel` for document ID types — not plain `string` +- Use `Doc<"tableName">` for full document types +- Use `as const` for string literals in discriminated unions +- Declare arrays as `const arr: Array = [...]` +- Declare records as `const rec: Record = {...}` + +--- + +## 9. Queries + +- **Never use `.filter()`** — define an index in the schema and use `.withIndex()` instead +- Use `.unique()` to assert a single result (throws if multiple match) +- Use `.take(n)` or `.paginate()` to limit results +- Prefer `for await (const row of query)` for async iteration over `.collect()` + loop +- Convex does **not** support `.delete()` on queries — collect results then `ctx.db.delete(row._id)` each one +- Default sort order is ascending `_creationTime`; use `.order("asc" | "desc")` to override + +### Full-text search + +```typescript +const results = await ctx.db + .query("products") + .withSearchIndex("search_name", (q) => + q.search("name", "dog food").eq("category", "dogs"), + ) + .take(10); +``` + +### Pagination + +```typescript +import { paginationOptsValidator } from "convex/server"; + +export const list = query({ + args: { paginationOpts: paginationOptsValidator, category: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("products") + .withIndex("by_category", (q) => q.eq("category", args.category)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); +// Returns: { page, isDone, continueCursor } +``` + +--- + +## 10. Mutations + +- `ctx.db.patch(id, fields)` — shallow merge; throws if document does not exist +- `ctx.db.replace(id, doc)` — full replace; throws if document does not exist +- `ctx.db.insert(table, doc)` — insert new document, returns `Id` +- `ctx.db.delete(id)` — delete document by id + +--- + +## 11. Actions + +- Add `"use node";` at the top of files whose actions use Node.js built-ins +- **Never** put `"use node";` in a file that also exports queries or mutations — separate the action into its own file +- `fetch()` is available in the default Convex (V8) runtime — no `"use node"` needed just for fetch +- **Never** use `ctx.db` inside an action — actions have no database access; use `ctx.runQuery` / `ctx.runMutation` instead +- Only call an action from another action when you need to cross runtimes; otherwise extract shared logic into a plain async helper + +```typescript +"use node"; +import { internalAction } from "./_generated/server"; + +export const myAction = internalAction({ + args: {}, + handler: async (ctx, args) => { + // Node.js built-ins available here + return null; + }, +}); +``` + +--- + +## 12. Scheduling & Crons + +- Use only `crons.interval` or `crons.cron` — do NOT use `crons.hourly`, `crons.daily`, or `crons.weekly` +- Both methods take a FunctionReference — never pass the function directly +- Export `crons` as `default` from `convex/crons.ts` +- Always import `internal` from `_generated/api` when a cron calls an internal function, even in the same file + +```typescript +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; + +const crons = cronJobs(); + +crons.interval("cleanup expired carts", { hours: 24 }, internal.carts.cleanupExpired, {}); + +export default crons; +``` + +--- + +## 13. File Storage + +- `ctx.storage.getUrl(storageId)` — returns a signed URL or `null` if the file doesn't exist +- Do **not** use the deprecated `ctx.storage.getMetadata()` — query the `_storage` system table instead: + +```typescript +const metadata = await ctx.db.system.get("_storage", args.fileId); +``` + +- Storage items are `Blob` objects — convert to/from `Blob` when reading or writing + +--- + +## 14. API Design & File Organisation + +- Use file-based routing to organise public functions logically: `convex/products.ts`, `convex/orders.ts`, etc. +- Extract shared business logic into `convex/model/*.ts` helpers and import them into public function files +- Keep `internalQuery`/`internalMutation`/`internalAction` in the same file as the public function that calls them, unless the file would exceed a manageable size +- Avoid chaining many `ctx.runQuery`/`ctx.runMutation` calls from a single action — race conditions become likely diff --git a/convex/convex.config.ts b/convex/convex.config.ts new file mode 100644 index 0000000..bd13f50 --- /dev/null +++ b/convex/convex.config.ts @@ -0,0 +1,7 @@ +import { defineApp } from "convex/server"; +import resend from "@convex-dev/resend/convex.config.js"; + +const app = defineApp(); +app.use(resend); + +export default app; diff --git a/convex/http.ts b/convex/http.ts index 7739f0b..b4ed0a5 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -134,4 +134,16 @@ http.route({ }), }); +http.route({ + path: "/shippo/webhook", + method: "POST", + handler: httpAction(async (ctx, request) => { + const body = await request.text(); + // Always respond 200 first — Shippo retries on non-2xx, so we must not + // let internal errors cause retry storms. Errors are logged in the action. + await ctx.runAction(internal.shippoWebhook.handleTrackUpdated, { body }); + return new Response(null, { status: 200 }); + }), +}); + export default http; \ No newline at end of file diff --git a/convex/model/orders.ts b/convex/model/orders.ts index 934ca9f..b15dbe5 100644 --- a/convex/model/orders.ts +++ b/convex/model/orders.ts @@ -1,6 +1,24 @@ -import { QueryCtx } from "../_generated/server"; +import { QueryCtx, MutationCtx } from "../_generated/server"; import { Id, Doc } from "../_generated/dataModel"; +export async function recordOrderTimelineEvent( + ctx: MutationCtx, + args: { + orderId: Id<"orders">; + eventType: string; + source: string; + fromStatus?: string; + toStatus?: string; + payload?: string; + userId?: Id<"users">; + }, +): Promise { + await ctx.db.insert("orderTimelineEvents", { + ...args, + createdAt: Date.now(), + }); +} + export async function getOrderWithItems( ctx: QueryCtx, orderId: Id<"orders">, @@ -48,6 +66,37 @@ export function canCustomerCancel(order: Doc<"orders">): { } } +/** + * Determines whether a customer is allowed to request a return for a given order. + * + * Eligibility: order must be `delivered` (customer has received the goods), + * return not yet requested, and not already refunded. + * Returns and cancellations are separate flows — cancellation is only available + * on `confirmed` orders (before fulfilment begins). + */ +export function canCustomerRequestReturn(order: Doc<"orders">): { + allowed: boolean; + reason?: string; +} { + if (order.status !== "delivered") { + return { + allowed: false, + reason: + "Returns are only available for delivered orders.", + }; + } + if (order.returnRequestedAt) { + return { + allowed: false, + reason: "A return has already been requested for this order.", + }; + } + if (order.paymentStatus === "refunded") { + return { allowed: false, reason: "This order has already been refunded." }; + } + return { allowed: true }; +} + export interface OutOfStockItem { variantId: Id<"productVariants">; requested: number; diff --git a/convex/model/products.ts b/convex/model/products.ts index 77c16c0..0f648e4 100644 --- a/convex/model/products.ts +++ b/convex/model/products.ts @@ -1,5 +1,5 @@ import { QueryCtx, MutationCtx } from "../_generated/server"; -import { Id } from "../_generated/dataModel"; +import type { Id, Doc } from "../_generated/dataModel"; /** * Recalculate product averageRating and reviewCount from approved reviews. @@ -57,10 +57,10 @@ export async function getProductWithRelations( export async function enrichProducts( ctx: QueryCtx, - products: Awaited>[], + products: Doc<"products">[], ) { return Promise.all( - products.map(async (product: any) => { + products.map(async (product) => { const [imagesRaw, variants] = await Promise.all([ ctx.db .query("productImages") diff --git a/convex/model/shippo.ts b/convex/model/shippo.ts index be741df..b8a9911 100644 --- a/convex/model/shippo.ts +++ b/convex/model/shippo.ts @@ -343,6 +343,68 @@ export async function getShippingRatesFromShippo(input: { return { shipmentObjectId: body.object_id, rates }; } +/** + * Fetches a Shippo shipment by ID and returns the `object_id` of the rate + * matching `serviceCode` (servicelevel.token) and `carrier` (provider). + * Throws a ConvexError if the shipment is not found, the rate is missing, + * or the API is unreachable. + */ +export async function getShipmentRateObjectId( + shipmentId: string, + serviceCode: string, + carrier: string, +): Promise { + const apiKey = process.env.SHIPPO_API_KEY; + if (!apiKey) { + throw new ConvexError("Shipping service unavailable (missing API key)."); + } + + let response: Response; + try { + response = await fetch(`${SHIPPO_SHIPMENTS_URL}${shipmentId}`, { + method: "GET", + headers: { Authorization: `ShippoToken ${apiKey}` }, + }); + } catch { + throw new ConvexError("Shippo service is unreachable. Please try again."); + } + + if (response.status === 404) { + throw new ConvexError(`Shipment "${shipmentId}" not found in Shippo.`); + } + if (!response.ok) { + throw new ConvexError(`Shippo service error (status ${response.status}).`); + } + + let body: { + object_id: string; + rates: Array<{ + object_id: string; + provider: string; + servicelevel: { token: string; name: string }; + }>; + }; + try { + body = await response.json(); + } catch { + throw new ConvexError("Shippo returned an unexpected response."); + } + + const matching = body.rates.filter( + (r) => + r.servicelevel.token === serviceCode && + r.provider.toLowerCase() === carrier.toLowerCase(), + ); + + if (matching.length === 0) { + throw new ConvexError( + `No rate found for service "${serviceCode}" and carrier "${carrier}". The rate may have expired.`, + ); + } + + return matching[0].object_id; +} + export function selectBestRate(rates: ShippoRate[]): { selected: ShippoRate; alternatives: ShippoRate[]; diff --git a/convex/orders.timeline.test.ts b/convex/orders.timeline.test.ts new file mode 100644 index 0000000..7ca76ba --- /dev/null +++ b/convex/orders.timeline.test.ts @@ -0,0 +1,576 @@ +/** + * Phase 1 — orderTimelineEvents integration tests + * + * Covers: + * - recordOrderTimelineEvent (helper, via t.run) + * - fulfillFromCheckout → timeline event created (source: stripe_webhook) + * - fulfillFromCheckout → idempotent: no duplicate event on replay + * - orders.updateStatus → timeline event with fromStatus/toStatus/userId + * - orders.cancel → timeline event customer_cancel + * - orders.getTimeline → ordering, auth enforcement + */ +import { convexTest } from "convex-test"; +import { describe, it, expect } from "vitest"; +import { api, internal } from "./_generated/api"; +import type { MutationCtx } from "./_generated/server"; +import schema from "./schema"; +import { recordOrderTimelineEvent } from "./model/orders"; + +const modules = import.meta.glob("./**/*.ts"); + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +async function setupCustomerAndProduct(t: ReturnType) { + const asCustomer = t.withIdentity({ + name: "Alice", + email: "alice@example.com", + subject: "clerk_alice_tl", + }); + const userId = await asCustomer.mutation(api.users.store, {}); + + let variantId: any; + await t.run(async (ctx) => { + const categoryId = await ctx.db.insert("categories", { + name: "Toys", + slug: "toys-tl", + }); + const productId = await ctx.db.insert("products", { + name: "Ball", + slug: "ball-tl", + status: "active", + categoryId, + tags: [], + parentCategorySlug: "toys-tl", + childCategorySlug: "toys-tl", + }); + variantId = await ctx.db.insert("productVariants", { + productId, + name: "Red Ball", + sku: "BALL-TL-001", + price: 999, + stockQuantity: 50, + isActive: true, + weight: 200, + weightUnit: "g", + }); + }); + + return { asCustomer, userId, variantId }; +} + +async function setupAdmin(t: ReturnType) { + const asAdmin = t.withIdentity({ + name: "Admin", + email: "admin@example.com", + subject: "clerk_admin_tl", + }); + const adminId = await asAdmin.mutation(api.users.store, {}); + await t.run(async (ctx) => { + await ctx.db.patch(adminId, { role: "admin" }); + }); + return { asAdmin, adminId }; +} + +/** Insert a confirmed order + one order item directly into the DB. */ +async function makeConfirmedOrder( + t: ReturnType, + userId: any, + variantId: any, + quantity = 2, +) { + let orderId: any; + await t.run(async (ctx) => { + orderId = await ctx.db.insert("orders", { + orderNumber: `ORD-TL-${Math.random().toString(36).slice(2, 7).toUpperCase()}`, + userId, + email: "alice@example.com", + status: "confirmed", + paymentStatus: "paid", + subtotal: 999 * quantity, + tax: 0, + shipping: 500, + discount: 0, + total: 999 * quantity + 500, + currency: "GBP", + shippingAddressSnapshot: { + fullName: "Alice Smith", + firstName: "Alice", + lastName: "Smith", + addressLine1: "1 Test Lane", + city: "London", + postalCode: "E1 1AA", + country: "GB", + }, + billingAddressSnapshot: { + firstName: "Alice", + lastName: "Smith", + addressLine1: "1 Test Lane", + city: "London", + postalCode: "E1 1AA", + country: "GB", + }, + shippoShipmentId: "shp_test", + shippingMethod: "Standard", + shippingServiceCode: "std", + carrier: "DPD", + createdAt: Date.now(), + updatedAt: Date.now(), + }); + await ctx.db.insert("orderItems", { + orderId, + variantId, + productName: "Ball", + variantName: "Red Ball", + sku: "BALL-TL-001", + quantity, + unitPrice: 999, + totalPrice: 999 * quantity, + }); + }); + return orderId; +} + +/** Full setup required to invoke fulfillFromCheckout (user, address, cart item). */ +async function setupFulfillmentData(t: ReturnType) { + const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t); + + let addressId: any; + await t.run(async (ctx) => { + addressId = await ctx.db.insert("addresses", { + userId, + type: "shipping", + fullName: "Alice Smith", + firstName: "Alice", + lastName: "Smith", + phone: "+447911123456", + addressLine1: "1 Test Lane", + city: "London", + postalCode: "E1 1AA", + country: "GB", + isDefault: true, + }); + }); + + await asCustomer.mutation(api.carts.addItem, { variantId, quantity: 2 }); + + return { asCustomer, userId, variantId, addressId }; +} + +// ─── recordOrderTimelineEvent helper ───────────────────────────────────────── + +describe("recordOrderTimelineEvent", () => { + it("inserts a timeline event with all provided fields", async () => { + const t = convexTest(schema, modules); + const { userId, variantId } = await setupCustomerAndProduct(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + await t.run(async (ctx) => { + await recordOrderTimelineEvent(ctx as unknown as MutationCtx, { + orderId, + eventType: "status_change", + source: "admin", + fromStatus: "confirmed", + toStatus: "shipped", + userId, + }); + }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events).toHaveLength(1); + expect(events[0].eventType).toBe("status_change"); + expect(events[0].source).toBe("admin"); + expect(events[0].fromStatus).toBe("confirmed"); + expect(events[0].toStatus).toBe("shipped"); + expect(events[0].userId).toBe(userId); + }); + + it("sets createdAt automatically", async () => { + const t = convexTest(schema, modules); + const { userId, variantId } = await setupCustomerAndProduct(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + const before = Date.now(); + + await t.run(async (ctx) => { + await recordOrderTimelineEvent(ctx as unknown as MutationCtx, { + orderId, + eventType: "status_change", + source: "admin", + toStatus: "confirmed", + }); + }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events[0].createdAt).toBeGreaterThanOrEqual(before); + }); + + it("stores optional payload as a JSON string", async () => { + const t = convexTest(schema, modules); + const { userId, variantId } = await setupCustomerAndProduct(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + const payload = JSON.stringify({ amount: 1999, currency: "gbp" }); + + await t.run(async (ctx) => { + await recordOrderTimelineEvent(ctx as unknown as MutationCtx, { + orderId, + eventType: "refund", + source: "admin", + payload, + }); + }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events[0].payload).toBe(payload); + expect(JSON.parse(events[0].payload!).amount).toBe(1999); + }); +}); + +// ─── fulfillFromCheckout → timeline ────────────────────────────────────────── + +describe("orders.fulfillFromCheckout - timeline events", () => { + it("records a status_change event with toStatus: confirmed after order creation", async () => { + const t = convexTest(schema, modules); + const { userId, addressId } = await setupFulfillmentData(t); + + const orderId = await t.mutation(internal.orders.fulfillFromCheckout, { + stripeCheckoutSessionId: "cs_test_tl_001", + stripePaymentIntentId: "pi_test_tl_001", + convexUserId: userId, + addressId, + shipmentObjectId: "shp_tl_001", + shippingMethod: "Standard", + shippingServiceCode: "std", + carrier: "DPD", + amountTotal: 2498, + amountShipping: 500, + currency: "gbp", + }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events).toHaveLength(1); + expect(events[0].eventType).toBe("status_change"); + expect(events[0].source).toBe("stripe_webhook"); + expect(events[0].toStatus).toBe("confirmed"); + expect(events[0].fromStatus).toBeUndefined(); + expect(events[0].userId).toBeUndefined(); + }); + + it("does not record a duplicate event on replay (idempotent)", async () => { + const t = convexTest(schema, modules); + const { userId, addressId } = await setupFulfillmentData(t); + + const fulfillArgs = { + stripeCheckoutSessionId: "cs_test_tl_idem", + stripePaymentIntentId: null, + convexUserId: userId, + addressId, + shipmentObjectId: "shp_tl_idem", + shippingMethod: "Standard", + shippingServiceCode: "std", + carrier: "DPD", + amountTotal: null, + amountShipping: 500, + currency: null, + }; + + const orderId = await t.mutation( + internal.orders.fulfillFromCheckout, + fulfillArgs, + ); + // Replay — order already exists; should return same id without inserting a new event + const orderId2 = await t.mutation( + internal.orders.fulfillFromCheckout, + fulfillArgs, + ); + + expect(orderId2).toBe(orderId); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events).toHaveLength(1); + }); +}); + +// ─── orders.updateStatus → timeline ────────────────────────────────────────── + +describe("orders.updateStatus - timeline events", () => { + it("records a status_change event with correct fromStatus and toStatus", async () => { + const t = convexTest(schema, modules); + const { userId, variantId } = await setupCustomerAndProduct(t); + const { asAdmin } = await setupAdmin(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + await asAdmin.mutation(api.orders.updateStatus, { + id: orderId, + status: "shipped", + }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events).toHaveLength(1); + expect(events[0].eventType).toBe("status_change"); + expect(events[0].source).toBe("admin"); + expect(events[0].fromStatus).toBe("confirmed"); + expect(events[0].toStatus).toBe("shipped"); + }); + + it("records the admin userId on the event", async () => { + const t = convexTest(schema, modules); + const { userId, variantId } = await setupCustomerAndProduct(t); + const { asAdmin, adminId } = await setupAdmin(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + await asAdmin.mutation(api.orders.updateStatus, { + id: orderId, + status: "processing", + }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events[0].userId).toBe(adminId); + }); + + it("records a separate event for each status change", async () => { + const t = convexTest(schema, modules); + const { userId, variantId } = await setupCustomerAndProduct(t); + const { asAdmin } = await setupAdmin(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + await asAdmin.mutation(api.orders.updateStatus, { + id: orderId, + status: "processing", + }); + await asAdmin.mutation(api.orders.updateStatus, { + id: orderId, + status: "shipped", + }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events).toHaveLength(2); + expect(events.map((e) => e.toStatus)).toEqual( + expect.arrayContaining(["processing", "shipped"]), + ); + }); + + it("updates the order updatedAt timestamp", async () => { + const t = convexTest(schema, modules); + const { userId, variantId } = await setupCustomerAndProduct(t); + const { asAdmin } = await setupAdmin(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + const before = Date.now(); + await asAdmin.mutation(api.orders.updateStatus, { + id: orderId, + status: "shipped", + }); + + const order = await t.run(async (ctx) => ctx.db.get(orderId)); + expect(order!.updatedAt).toBeGreaterThanOrEqual(before); + }); +}); + +// ─── orders.cancel → timeline ───────────────────────────────────────────────── + +describe("orders.cancel - timeline events", () => { + it("records a customer_cancel event on cancel", async () => { + const t = convexTest(schema, modules); + const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + await asCustomer.mutation(api.orders.cancel, { id: orderId }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events).toHaveLength(1); + expect(events[0].eventType).toBe("customer_cancel"); + expect(events[0].source).toBe("customer_cancel"); + }); + + it("records fromStatus: confirmed and toStatus: cancelled", async () => { + const t = convexTest(schema, modules); + const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + await asCustomer.mutation(api.orders.cancel, { id: orderId }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events[0].fromStatus).toBe("confirmed"); + expect(events[0].toStatus).toBe("cancelled"); + }); + + it("records the customer userId on the event", async () => { + const t = convexTest(schema, modules); + const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + await asCustomer.mutation(api.orders.cancel, { id: orderId }); + + const events = await t.run(async (ctx) => + ctx.db + .query("orderTimelineEvents") + .withIndex("by_order", (q) => q.eq("orderId", orderId)) + .collect(), + ); + + expect(events[0].userId).toBe(userId); + }); +}); + +// ─── orders.getTimeline ─────────────────────────────────────────────────────── + +describe("orders.getTimeline", () => { + it("returns an empty array for an order with no timeline events", async () => { + const t = convexTest(schema, modules); + const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + const events = await asCustomer.query(api.orders.getTimeline, { orderId }); + expect(events).toHaveLength(0); + }); + + it("returns events in ascending createdAt order", async () => { + const t = convexTest(schema, modules); + const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + await t.run(async (ctx) => { + await ctx.db.insert("orderTimelineEvents", { + orderId, + eventType: "status_change", + source: "stripe_webhook", + toStatus: "confirmed", + createdAt: 3000, + }); + await ctx.db.insert("orderTimelineEvents", { + orderId, + eventType: "status_change", + source: "admin", + fromStatus: "confirmed", + toStatus: "processing", + createdAt: 1000, + }); + await ctx.db.insert("orderTimelineEvents", { + orderId, + eventType: "status_change", + source: "admin", + fromStatus: "processing", + toStatus: "shipped", + createdAt: 2000, + }); + }); + + const events = await asCustomer.query(api.orders.getTimeline, { orderId }); + + expect(events).toHaveLength(3); + expect(events[0].createdAt).toBe(1000); + expect(events[1].createdAt).toBe(2000); + expect(events[2].createdAt).toBe(3000); + }); + + it("throws if the order belongs to a different user", async () => { + const t = convexTest(schema, modules); + const { userId, variantId } = await setupCustomerAndProduct(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + const asBob = t.withIdentity({ + name: "Bob", + email: "bob@example.com", + subject: "clerk_bob_tl", + }); + await asBob.mutation(api.users.store, {}); + + await expect( + asBob.query(api.orders.getTimeline, { orderId }), + ).rejects.toThrow("Unauthorized: order does not belong to you"); + }); + + it("admin can view the timeline for any order", async () => { + const t = convexTest(schema, modules); + const { userId, variantId } = await setupCustomerAndProduct(t); + const { asAdmin } = await setupAdmin(t); + const orderId = await makeConfirmedOrder(t, userId, variantId); + + await t.run(async (ctx) => { + await ctx.db.insert("orderTimelineEvents", { + orderId, + eventType: "status_change", + source: "stripe_webhook", + toStatus: "confirmed", + createdAt: Date.now(), + }); + }); + + const events = await asAdmin.query(api.orders.getTimeline, { orderId }); + expect(events).toHaveLength(1); + }); + + it("throws if the order does not exist", async () => { + const t = convexTest(schema, modules); + const { asCustomer, userId, variantId } = await setupCustomerAndProduct(t); + + // Create and delete an order to get a stale ID + const orderId = await makeConfirmedOrder(t, userId, variantId); + await t.run(async (ctx) => { + await ctx.db.delete(orderId); + }); + + await expect( + asCustomer.query(api.orders.getTimeline, { orderId }), + ).rejects.toThrow("Order not found"); + }); +}); diff --git a/convex/wishlists.ts b/convex/wishlists.ts index e71fd7a..5ed4e33 100644 --- a/convex/wishlists.ts +++ b/convex/wishlists.ts @@ -2,7 +2,7 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import * as Users from "./model/users"; import { enrichProducts } from "./model/products"; -import type { Id } from "./_generated/dataModel"; +import type { Id, Doc } from "./_generated/dataModel"; export const list = query({ args: {}, @@ -18,7 +18,7 @@ export const list = query({ const productIds = [...new Set(rows.map((r) => r.productId))]; const products = ( await Promise.all(productIds.map((id) => ctx.db.get(id))) - ).filter(Boolean) as Awaited>[]; + ).filter((p): p is Doc<"products"> => p != null); const enriched = await enrichProducts(ctx, products); const productMap = new Map( diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 67aa671..1fdfac0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -132,7 +132,9 @@ export type OrderStatus = | "shipped" | "delivered" | "cancelled" - | "refunded"; + | "refunded" + | "return" + | "completed"; export type PaymentStatus = "pending" | "paid" | "failed" | "refunded";