feat(convex): add implementation rules and configuration for backend functions

- Introduced a comprehensive markdown document outlining implementation rules for Convex functions, including syntax, registration, HTTP endpoints, and TypeScript usage.
- Created a new configuration file for the Convex app, integrating the Resend service.
- Added a new HTTP route for handling Shippo webhooks to ensure proper response handling.
- Implemented integration tests for order timeline events, covering various scenarios including order fulfillment and status changes.
- Enhanced existing functions with type safety improvements and additional validation logic.

This commit establishes clear guidelines for backend development and improves the overall structure and reliability of the Convex application.
This commit is contained in:
2026-03-07 19:32:06 +03:00
parent 83a5172397
commit 0f91d3dc05
9 changed files with 955 additions and 7 deletions

240
convex/CLAUDE.md Normal file
View File

@@ -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<T> = [...]`
- Declare records as `const rec: Record<K, V> = {...}`
---
## 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

7
convex/convex.config.ts Normal file
View File

@@ -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;

View File

@@ -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; export default http;

View File

@@ -1,6 +1,24 @@
import { QueryCtx } from "../_generated/server"; import { QueryCtx, MutationCtx } from "../_generated/server";
import { Id, Doc } from "../_generated/dataModel"; 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<void> {
await ctx.db.insert("orderTimelineEvents", {
...args,
createdAt: Date.now(),
});
}
export async function getOrderWithItems( export async function getOrderWithItems(
ctx: QueryCtx, ctx: QueryCtx,
orderId: Id<"orders">, 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 { export interface OutOfStockItem {
variantId: Id<"productVariants">; variantId: Id<"productVariants">;
requested: number; requested: number;

View File

@@ -1,5 +1,5 @@
import { QueryCtx, MutationCtx } from "../_generated/server"; 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. * Recalculate product averageRating and reviewCount from approved reviews.
@@ -57,10 +57,10 @@ export async function getProductWithRelations(
export async function enrichProducts( export async function enrichProducts(
ctx: QueryCtx, ctx: QueryCtx,
products: Awaited<ReturnType<typeof ctx.db.query>>[], products: Doc<"products">[],
) { ) {
return Promise.all( return Promise.all(
products.map(async (product: any) => { products.map(async (product) => {
const [imagesRaw, variants] = await Promise.all([ const [imagesRaw, variants] = await Promise.all([
ctx.db ctx.db
.query("productImages") .query("productImages")

View File

@@ -343,6 +343,68 @@ export async function getShippingRatesFromShippo(input: {
return { shipmentObjectId: body.object_id, rates }; 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<string> {
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[]): { export function selectBestRate(rates: ShippoRate[]): {
selected: ShippoRate; selected: ShippoRate;
alternatives: ShippoRate[]; alternatives: ShippoRate[];

View File

@@ -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<typeof convexTest>) {
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<typeof convexTest>) {
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<typeof convexTest>,
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<typeof convexTest>) {
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");
});
});

View File

@@ -2,7 +2,7 @@ import { query, mutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import * as Users from "./model/users"; import * as Users from "./model/users";
import { enrichProducts } from "./model/products"; import { enrichProducts } from "./model/products";
import type { Id } from "./_generated/dataModel"; import type { Id, Doc } from "./_generated/dataModel";
export const list = query({ export const list = query({
args: {}, args: {},
@@ -18,7 +18,7 @@ export const list = query({
const productIds = [...new Set(rows.map((r) => r.productId))]; const productIds = [...new Set(rows.map((r) => r.productId))];
const products = ( const products = (
await Promise.all(productIds.map((id) => ctx.db.get(id))) await Promise.all(productIds.map((id) => ctx.db.get(id)))
).filter(Boolean) as Awaited<ReturnType<typeof ctx.db.get>>[]; ).filter((p): p is Doc<"products"> => p != null);
const enriched = await enrichProducts(ctx, products); const enriched = await enrichProducts(ctx, products);
const productMap = new Map( const productMap = new Map(

View File

@@ -132,7 +132,9 @@ export type OrderStatus =
| "shipped" | "shipped"
| "delivered" | "delivered"
| "cancelled" | "cancelled"
| "refunded"; | "refunded"
| "return"
| "completed";
export type PaymentStatus = "pending" | "paid" | "failed" | "refunded"; export type PaymentStatus = "pending" | "paid" | "failed" | "refunded";