feat/admin #2

Merged
admin merged 10 commits from feat/admin into main 2026-03-07 20:51:13 +00:00
9 changed files with 955 additions and 7 deletions
Showing only changes of commit 0f91d3dc05 - Show all commits

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