diff --git a/packages/core/src/routes/admin-api.ts b/packages/core/src/routes/admin-api.ts index 49e592c..a7d04b1 100644 --- a/packages/core/src/routes/admin-api.ts +++ b/packages/core/src/routes/admin-api.ts @@ -166,26 +166,56 @@ async function queryOrders( where.createdAt = range; } - const result = await storeOf(ctx, "orders").query({ - where, - orderBy: { createdAt: "desc" }, - limit, - ...(cursor ? { cursor } : {}), - }); - let items = result.items.map((r) => ({ ...(r.data as Order), id: r.id })); - if (currency) items = items.filter((o) => o.currency === currency); - if (email) { - items = items.filter((o) => o.customerEmail.toLowerCase().includes(email)); - } - const minTotal = minTotalRaw ? Number.parseInt(minTotalRaw, 10) : null; - const maxTotal = maxTotalRaw ? Number.parseInt(maxTotalRaw, 10) : null; - if (Number.isFinite(minTotal ?? NaN)) { - items = items.filter((o) => o.total.amount >= (minTotal ?? 0)); - } - if (Number.isFinite(maxTotal ?? NaN)) { - items = items.filter((o) => o.total.amount <= (maxTotal ?? 0)); + try { + // Only include `where` if it has properties; older EmDash versions + // might not handle empty where clauses consistently. + const hasWhere = Object.keys(where).length > 0; + // CRITICAL: Do NOT use `orderBy` on Postgres — EmDash generates + // `->>` JSON path extraction on TEXT columns which triggers + // "operator does not exist: text ->> unknown". Fetch without + // orderBy and sort in JS. Works on D1/SQLite and Postgres. + const result = await storeOf(ctx, "orders").query({ + ...(hasWhere ? { where } : {}), + limit, + ...(cursor ? { cursor } : {}), + }); + let items = result.items.map((r) => ({ ...(r.data as Order), id: r.id })); + // Sort by createdAt desc in JS (replaces SQL ORDER BY) + items.sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateB - dateA; // desc + }); + if (currency) items = items.filter((o) => o.currency === currency); + if (email) { + items = items.filter((o) => o.customerEmail.toLowerCase().includes(email)); + } + const minTotal = minTotalRaw ? Number.parseInt(minTotalRaw, 10) : null; + const maxTotal = maxTotalRaw ? Number.parseInt(maxTotalRaw, 10) : null; + if (Number.isFinite(minTotal ?? NaN)) { + items = items.filter((o) => o.total.amount >= (minTotal ?? 0)); + } + if (Number.isFinite(maxTotal ?? NaN)) { + items = items.filter((o) => o.total.amount <= (maxTotal ?? 0)); + } + return json({ items, cursor: result.cursor, hasMore: result.hasMore }); + } catch (err) { + ctx.log.error("queryOrders failed", { + error: err instanceof Error ? err.message : String(err), + where, + hasWhere: Object.keys(where).length > 0, + }); + return json( + { + error: "query_failed", + message: + err instanceof Error + ? err.message + : "Could not query orders — please contact support", + }, + 500, + ); } - return json({ items, cursor: result.cursor, hasMore: result.hasMore }); } async function getOrderDetail( @@ -337,24 +367,47 @@ async function queryCustomers( where.createdAt = range; } - const result = await storeOf(ctx, "customers").query({ - where, - orderBy: { createdAt: "desc" }, - limit, - ...(cursor ? { cursor } : {}), - }); - let items = result.items.map((r) => ({ ...(r.data as Customer), id: r.id })); - if (search) { - items = items.filter( - (c) => - c.email.toLowerCase().includes(search) || - (c.firstName ?? "").toLowerCase().includes(search) || - (c.lastName ?? "").toLowerCase().includes(search), + try { + const hasWhere = Object.keys(where).length > 0; + // Omit orderBy to avoid Postgres "text ->> unknown" error + const result = await storeOf(ctx, "customers").query({ + ...(hasWhere ? { where } : {}), + limit, + ...(cursor ? { cursor } : {}), + }); + let items = result.items.map((r) => ({ ...(r.data as Customer), id: r.id })); + // Sort by createdAt desc in JS + items.sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateB - dateA; + }); + if (search) { + items = items.filter( + (c) => + c.email.toLowerCase().includes(search) || + (c.firstName ?? "").toLowerCase().includes(search) || + (c.lastName ?? "").toLowerCase().includes(search), + ); + } + if (guestFilter === "true") items = items.filter((c) => !c.userId); + if (guestFilter === "false") items = items.filter((c) => Boolean(c.userId)); + return json({ items, cursor: result.cursor, hasMore: result.hasMore }); + } catch (err) { + ctx.log.error("queryCustomers failed", { + error: err instanceof Error ? err.message : String(err), + }); + return json( + { + error: "query_failed", + message: + err instanceof Error + ? err.message + : "Could not query customers — please contact support", + }, + 500, ); } - if (guestFilter === "true") items = items.filter((c) => !c.userId); - if (guestFilter === "false") items = items.filter((c) => Boolean(c.userId)); - return json({ items, cursor: result.cursor, hasMore: result.hasMore }); } async function getCustomerDetail( @@ -954,33 +1007,56 @@ async function listSubscriptions( where.createdAt = range; } - const result = await storeOf(ctx, "subscriptions").query({ - where, - orderBy: { createdAt: "desc" }, - limit, - ...(cursor ? { cursor } : {}), - }); - let items = result.items.map((r) => ({ - ...(r.data as Subscription), - id: r.id, - })); - if (productId) items = items.filter((s) => s.productId === productId); - if (email) { - // Email lookup requires joining customers; fetch the affected set - // lazily to avoid a full customer scan. - const customerIds = Array.from(new Set(items.map((s) => s.customerId))); - const customers = await Promise.all( - customerIds.map(async (id) => { - const c = await storeOf(ctx, "customers").get(id); - return [id, c?.email?.toLowerCase() ?? ""] as const; - }), - ); - const emailById = new Map(customers); - items = items.filter((s) => - (emailById.get(s.customerId) ?? "").includes(email), + try { + const hasWhere = Object.keys(where).length > 0; + // Omit orderBy to avoid Postgres "text ->> unknown" error + const result = await storeOf(ctx, "subscriptions").query({ + ...(hasWhere ? { where } : {}), + limit, + ...(cursor ? { cursor } : {}), + }); + let items = result.items.map((r) => ({ + ...(r.data as Subscription), + id: r.id, + })); + // Sort by createdAt desc in JS + items.sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateB - dateA; + }); + if (productId) items = items.filter((s) => s.productId === productId); + if (email) { + // Email lookup requires joining customers; fetch the affected set + // lazily to avoid a full customer scan. + const customerIds = Array.from(new Set(items.map((s) => s.customerId))); + const customers = await Promise.all( + customerIds.map(async (id) => { + const c = await storeOf(ctx, "customers").get(id); + return [id, c?.email?.toLowerCase() ?? ""] as const; + }), + ); + const emailById = new Map(customers); + items = items.filter((s) => + (emailById.get(s.customerId) ?? "").includes(email), + ); + } + return json({ items, cursor: result.cursor, hasMore: result.hasMore }); + } catch (err) { + ctx.log.error("listSubscriptions failed", { + error: err instanceof Error ? err.message : String(err), + }); + return json( + { + error: "query_failed", + message: + err instanceof Error + ? err.message + : "Could not query subscriptions — please contact support", + }, + 500, ); } - return json({ items, cursor: result.cursor, hasMore: result.hasMore }); } async function postSubscriptionAction( @@ -1027,7 +1103,7 @@ async function postSubscriptionAction( async function listReviews(ctx: PluginContext, req: Request): Promise { const url = new URL(req.url); - const status = url.searchParams.get("status") ?? "pending"; + const status = url.searchParams.get("status"); const productId = url.searchParams.get("productId"); const ratingRaw = url.searchParams.get("rating"); const verifiedOnly = url.searchParams.get("verifiedOnly") === "true"; @@ -1039,9 +1115,8 @@ async function listReviews(ctx: PluginContext, req: Request): Promise Math.max(1, Number.parseInt(url.searchParams.get("limit") ?? "50", 10) || 50), ); - const where: Record = { - status, - }; + const where: Record = {}; + if (status) where.status = status; if (productId) where.productId = productId; if (from || to) { const range: { gte?: string; lte?: string } = {}; @@ -1050,19 +1125,47 @@ async function listReviews(ctx: PluginContext, req: Request): Promise where.createdAt = range; } - const result = await storeOf(ctx, "reviews").query({ - where, - orderBy: { createdAt: "desc" }, - limit, - ...(cursor ? { cursor } : {}), - }); - let items = result.items.map((r) => ({ ...(r.data as Review), id: r.id })); - const rating = ratingRaw ? Number.parseInt(ratingRaw, 10) : null; - if (rating && rating >= 1 && rating <= 5) { - items = items.filter((r) => r.rating === rating); + try { + const hasWhere = Object.keys(where).length > 0; + // Omit orderBy to avoid Postgres "text ->> unknown" error + const result = await storeOf(ctx, "reviews").query({ + ...(hasWhere ? { where } : {}), + limit, + ...(cursor ? { cursor } : {}), + }); + let items = result.items.map((r) => ({ ...(r.data as Review), id: r.id })); + // Sort by createdAt desc in JS + items.sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateB - dateA; + }); + // Apply post-fetch filters: status default, rating, verifiedOnly + if (!status) { + // Default to pending when no status filter was provided + items = items.filter((r) => r.status === "pending"); + } + const rating = ratingRaw ? Number.parseInt(ratingRaw, 10) : null; + if (rating && rating >= 1 && rating <= 5) { + items = items.filter((r) => r.rating === rating); + } + if (verifiedOnly) items = items.filter((r) => r.verifiedPurchase); + return json({ items, cursor: result.cursor, hasMore: result.hasMore }); + } catch (err) { + ctx.log.error("listReviews failed", { + error: err instanceof Error ? err.message : String(err), + }); + return json( + { + error: "query_failed", + message: + err instanceof Error + ? err.message + : "Could not query reviews — please contact support", + }, + 500, + ); } - if (verifiedOnly) items = items.filter((r) => r.verifiedPurchase); - return json({ items, cursor: result.cursor, hasMore: result.hasMore }); } async function postReviewModerate( diff --git a/packages/core/test/admin-api-orders.test.ts b/packages/core/test/admin-api-orders.test.ts new file mode 100644 index 0000000..c0c7ad6 --- /dev/null +++ b/packages/core/test/admin-api-orders.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, mock } from "bun:test"; +import { adminApiRoutes } from "../src/routes/admin-api"; +import type { PluginContext, RouteContext } from "emdash"; + +/** + * Regression tests for admin API query error handling. + * + * Verifies that query routes return structured error responses instead + * of throwing when the storage layer fails (e.g. version incompatibility, + * malformed query, or database unavailability). + */ + +function createMockContext(overrides?: { + queryThrows?: Error; + queryReturns?: { items: unknown[]; cursor: string | null; hasMore: boolean }; +}): PluginContext { + const errorLog = mock(() => {}); + const queryFn = overrides?.queryThrows + ? mock(() => { + throw overrides.queryThrows; + }) + : mock(() => Promise.resolve(overrides?.queryReturns ?? { items: [], cursor: null, hasMore: false })); + + return { + storage: { + orders: { query: queryFn }, + customers: { query: queryFn }, + subscriptions: { query: queryFn }, + reviews: { query: queryFn }, + }, + log: { + error: errorLog, + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), + }, + } as unknown as PluginContext; +} + +describe("admin orders query error handling", () => { + it("queryOrders: storage throw → HTTP 500 with error + ctx.log.error called", async () => { + const storageError = new Error("Storage query failed: version incompatibility"); + const ctx = createMockContext({ queryThrows: storageError }); + + const url = new URL("http://localhost/_emdash/api/plugins/dashcommerce/admin/orders"); + const req = new Request(url); + const routeCtx = { request: req } as RouteContext; + + const handler = adminApiRoutes["admin/orders"].handler; + const response = await handler(routeCtx, ctx); + + expect(response.status).toBe(500); + const body = (await response.json()) as { error: string; message: string }; + expect(body.error).toBe("query_failed"); + expect(body.message).toContain("Storage query failed"); + expect(ctx.log.error).toHaveBeenCalledTimes(1); + }); + + it("queryOrders: omits where clause when no filters provided", async () => { + const ctx = createMockContext(); + const url = new URL("http://localhost/_emdash/api/plugins/dashcommerce/admin/orders"); + const req = new Request(url); + const routeCtx = { request: req } as RouteContext; + + const handler = adminApiRoutes["admin/orders"].handler; + await handler(routeCtx, ctx); + + const queryCall = (ctx.storage as { orders: { query: ReturnType } }).orders.query.mock + .calls[0][0]; + expect("where" in queryCall).toBe(false); + // CRITICAL: orderBy must be omitted to avoid Postgres "text ->> unknown" error + expect("orderBy" in queryCall).toBe(false); + }); + + it("queryOrders: includes where clause when filters provided", async () => { + const ctx = createMockContext(); + const url = new URL( + "http://localhost/_emdash/api/plugins/dashcommerce/admin/orders?status=completed", + ); + const req = new Request(url); + const routeCtx = { request: req } as RouteContext; + + const handler = adminApiRoutes["admin/orders"].handler; + await handler(routeCtx, ctx); + + const queryCall = (ctx.storage as { orders: { query: ReturnType } }).orders.query.mock + .calls[0][0]; + expect("where" in queryCall).toBe(true); + expect(queryCall.where).toEqual({ status: "completed" }); + // CRITICAL: orderBy must be omitted even with filters + expect("orderBy" in queryCall).toBe(false); + }); + + it("queryCustomers: storage throw → HTTP 500 with error + ctx.log.error called", async () => { + const storageError = new Error("Database connection lost"); + const ctx = createMockContext({ queryThrows: storageError }); + + const url = new URL("http://localhost/_emdash/api/plugins/dashcommerce/admin/customers"); + const req = new Request(url); + const routeCtx = { request: req } as RouteContext; + + const handler = adminApiRoutes["admin/customers"].handler; + const response = await handler(routeCtx, ctx); + + expect(response.status).toBe(500); + const body = (await response.json()) as { error: string; message: string }; + expect(body.error).toBe("query_failed"); + expect(body.message).toContain("Database connection lost"); + expect(ctx.log.error).toHaveBeenCalledTimes(1); + }); + + it("listSubscriptions: storage throw → HTTP 500 with error + ctx.log.error called", async () => { + const storageError = new Error("Index not found"); + const ctx = createMockContext({ queryThrows: storageError }); + + const url = new URL("http://localhost/_emdash/api/plugins/dashcommerce/admin/subscriptions"); + const req = new Request(url); + const routeCtx = { request: req } as RouteContext; + + const handler = adminApiRoutes["admin/subscriptions"].handler; + const response = await handler(routeCtx, ctx); + + expect(response.status).toBe(500); + const body = (await response.json()) as { error: string; message: string }; + expect(body.error).toBe("query_failed"); + expect(body.message).toContain("Index not found"); + expect(ctx.log.error).toHaveBeenCalledTimes(1); + }); + + it("listReviews: storage throw → HTTP 500 with error + ctx.log.error called", async () => { + const storageError = new Error("Query timeout"); + const ctx = createMockContext({ queryThrows: storageError }); + + const url = new URL("http://localhost/_emdash/api/plugins/dashcommerce/admin/reviews"); + const req = new Request(url); + const routeCtx = { request: req } as RouteContext; + + const handler = adminApiRoutes["admin/reviews"].handler; + const response = await handler(routeCtx, ctx); + + expect(response.status).toBe(500); + const body = (await response.json()) as { error: string; message: string }; + expect(body.error).toBe("query_failed"); + expect(body.message).toContain("Query timeout"); + expect(ctx.log.error).toHaveBeenCalledTimes(1); + }); + + it("listReviews: omits where clause when no filters provided", async () => { + const ctx = createMockContext(); + const url = new URL("http://localhost/_emdash/api/plugins/dashcommerce/admin/reviews"); + const req = new Request(url); + const routeCtx = { request: req } as RouteContext; + + const handler = adminApiRoutes["admin/reviews"].handler; + await handler(routeCtx, ctx); + + const queryCall = (ctx.storage as { reviews: { query: ReturnType } }).reviews.query.mock + .calls[0][0]; + expect("where" in queryCall).toBe(false); + expect("orderBy" in queryCall).toBe(false); + }); + + it("listReviews: includes where clause when status filter provided", async () => { + const ctx = createMockContext(); + const url = new URL( + "http://localhost/_emdash/api/plugins/dashcommerce/admin/reviews?status=approved", + ); + const req = new Request(url); + const routeCtx = { request: req } as RouteContext; + + const handler = adminApiRoutes["admin/reviews"].handler; + await handler(routeCtx, ctx); + + const queryCall = (ctx.storage as { reviews: { query: ReturnType } }).reviews.query.mock + .calls[0][0]; + expect("where" in queryCall).toBe(true); + expect(queryCall.where).toEqual({ status: "approved" }); + expect("orderBy" in queryCall).toBe(false); + }); +}); diff --git a/packages/core/test/storage-query-shapes.test.ts b/packages/core/test/storage-query-shapes.test.ts new file mode 100644 index 0000000..0e68d17 --- /dev/null +++ b/packages/core/test/storage-query-shapes.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { PluginContext } from "emdash"; + +/** + * Test to isolate which query shapes trigger "text ->> unknown" on Postgres. + * + * Hypothesis: `orderBy` on indexed fields triggers JSON path extraction + * via `->>` operator, which fails when the storage column is TEXT not JSONB. + */ + +function createMockStorageContext(queryBehavior: "throw" | "success"): PluginContext { + const queryFn = mock((opts: { + where?: Record; + orderBy?: Record; + limit: number; + cursor?: string; + }) => { + // Simulate Postgres error when orderBy is present + if (queryBehavior === "throw" && opts.orderBy) { + throw new Error("operator does not exist: text ->> unknown"); + } + return Promise.resolve({ items: [], cursor: null, hasMore: false }); + }); + + return { + storage: { + orders: { query: queryFn }, + }, + log: { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), + }, + } as unknown as PluginContext; +} + +describe("storage query shapes that trigger text ->> unknown", () => { + it("orderBy on indexed field triggers error", async () => { + const ctx = createMockStorageContext("throw"); + + try { + await (ctx.storage as { orders: { query: (opts: unknown) => Promise } }).orders.query({ + orderBy: { createdAt: "desc" }, + limit: 50, + }); + throw new Error("Should have thrown"); + } catch (err) { + expect((err as Error).message).toContain("text ->> unknown"); + } + }); + + it("query without orderBy succeeds", async () => { + const ctx = createMockStorageContext("throw"); + + const result = await (ctx.storage as { orders: { query: (opts: unknown) => Promise<{ items: unknown[] }> } }).orders.query({ + limit: 50, + }); + + expect(result.items).toEqual([]); + }); + + it("where clause on indexed field without orderBy succeeds", async () => { + const ctx = createMockStorageContext("throw"); + + const result = await (ctx.storage as { orders: { query: (opts: unknown) => Promise<{ items: unknown[] }> } }).orders.query({ + where: { status: "completed" }, + limit: 50, + }); + + expect(result.items).toEqual([]); + }); +});