Skip to content

Commit de6793a

Browse files
(SP: 1) [Admin] Align products list in use checks with DB column names (order_items/inventory_moves)
1 parent 5447700 commit de6793a

14 files changed

Lines changed: 218 additions & 174 deletions

File tree

frontend/app/[locale]/shop/admin/orders/page.tsx

Lines changed: 109 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ export default async function AdminOrdersPage({
6161
const hasNext = all.length > PAGE_SIZE;
6262
const items = all.slice(0, PAGE_SIZE);
6363

64+
const viewModels = items.map(order => {
65+
const currency = orderCurrency(order, locale);
66+
const totalMinor = pickMinor(order?.totalAmountMinor, order?.totalAmount);
67+
68+
return {
69+
id: order.id,
70+
createdAt: formatDate(order.createdAt, locale),
71+
paymentStatus: order.paymentStatus,
72+
totalFormatted:
73+
totalMinor === null ? '-' : formatMoney(totalMinor, currency, locale),
74+
itemCount: order.itemCount,
75+
paymentProvider: order.paymentProvider ?? '-',
76+
viewHref: `/shop/admin/orders/${order.id}`,
77+
viewAriaLabel: `View order ${order.id}`,
78+
};
79+
});
80+
6481
return (
6582
<>
6683
<ShopAdminTopbar />
@@ -91,84 +108,72 @@ export default async function AdminOrdersPage({
91108
<section className="mt-6" aria-label="Orders list">
92109
{/* Mobile cards */}
93110
<div className="md:hidden">
94-
{items.length === 0 ? (
111+
{viewModels.length === 0 ? (
95112
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
96113
No orders yet.
97114
</div>
98115
) : (
99116
<ul className="space-y-3">
100-
{items.map(order => {
101-
const currency = orderCurrency(order, locale);
102-
const totalMinor = pickMinor(
103-
order?.totalAmountMinor,
104-
order?.totalAmount
105-
);
106-
const totalFormatted =
107-
totalMinor === null
108-
? '-'
109-
: formatMoney(totalMinor, currency, locale);
110-
111-
return (
112-
<li
113-
key={order.id}
114-
className="rounded-lg border border-border bg-background p-4"
115-
>
116-
<div className="flex items-start justify-between gap-3">
117-
<div className="min-w-0">
118-
<div className="text-xs text-muted-foreground">
119-
{formatDate(order.createdAt, locale)}
120-
</div>
121-
<div className="mt-1">
122-
<span className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground">
123-
{order.paymentStatus}
124-
</span>
125-
</div>
117+
{viewModels.map(vm => (
118+
<li
119+
key={vm.id}
120+
className="rounded-lg border border-border bg-background p-4"
121+
>
122+
<div className="flex items-start justify-between gap-3">
123+
<div className="min-w-0">
124+
<div className="text-xs text-muted-foreground">
125+
{vm.createdAt}
126126
</div>
127-
128-
<div className="shrink-0 whitespace-nowrap text-right text-sm font-medium text-foreground">
129-
{totalFormatted}
127+
<div className="mt-1">
128+
<span className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground">
129+
{vm.paymentStatus}
130+
</span>
130131
</div>
131132
</div>
132133

133-
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
134-
<div>
135-
<dt className="text-muted-foreground">Items</dt>
136-
<dd className="text-foreground">{order.itemCount}</dd>
137-
</div>
134+
<div className="shrink-0 whitespace-nowrap text-right text-sm font-medium text-foreground">
135+
{vm.totalFormatted}
136+
</div>
137+
</div>
138138

139-
<div className="min-w-0">
140-
<dt className="text-muted-foreground">Provider</dt>
141-
<dd
142-
className="truncate text-foreground"
143-
title={order.paymentProvider ?? '-'}
144-
>
145-
{order.paymentProvider ?? '-'}
146-
</dd>
147-
</div>
139+
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
140+
<div>
141+
<dt className="text-muted-foreground">Items</dt>
142+
<dd className="text-foreground">{vm.itemCount}</dd>
143+
</div>
148144

149-
<div className="col-span-2">
150-
<dt className="text-muted-foreground">Order ID</dt>
151-
<dd
152-
className="break-all font-mono text-[11px] text-muted-foreground"
153-
title={order.id}
154-
>
155-
{order.id}
156-
</dd>
157-
</div>
158-
</dl>
145+
<div className="min-w-0">
146+
<dt className="text-muted-foreground">Provider</dt>
147+
<dd
148+
className="truncate text-foreground"
149+
title={vm.paymentProvider}
150+
>
151+
{vm.paymentProvider}
152+
</dd>
153+
</div>
159154

160-
<div className="mt-3">
161-
<Link
162-
href={`/shop/admin/orders/${order.id}`}
163-
className="inline-flex items-center justify-center rounded-md border border-border px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
164-
aria-label={`View order ${order.id}`}
155+
<div className="col-span-2">
156+
<dt className="text-muted-foreground">Order ID</dt>
157+
<dd
158+
className="break-all font-mono text-[11px] text-muted-foreground"
159+
title={vm.id}
165160
>
166-
View
167-
</Link>
161+
{vm.id}
162+
</dd>
168163
</div>
169-
</li>
170-
);
171-
})}
164+
</dl>
165+
166+
<div className="mt-3">
167+
<Link
168+
href={vm.viewHref}
169+
className="inline-flex items-center justify-center rounded-md border border-border px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
170+
aria-label={vm.viewAriaLabel}
171+
>
172+
View
173+
</Link>
174+
</div>
175+
</li>
176+
))}
172177
</ul>
173178
)}
174179
</div>
@@ -227,7 +232,7 @@ export default async function AdminOrdersPage({
227232
</thead>
228233

229234
<tbody className="divide-y divide-border">
230-
{items.length === 0 ? (
235+
{viewModels.length === 0 ? (
231236
<tr>
232237
<td
233238
className="px-3 py-6 text-muted-foreground"
@@ -237,57 +242,45 @@ export default async function AdminOrdersPage({
237242
</td>
238243
</tr>
239244
) : (
240-
items.map(order => {
241-
const currency = orderCurrency(order, locale);
242-
const totalMinor = pickMinor(
243-
order?.totalAmountMinor,
244-
order?.totalAmount
245-
);
246-
const totalFormatted =
247-
totalMinor === null
248-
? '-'
249-
: formatMoney(totalMinor, currency, locale);
250-
251-
return (
252-
<tr key={order.id} className="hover:bg-muted/50">
253-
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
254-
{formatDate(order.createdAt, locale)}
255-
</td>
256-
257-
<td className="px-3 py-2 whitespace-nowrap">
258-
<span className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground">
259-
{order.paymentStatus}
260-
</span>
261-
</td>
262-
263-
<td className="px-3 py-2 text-foreground whitespace-nowrap">
264-
{totalFormatted}
265-
</td>
266-
267-
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
268-
{order.itemCount}
269-
</td>
270-
271-
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
272-
{order.paymentProvider ?? '-'}
273-
</td>
274-
275-
<td className="px-3 py-2 font-mono text-xs text-muted-foreground break-all">
276-
{order.id}
277-
</td>
278-
279-
<td className="px-3 py-2 whitespace-nowrap">
280-
<Link
281-
href={`/shop/admin/orders/${order.id}`}
282-
className="rounded-md border border-border px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
283-
aria-label={`View order ${order.id}`}
284-
>
285-
View
286-
</Link>
287-
</td>
288-
</tr>
289-
);
290-
})
245+
viewModels.map(vm => (
246+
<tr key={vm.id} className="hover:bg-muted/50">
247+
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
248+
{vm.createdAt}
249+
</td>
250+
251+
<td className="px-3 py-2 whitespace-nowrap">
252+
<span className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground">
253+
{vm.paymentStatus}
254+
</span>
255+
</td>
256+
257+
<td className="px-3 py-2 text-foreground whitespace-nowrap">
258+
{vm.totalFormatted}
259+
</td>
260+
261+
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
262+
{vm.itemCount}
263+
</td>
264+
265+
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
266+
{vm.paymentProvider}
267+
</td>
268+
269+
<td className="px-3 py-2 font-mono text-xs text-muted-foreground break-all">
270+
{vm.id}
271+
</td>
272+
273+
<td className="px-3 py-2 whitespace-nowrap">
274+
<Link
275+
href={vm.viewHref}
276+
className="rounded-md border border-border px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
277+
aria-label={vm.viewAriaLabel}
278+
>
279+
View
280+
</Link>
281+
</td>
282+
</tr>
283+
))
291284
)}
292285
</tbody>
293286
</table>

frontend/app/[locale]/shop/admin/products/page.tsx

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import { AdminProductDeleteButton } from '@/components/shop/admin/admin-product-
88
import { AdminProductStatusToggle } from '@/components/shop/admin/admin-product-status-toggle';
99
import { AdminPagination } from '@/components/shop/admin/admin-pagination';
1010
import { db } from '@/db';
11-
import { products, productPrices } from '@/db/schema';
11+
import {
12+
inventoryMoves,
13+
orderItems,
14+
products,
15+
productPrices,
16+
} from '@/db/schema';
1217
import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency';
1318
import { parsePage } from '@/lib/pagination';
1419

@@ -37,6 +42,19 @@ export default async function AdminProductsPage({
3742
const offset = (page - 1) * PAGE_SIZE;
3843

3944
const displayCurrency = resolveCurrencyFromLocale(locale);
45+
const isInUseSql = sql<boolean>`(
46+
exists (
47+
select 1
48+
from ${orderItems} oi
49+
where oi.product_id = ${products.id}
50+
)
51+
OR
52+
exists (
53+
select 1
54+
from ${inventoryMoves} im
55+
where im.product_id = ${products.id}
56+
)
57+
)`;
4058

4159
const all = await db
4260
.select({
@@ -51,21 +69,7 @@ export default async function AdminProductsPage({
5169
isFeatured: products.isFeatured,
5270
createdAt: products.createdAt,
5371
priceMinor: productPrices.priceMinor,
54-
isInUse: sql<boolean>`
55-
(
56-
exists (
57-
select 1
58-
from order_items oi
59-
where oi.product_id = ${products.id}
60-
)
61-
OR
62-
exists (
63-
select 1
64-
from inventory_moves im
65-
where im.product_id = ${products.id}
66-
)
67-
)
68-
`,
72+
isInUse: isInUseSql,
6973
})
7074
.from(products)
7175
.leftJoin(

frontend/app/api/shop/admin/orders/[id]/route.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
AdminUnauthorizedError,
88
requireAdminApi,
99
} from '@/lib/auth/admin';
10-
import { requireAdminCsrf } from '@/lib/security/admin-csrf';
1110

1211
import { getAdminOrderDetail } from '@/db/queries/shop/admin-orders';
1312

@@ -46,17 +45,8 @@ export async function GET(
4645
try {
4746
await requireAdminApi(request);
4847

49-
const csrfRes = requireAdminCsrf(request, 'admin:orders:read');
50-
if (csrfRes) {
51-
logWarn('admin_order_detail_csrf_rejected', {
52-
...baseMeta,
53-
code: 'CSRF_REJECTED',
54-
orderId: orderIdForLog,
55-
durationMs: Date.now() - startedAtMs,
56-
});
57-
csrfRes.headers.set('Cache-Control', 'no-store');
58-
return csrfRes;
59-
}
48+
// CSRF is enforced only for state-changing admin routes.
49+
// This endpoint is read-only (GET), so we intentionally do not require CSRF.
6050

6151
const rawParams = await context.params;
6252
const parsed = orderIdParamSchema.safeParse(rawParams);

frontend/app/api/shop/admin/orders/reconcile-stale/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,15 @@ export async function POST(request: NextRequest) {
3030
const requestId =
3131
request.headers.get('x-request-id')?.trim() || crypto.randomUUID();
3232

33+
// NOTE: We intentionally keep TWO origin checks:
34+
// 1) guardBrowserSameOrigin(): generic unsafe-request Origin allowlist gate
35+
// (APP_ORIGIN/APP_ADDITIONAL_ORIGINS), fail-fast before auth/body parsing.
36+
// 2) isSameOrigin(): CSRF-specific strict same-origin assertion, so CSRF origin mismatch
37+
// is logged/coded separately.
38+
// These checks are not equivalent and serve different error semantics (policy vs CSRF).
39+
3340
const blocked = guardBrowserSameOrigin(request);
41+
3442
if (blocked) {
3543
logWarn('admin_reconcile_stale_origin_blocked', {
3644
requestId,

frontend/app/api/shop/admin/products/[id]/status/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import { requireAdminCsrf } from '@/lib/security/admin-csrf';
1212
import { guardBrowserSameOrigin } from '@/lib/security/origin';
1313

1414
import { logError, logWarn } from '@/lib/logging';
15+
import { ProductNotFoundError } from '@/lib/errors/products';
1516
import { toggleProductStatus } from '@/lib/services/products';
17+
1618
export const runtime = 'nodejs';
1719

1820
const productIdParamSchema = z.object({ id: z.string().uuid() });
@@ -122,7 +124,7 @@ export async function PATCH(
122124
return noStoreJson({ code: error.code }, { status: 403 });
123125
}
124126

125-
if (error instanceof Error && error.message === 'PRODUCT_NOT_FOUND') {
127+
if (error instanceof ProductNotFoundError) {
126128
logWarn('admin_product_status_not_found', {
127129
...baseMeta,
128130
code: 'PRODUCT_NOT_FOUND',

frontend/app/api/shop/checkout/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,10 @@ export async function POST(request: NextRequest) {
224224
idempotencyKey.format?.()
225225
);
226226
}
227+
// For observability: shorten to avoid oversized structured logs.
228+
// Never used as an idempotency key; the full header value remains canonical.
227229
const idempotencyKeyShort = idempotencyKey.slice(0, 32);
230+
228231
const meta = {
229232
...baseMeta,
230233
idempotencyKey: idempotencyKeyShort,

0 commit comments

Comments
 (0)