feat/admin #2
240
convex/CLAUDE.md
Normal file
240
convex/CLAUDE.md
Normal 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
7
convex/convex.config.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
576
convex/orders.timeline.test.ts
Normal file
576
convex/orders.timeline.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user