Skip to content

(SP: 3) [Shop/Backend/UI] Close critical checkout/refund invariants + unify shop layouts/theme + fix catalog pagination & cart price display#144

Merged
ViktorSvertoka merged 9 commits into
developfrom
lso/feat/shop
Jan 15, 2026
Merged

(SP: 3) [Shop/Backend/UI] Close critical checkout/refund invariants + unify shop layouts/theme + fix catalog pagination & cart price display#144
ViktorSvertoka merged 9 commits into
developfrom
lso/feat/shop

Conversation

@liudmylasovetovs
Copy link
Copy Markdown
Collaborator

@liudmylasovetovs liudmylasovetovs commented Jan 15, 2026

Description

This PR closes several P0 holes in the shop’s critical payment/inventory flows (refund, release invariants, post-create Stripe failures), while also finishing shop UI/layout unification (shared container/theme tokens, admin topbar), improving catalog pagination, and fixing cart price rendering.


Related Issue

Issue: #<issue_number>


Changes

  • Refund (P0): Admin refund action now initiates a real Stripe refund (stripe.refunds.create) with idempotency; stores provider refund reference in pspMetadata.refunds[]; admin action marks refund initiated (not final). Finalization remains webhook-only (refund → refunded + exactly-once restock).
  • Inventory release invariants (P0): If applyReleaseMove fails (throw or { ok:false }), the system does not set inventoryStatus='released', stockRestored=true, or restockedAt; order remains in a safe retry/janitor-repairable state. Includes regression test.
  • Checkout failure semantics (P0): Enforces “400 only before any DB writes.” Post-create Stripe failures now return correct classes (e.g., PI create → 502 STRIPE_ERROR, PI attach conflict → 409 CHECKOUT_CONFLICT, invariant → 500) with contract tests.
  • Catalog pagination: Fixes shop products pagination behavior (client-side “Load more” flow).
  • UI/layout unification: Moves shop theme tokens into scoped global .shop-scope overrides; restores/aligns admin topbar container spacing; shop header nav gets “Home” link and hover/active styling aligned with platform nav.
  • Cart UI bugfix: Fixes incorrect price display in cart.

Database Changes (if applicable)

  • Schema migration required
  • Seed data updated
  • Breaking changes to existing queries
  • Transaction-safe migration
  • Migration tested locally on Neon

How Has This Been Tested?

  • Tested locally
  • Verified in development environment
  • Checked responsive layout (if UI-related)
  • Tested accessibility (keyboard / screen reader)

Commands / checks (PowerShell):

  • npm run build
  • npx vitest run
  • npx vitest run .\lib\tests\restock-release-failure-invariant.test.ts
  • Contract tests for post-create Stripe failure semantics
  • Manual smoke: admin refund flow + webhook finalization; admin-api-killswitch behavior in prod-mode config; shop header/nav hover/active; catalog pagination; cart price rendering

Screenshots (if applicable)

  • (optional) Shop header nav active/hover + Home link
  • (optional) Admin topbar spacing
  • (optional) Catalog pagination “Load more”

Checklist

Before submitting

  • Code has been self-reviewed
  • No TypeScript or console errors
  • Code follows project conventions
  • Scope is limited to this feature/fix
  • No unrelated refactors included
  • English used in code, commits, and docs
  • New dependencies discussed with team
  • Database migration tested locally (if applicable)
  • GitHub Projects card moved to In Review

Reviewers

Summary by CodeRabbit

  • New Features

    • Revamped shop admin UI with top bar, navigation, paginated product/order lists, and improved order detail layout.
    • Client-side incremental product catalog browsing with load-more UX.
    • Domain-backed refund flow with idempotency and richer refund tracking.
  • Bug Fixes

    • Hardened checkout and payment error handling; clearer Stripe error responses.
    • More robust inventory restocking and failure recovery to prevent orphaned orders.
  • Style

    • Scoped shop theme and dark-mode overrides added.
  • Tests

    • New tests covering checkout/payment intent, Stripe errors, and restock failure invariants.

✏️ Tip: You can customize this high-level summary in your review settings.

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 15, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit e8c5349
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/696871d779dda500081d0e00
😎 Deploy Preview https://deploy-preview-144--develop-devlovers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 15, 2026

📝 Walkthrough

Walkthrough

Removed the legacy shop admin layout and theme file, added a ShopAdminTopbar and server guard, moved catalog rendering to a client-side incremental loader with a new /api/shop/catalog route, overhauled Stripe refund flow with idempotent refunds and merged PSP metadata, and standardized monetary variables to "minor" units.

Changes

Cohort / File(s) Summary
Shop Admin Pages Restructuring
frontend/app/[locale]/shop/admin/layout.tsx, frontend/app/[locale]/shop/admin/page.tsx, frontend/app/[locale]/shop/admin/orders/page.tsx, frontend/app/[locale]/shop/admin/orders/[id]/page.tsx, frontend/app/[locale]/shop/admin/products/page.tsx, frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx, frontend/app/[locale]/shop/admin/products/new/page.tsx
Deleted the locale-scoped admin layout; updated pages to call guardShopAdminPage(), render ShopAdminTopbar, become async where needed, introduce responsive panel/grid layouts and pagination for orders/products.
Shop Admin Guard & Topbar
frontend/lib/auth/guard-shop-admin-page.ts, frontend/components/shop/admin/shop-admin-topbar.tsx
Added guardShopAdminPage() (maps admin auth errors to notFound/redirect) and a presentational ShopAdminTopbar with Admin/Products/Orders and Back-to-shop links.
Shop Layout & Theme
frontend/app/[locale]/shop/layout.tsx, frontend/app/[locale]/shop/shop-theme.css, frontend/app/globals.css
Removed ShopLayout and deleted the dedicated shop-theme.css; introduced scoped .shop-scope theme overrides in globals.css with dark-mode variants to contain shop tokens.
Catalog Client + API
frontend/components/shop/catalog-products-client.tsx, frontend/app/api/shop/catalog/route.ts, frontend/app/[locale]/shop/products/page.tsx
New client-side CatalogProductsClient with incremental loading and race protection; new GET /api/shop/catalog route that validates filters and returns paginated products; products page now uses client component and canonicalizes ?page param.
Catalog Load More (client API)
frontend/components/shop/catalog-load-more.tsx
Converted to callback-based API: accepts isLoading and onLoadMore instead of performing internal routing.
Monetary Unit Normalization
frontend/lib/services/products/cart/rehydrate.ts, frontend/app/[locale]/shop/cart/page.tsx
Renamed cents variables to Minor (unitPriceMinor, lineTotalMinor, totalAmountMinor) and updated formatting/usage to use minor-unit values consistently.
Refunds & PSP / Stripe helpers
frontend/lib/psp/stripe.ts, frontend/lib/services/orders/refund.ts, frontend/lib/services/orders/payment-intent.ts, frontend/app/api/shop/admin/orders/[id]/refund/route.ts
Added createRefund with input validation/idempotency; replaced simple refund flow with Stripe-backed refund process, domain-level refund metadata, idempotency checks, and requestedBy opt in admin route; payment-intent attach now transitions to requires_payment when applicable.
Stripe Webhook Metadata Merge
frontend/app/api/shop/webhooks/stripe/route.ts
Added internal refund metadata types/helpers and mergePspMetadata logic to upsert and preserve refund history; updated webhook branches to persist merged PSP metadata and timestamps.
Restock & Checkout Flow
frontend/lib/services/orders/restock.ts, frontend/lib/services/orders/checkout.ts, frontend/app/api/shop/checkout/route.ts
Major restock rework: lease-based claim, release-move loop, explicit failure codes/failureMessage propagation, atomic finalization; checkout updated to use initialPaymentStatus and restock-based error recovery; checkout route error handling standardized (STRIPE errors -> 502, clearer conflict/status responses).
Header / Nav Changes
frontend/components/header/UnifiedHeader.tsx, frontend/components/header/AppMobileMenu.tsx, frontend/components/shop/header/nav-links.tsx
Removed duplicate Home link from UnifiedHeader; NavLinks gains includeHomeLink and centralized baseLink styles; AppMobileMenu conditionally renders Home for shop variant.
Admin Pagination Component
frontend/components/shop/admin/admin-pagination.tsx
New AdminPagination component rendering prev/current/next links with canonical href rules (no ?page for page 1).
Tests
frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts, frontend/lib/tests/checkout-stripe-error-contract.test.ts, frontend/lib/tests/restock-release-failure-invariant.test.ts, frontend/components/tests/CookieBanner.test.tsx
Added contract/integration tests covering PI rejection (409), Stripe error propagation (502), restock release-failure invariants; added JSDOM environment directive for CookieBanner test.
Project Structure / Misc
frontend/project-structure.txt
Updated project-structure summary to reflect moved/added auth/email flows, quiz changes, new components, schema tokens, legacy migration files, and new tests/seeds.

Sequence Diagram(s)

sequenceDiagram
    participant User as Browser
    participant Catalog as CatalogProductsClient (client)
    participant API as /api/shop/catalog
    participant DB as Database

    User->>Catalog: initial render (initialCatalog)
    Catalog->>User: render grid + LoadMore
    User->>Catalog: click Load more
    Catalog->>API: GET /api/shop/catalog?page=2&...
    API->>DB: query paged products
    DB-->>API: products + hasMore
    API-->>Catalog: { products, hasMore, page }
    Catalog->>Catalog: dedupe & append products
    Catalog->>User: render updated grid
Loading
sequenceDiagram
    participant Admin as Admin Browser
    participant Page as Server page (orders/[id])
    participant Guard as guardShopAdminPage()
    participant Auth as requireAdminPage()

    Admin->>Page: request /shop/admin/orders/123
    Page->>Guard: await guardShopAdminPage()
    Guard->>Auth: requireAdminPage()
    Auth-->>Guard: OK / throws admin errors
    Guard-->>Page: allow or perform notFound/redirect
    Page->>Page: render ShopAdminTopbar + order panels
    Page-->>Admin: HTML/SSR response
Loading
sequenceDiagram
    participant Admin as Admin UI
    participant API as POST /api/shop/admin/orders/[id]/refund
    participant RefundSvc as refundOrder()
    participant Stripe as Stripe API
    participant DB as Database
    participant Webhook as Stripe Webhook

    Admin->>API: POST refund request
    API->>RefundSvc: refundOrder(orderId, { requestedBy: 'admin' })
    RefundSvc->>DB: read order & pspMetadata
    RefundSvc->>RefundSvc: compute idempotency key, check existing refunds
    RefundSvc->>Stripe: createRefund(...)
    Stripe-->>RefundSvc: refundId, status
    RefundSvc->>DB: upsert pspMetadata (preserve refunds)
    RefundSvc-->>API: return updated order summary
    API-->>Admin: 200 OK
    Stripe->>Webhook: send refund/charge webhook
    Webhook->>DB: mergePspMetadata, update payment state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • AM1007
  • ViktorSvertoka

"I'm a rabbit in a topbar hat,
Hopping through refunds and products' chat,
Catalogs load more with a cheerful thump,
Minor units snug as a well-packed lump,
Admins click, Stripe pings — hop, clap, and splat!" 🐇✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly summarizes the main objectives: backend refund invariants, shop layout/theme unification, catalog pagination fixes, and cart price display corrections—all verified in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/project-structure.txt (1)

147-147: Regenerate project-structure.txt to reflect removal of shop-theme.css.

The file shop-theme.css was deleted from the codebase, but project-structure.txt still lists it at line 147. Since this file is auto-generated by save-structure.cjs, run the script to regenerate it with the current file structure.

🤖 Fix all issues with AI agents
In `@frontend/components/shop/catalog-products-client.tsx`:
- Around line 75-77: The race-condition guard is ineffective because
activeQueryRef.current is overwritten at the start of each request; remove the
assignment activeQueryRef.current = requestQueryKey from the fetch-start path
and instead rely on the existing update in the useEffect that watches baseQuery
and locale so that activeQueryRef.current remains the canonical value to compare
against when resolving responses (ensure the comparison activeQueryRef.current
!== requestQueryKey stays in the response handling branch to drop stale
responses).

In `@frontend/lib/services/orders/restock.ts`:
- Around line 205-237: The check for a falsey `ok` is dead because
applyReleaseMove returns Promise<{ ok: true; applied: boolean }> or throws, so
remove the unreachable branching and casts: in the loop over reservedMoves
delete the whole "if (res && typeof res === 'object' && 'ok' in (res as any) &&
(res as any).ok === false) { ... }" block and rely on the existing try/catch to
push failures into releaseFailures; alternatively, if you intend
applyReleaseMove to sometimes return {ok: false}, update applyReleaseMove's
return type and implementation (and callers) to allow { ok: false; reason?:
string } so the current guard becomes valid—choose one approach and make sure
reservedMoves loop and releaseFailures handling are consistent with that
decision.
🧹 Nitpick comments (18)
frontend/components/shop/admin/shop-admin-topbar.tsx (1)

3-42: LGTM! Clean and well-structured navigation component.

The component is well-implemented with appropriate responsive styling. Two optional improvements to consider:

  1. Accessibility: Adding a <nav> wrapper with an aria-label would improve screen reader navigation.
  2. Active state: The current route isn't visually distinguished. If needed later, this could accept the current path as a prop.
💡 Optional: Add semantic nav wrapper
 export function ShopAdminTopbar() {
   return (
-    <div className="border-b border-border bg-background">
+    <nav aria-label="Admin navigation" className="border-b border-border bg-background">
       <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
         ...
       </div>
-    </div>
+    </nav>
   );
 }
frontend/app/[locale]/shop/admin/products/page.tsx (1)

73-227: Clean table implementation with proper accessibility attributes.

The table structure is well-organized with:

  • Fixed column widths totaling 100%
  • Truncation handling for long text with title attributes
  • Appropriate aria-label attributes on status indicators
  • Defensive null handling for optional fields

Optional consideration: This page fetches all products without pagination. For large catalogs, consider adding server-side pagination or virtualization to avoid performance issues.

frontend/lib/services/products/cart/rehydrate.ts (1)

158-162: Consider renaming fromCents for clarity.

The function converts minor units to major/display values, but the name fromCents is misleading for zero-decimal currencies like JPY where the minor unit equals the major unit. Consider renaming to fromMinorUnits or minorToMajor for consistency with the codebase terminology shift to "minor units."

frontend/components/header/AppMobileMenu.tsx (1)

72-80: Home link correctly added for shop variant.

The conditional rendering and close() handler are properly implemented.

Minor styling inconsistency: The link styling here (hover:bg-secondary) differs from NavLinks desktop styling (hover:bg-muted/50). This may be intentional for mobile/desktop differentiation, but if you want full consistency, consider extracting the shared link styles.

frontend/app/[locale]/shop/admin/orders/[id]/page.tsx (2)

31-34: Consider specifying locale for date formatting.

toLocaleString() without arguments uses the server's default locale, which may differ from the user's expected locale. Consider passing the locale parameter for consistent formatting.

♻️ Suggested improvement
-function formatDateTime(value: Date | null | undefined) {
+function formatDateTime(value: Date | null | undefined, locale?: string) {
   if (!value) return '-';
-  return value.toLocaleString();
+  return value.toLocaleString(locale);
 }

Then update calls like formatDateTime(order.createdAt, locale).


87-96: IIFE pattern for inline currency formatting works but could be cleaner.

The immediately-invoked function expressions for formatting are functional but add cognitive overhead. Consider extracting to a helper or computing values earlier.

frontend/app/api/shop/catalog/route.ts (1)

28-32: Silent validation failure may mask client issues.

When catalogQuerySchema.safeParse fails, the code silently falls back to defaults without any indication to the caller. This could make debugging difficult for API consumers who send malformed parameters.

Consider returning a 400 response for invalid parameters, or at minimum logging the validation error server-side.

♻️ Option 1: Return 400 for invalid params
  const parsed = catalogQuerySchema.safeParse(rest);

- const filters = parsed.success
-   ? parsed.data
-   : { page: 1, limit: CATALOG_PAGE_SIZE };
+ if (!parsed.success) {
+   return NextResponse.json(
+     { error: 'Invalid query parameters', details: parsed.error.flatten() },
+     { status: 400 }
+   );
+ }
+ const filters = parsed.data;
♻️ Option 2: Log and fallback (current behavior with observability)
  const parsed = catalogQuerySchema.safeParse(rest);

- const filters = parsed.success
-   ? parsed.data
-   : { page: 1, limit: CATALOG_PAGE_SIZE };
+ const filters = parsed.success
+   ? parsed.data
+   : (console.warn('[catalog] Invalid query params, using defaults:', parsed.error.flatten()), { page: 1, limit: CATALOG_PAGE_SIZE });
frontend/components/shop/catalog-products-client.tsx (1)

103-104: Consider logging the error for debugging.

The catch block discards the actual error object. While the UI displays a generic message, logging the error would aid debugging in production.

Proposed improvement
-    } catch {
+    } catch (err) {
+      console.error('Failed to load more products:', err);
       setError('Failed to load more');
frontend/app/[locale]/shop/products/page.tsx (1)

53-58: Redundant page override after canonicalization.

Since the redirect at line 44 ensures resolvedSearchParams.page is never present when this code executes, the page: 1 override at line 56 is always applied. The parsed page from catalogQuerySchema (which defaults to 1) is immediately overwritten.

This is functionally correct but slightly redundant. Consider simplifying if desired:

Optional simplification
   const parsed = parsedParams.success
     ? parsedParams.data
     : { page: 1, limit: CATALOG_PAGE_SIZE };

-  // Для "Load more" UX: починаємо завжди з 1-ї сторінки (URL ?page=... ігноруємо).
-  const filters = {
-    ...parsed,
-    page: 1,
-    limit: parsed.limit ?? CATALOG_PAGE_SIZE,
-  };
+  // After canonicalization, page param is always absent, so parsed.page defaults to 1.
+  const filters = { ...parsed, limit: parsed.limit ?? CATALOG_PAGE_SIZE };
frontend/lib/services/orders/checkout.ts (2)

196-216: Minor: Duplicate inventoryStatus update.

Lines 197-200 update inventoryStatus to release_pending, then lines 204-216 update it again to the same value. The second update also sets other fields, so it's not incorrect, but the first update at lines 197-200 is redundant.

Optional consolidation
   } catch (e) {
     const failAt = new Date();
-
-    // Mark as "release pending" only. Finalization must happen via restockOrder().
-    await db
-      .update(orders)
-      .set({ inventoryStatus: 'release_pending', updatedAt: failAt })
-      .where(eq(orders.id, orderId));

     const isOos = e instanceof InsufficientStockError;

+    // Mark as "release pending" only. Finalization must happen via restockOrder().
     await db
       .update(orders)
       .set({
         status: 'INVENTORY_FAILED',
         inventoryStatus: 'release_pending',

759-779: Minor: Same duplicate inventoryStatus update pattern.

Similar to reconcileNoPaymentOrder, lines 760-763 and 767-779 both update inventoryStatus to 'release_pending'. Consider consolidating into a single update for clarity.

frontend/lib/tests/restock-release-failure-invariant.test.ts (1)

38-68: Consider using test database transactions for isolation.

The test seeds real data into the database and relies on manual cleanup in the finally block. If cleanup fails partially (e.g., FK constraint order), test pollution can occur.

Consider wrapping the test in a database transaction that rolls back, or using a test helper that ensures atomic cleanup.

frontend/app/api/shop/admin/orders/[id]/refund/route.ts (1)

31-33: LGTM: Explicit requestedBy improves audit trail.

Passing { requestedBy: 'admin' } explicitly documents the refund source for traceability in PSP metadata, even though the service defaults to 'admin'. This makes the intent clearer.

The comment on line 31 restating the file path is unnecessary and could be removed in a future cleanup.

frontend/lib/tests/checkout-stripe-error-contract.test.ts (1)

47-57: Minor inconsistency: selectedSize and selectedColor present here but absent in the other test file.

The makeReq helper includes selectedSize and selectedColor fields here, but the similar helper in checkout-set-payment-intent-reject-contract.test.ts omits them. While both likely pass validation, consider aligning for consistency.

frontend/lib/psp/stripe.ts (1)

64-67: Consider preserving the original error for debugging.

The catch block logs the error but throws a generic STRIPE_REFUND_FAILED. This loses Stripe-specific error details (e.g., StripeCardError, decline codes) that could aid debugging or enable more granular error handling upstream.

♻️ Optional: Preserve original error context
   } catch (error) {
     logError('Stripe refund creation failed', error);
-    throw new Error('STRIPE_REFUND_FAILED');
+    const wrappedError = new Error('STRIPE_REFUND_FAILED');
+    (wrappedError as any).cause = error;
+    throw wrappedError;
   }
frontend/app/api/shop/webhooks/stripe/route.ts (2)

57-57: Redundant null-coalescing.

((prevMeta ?? {}) as any) ?? {} is redundant since (prevMeta ?? {}) already ensures a non-null value.

♻️ Simplify
-  const base = ((prevMeta ?? {}) as any) ?? {};
+  const base = (prevMeta ?? {}) as any;

218-254: Merge order could unintentionally overwrite refunds[] in edge cases.

The spread order { ...metaWithRefunds, ...cleanedDelta } means if cleanedDelta ever contains a refunds key, it would overwrite the carefully upserted refunds array. Currently buildPspMetadata doesn't produce refunds, but this is fragile.

Consider explicitly preserving refunds after the merge:

♻️ Proposed fix to explicitly preserve refunds
   // IMPORTANT: merge, not overwrite (preserves refunds[])
   return {
     ...metaWithRefunds,
     ...cleanedDelta,
+    refunds: metaWithRefunds.refunds, // explicit preservation
   };
frontend/lib/services/orders/refund.ts (1)

9-50: Duplicate code: RefundMetaRecord and normalizeRefunds are duplicated from the webhook route.

These types and functions are nearly identical to those in frontend/app/api/shop/webhooks/stripe/route.ts. Consider extracting them to a shared module (e.g., @/lib/services/orders/refund-metadata.ts) to maintain consistency and reduce duplication.

♻️ Extract shared refund metadata utilities

Create a shared module:

// frontend/lib/services/orders/refund-metadata.ts
export type RefundMetaRecord = {
  refundId: string;
  idempotencyKey: string;
  amountMinor: number;
  currency: string;
  createdAt: string;
  createdBy: string;
  status?: string | null;
};

export function normalizeRefunds(
  meta: unknown,
  fallback: { currency: string; createdAt: string }
): RefundMetaRecord[] {
  // ... implementation
}

Then import in both files.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bb46d8c and 8278c1b.

📒 Files selected for processing (34)
  • frontend/app/[locale]/shop/admin/layout.tsx
  • frontend/app/[locale]/shop/admin/orders/[id]/page.tsx
  • frontend/app/[locale]/shop/admin/orders/page.tsx
  • frontend/app/[locale]/shop/admin/page.tsx
  • frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx
  • frontend/app/[locale]/shop/admin/products/new/page.tsx
  • frontend/app/[locale]/shop/admin/products/page.tsx
  • frontend/app/[locale]/shop/cart/page.tsx
  • frontend/app/[locale]/shop/layout.tsx
  • frontend/app/[locale]/shop/products/page.tsx
  • frontend/app/[locale]/shop/shop-theme.css
  • frontend/app/api/shop/admin/orders/[id]/refund/route.ts
  • frontend/app/api/shop/catalog/route.ts
  • frontend/app/api/shop/checkout/route.ts
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/app/globals.css
  • frontend/components/header/AppMobileMenu.tsx
  • frontend/components/header/UnifiedHeader.tsx
  • frontend/components/shop/admin/shop-admin-topbar.tsx
  • frontend/components/shop/catalog-load-more.tsx
  • frontend/components/shop/catalog-products-client.tsx
  • frontend/components/shop/header/nav-links.tsx
  • frontend/components/tests/CookieBanner.test.tsx
  • frontend/lib/auth/guard-shop-admin-page.ts
  • frontend/lib/psp/stripe.ts
  • frontend/lib/services/orders/checkout.ts
  • frontend/lib/services/orders/payment-intent.ts
  • frontend/lib/services/orders/refund.ts
  • frontend/lib/services/orders/restock.ts
  • frontend/lib/services/products/cart/rehydrate.ts
  • frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts
  • frontend/lib/tests/checkout-stripe-error-contract.test.ts
  • frontend/lib/tests/restock-release-failure-invariant.test.ts
  • frontend/project-structure.txt
💤 Files with no reviewable changes (3)
  • frontend/app/[locale]/shop/shop-theme.css
  • frontend/app/[locale]/shop/layout.tsx
  • frontend/app/[locale]/shop/admin/layout.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-30T16:01:02.523Z
Learnt from: kryvosheyin
Repo: DevLoversTeam/devlovers.net PR: 88
File: frontend/drizzle/schema.ts:153-153
Timestamp: 2025-12-30T16:01:02.523Z
Learning: The file frontend/drizzle/schema.ts is a legacy file not used by Drizzle or the runtime. The canonical schemas live in frontend/db/schema/* as configured in drizzle-config.ts, and that's what migrations and runtime use.

Applied to files:

  • frontend/project-structure.txt
🧬 Code graph analysis (13)
frontend/lib/auth/guard-shop-admin-page.ts (1)
frontend/lib/auth/admin.ts (4)
  • requireAdminPage (62-70)
  • AdminApiDisabledError (7-13)
  • AdminUnauthorizedError (15-21)
  • AdminForbiddenError (23-29)
frontend/components/shop/header/nav-links.tsx (1)
frontend/lib/utils.ts (1)
  • cn (4-6)
frontend/app/api/shop/admin/orders/[id]/refund/route.ts (1)
frontend/lib/services/orders/refund.ts (1)
  • refundOrder (81-185)
frontend/lib/psp/stripe.ts (3)
frontend/lib/env/stripe.ts (1)
  • getStripeEnv (17-49)
frontend/lib/logging.ts (1)
  • logError (87-93)
frontend/db/legacy-migrations/drizzle_legacy/schema.ts (1)
  • currency (7-7)
frontend/app/[locale]/shop/products/page.tsx (3)
frontend/lib/validation/shop.ts (1)
  • catalogQuerySchema (62-72)
frontend/lib/config/catalog.ts (1)
  • CATALOG_PAGE_SIZE (39-39)
frontend/components/shop/catalog-products-client.tsx (1)
  • CatalogProductsClient (25-132)
frontend/app/[locale]/shop/cart/page.tsx (1)
frontend/lib/shop/currency.ts (1)
  • formatMoney (115-128)
frontend/lib/services/orders/checkout.ts (4)
frontend/lib/services/orders/restock.ts (1)
  • restockOrder (56-314)
frontend/lib/services/orders.ts (2)
  • restockOrder (5-5)
  • restockOrder (5-5)
frontend/lib/logging.ts (1)
  • logError (87-93)
frontend/lib/services/errors.ts (1)
  • InsufficientStockError (18-24)
frontend/lib/tests/restock-release-failure-invariant.test.ts (4)
frontend/db/index.ts (1)
  • db (54-54)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
frontend/lib/services/orders/restock.ts (1)
  • restockOrder (56-314)
frontend/db/schema/shop.ts (1)
  • inventoryMoves (334-363)
frontend/app/api/shop/catalog/route.ts (3)
frontend/lib/validation/shop.ts (1)
  • catalogQuerySchema (62-72)
frontend/lib/config/catalog.ts (1)
  • CATALOG_PAGE_SIZE (39-39)
frontend/lib/shop/data.ts (1)
  • getCatalogProducts (207-234)
frontend/app/[locale]/shop/admin/products/new/page.tsx (2)
frontend/lib/auth/guard-shop-admin-page.ts (1)
  • guardShopAdminPage (9-18)
frontend/components/shop/admin/shop-admin-topbar.tsx (1)
  • ShopAdminTopbar (3-43)
frontend/lib/services/orders/restock.ts (3)
frontend/lib/services/errors.ts (1)
  • OrderStateInvalidError (93-116)
frontend/lib/services/orders/payment-state.ts (1)
  • guardedPaymentStatusUpdate (122-263)
frontend/lib/services/inventory.ts (1)
  • applyReleaseMove (118-178)
frontend/components/header/UnifiedHeader.tsx (1)
frontend/components/shop/header/nav-links.tsx (1)
  • NavLinks (32-102)
frontend/app/[locale]/shop/admin/products/page.tsx (4)
frontend/lib/auth/guard-shop-admin-page.ts (1)
  • guardShopAdminPage (9-18)
frontend/components/shop/admin/shop-admin-topbar.tsx (1)
  • ShopAdminTopbar (3-43)
frontend/lib/shop/currency.ts (1)
  • formatMoney (115-128)
frontend/components/shop/admin/admin-product-status-toggle.tsx (1)
  • AdminProductStatusToggle (10-67)
🔇 Additional comments (64)
frontend/components/tests/CookieBanner.test.tsx (1)

1-3: LGTM!

The @vitest-environment jsdom directive is correctly placed and necessary for this test file, which relies on browser APIs like localStorage and DOM rendering via @testing-library/react.

frontend/lib/auth/guard-shop-admin-page.ts (1)

9-17: Well-implemented authorization guard with appropriate error mapping.

The guard correctly:

  • Uses notFound() for disabled API and forbidden access (security best practice—doesn't reveal page existence)
  • Uses redirect('/login') for unauthenticated users
  • Re-throws unexpected errors for proper error boundary handling

Since notFound() and redirect() throw internally in Next.js, the control flow is correct—throw err on line 16 only executes for unhandled error types.

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

38-44: Guard placement and async params handling are correct.

The guard is invoked before any data fetching or rendering, ensuring unauthorized access is blocked early. The async params pattern follows Next.js 15+ conventions.

frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx (2)

24-32: Correct guard placement before parameter validation and data access.

The guard runs first (line 29), then params are validated with Zod schema. This ensures authorization is checked before any database queries, following the fail-fast principle.


73-97: Clean integration of topbar with existing form.

The fragment wrapper appropriately adds the ShopAdminTopbar without introducing unnecessary DOM nesting. The ProductForm props remain unchanged, preserving existing functionality.

frontend/app/globals.css (2)

166-181: Circular reference in --radius will cause unexpected behavior.

Line 168 defines --radius: calc(var(--radius) - 2px) which creates a self-referential loop. CSS custom properties don't resolve previous values in the same scope—this will result in the property being invalid or using the initial value.

🐛 Proposed fix: Reference the global radius value explicitly
 .shop-scope {
   /* keep shop rounding slightly tighter than platform */
-  --radius: calc(var(--radius) - 2px);
+  --radius: calc(0.625rem - 2px);  /* 0.625rem is the global --radius value from line 36 */

Alternatively, define a separate variable at the root level (e.g., --radius-base: 0.625rem) and reference that in both places.

Likely an incorrect or invalid review comment.


183-199: Dark mode shop-scope overrides are correct.

The color-mix() usage for derived border/input colors is clean and avoids hardcoded hex values, maintaining consistency with the design system. Browser support (Chrome 111+, Firefox 113+, Safari 16.2+) is well-established with ~92% global coverage and aligns with the project's modern tech stack (Next.js 16, Tailwind v4, React 19).

frontend/app/[locale]/shop/admin/page.tsx (1)

5-14: LGTM! Clean guard and layout integration.

The async guard pattern correctly enforces access control before rendering, and the ShopAdminTopbar provides consistent navigation. The fragment wrapper appropriately groups the topbar with the content container.

frontend/app/[locale]/shop/admin/products/new/page.tsx (1)

5-13: LGTM! Consistent admin page pattern.

The guard and topbar integration follows the same pattern as other admin pages, maintaining consistency across the admin section.

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

15-28: LGTM! Well-structured helper functions.

The pickMinor, orderCurrency, and formatDate helpers cleanly encapsulate data transformation logic with appropriate null handling.


56-141: LGTM! Proper semantic table structure.

The table uses correct <thead>/<tbody> semantics, and the colSpan={7} on the empty state row correctly matches the column count.


46-53: The endpoint is properly protected with authentication—no action required.

The /api/shop/admin/orders/reconcile-stale route validates authentication via requireAdminApi(), which verifies a valid session and requires the admin role. CSRF risk is minimal here because the form is same-origin and the endpoint is strictly authenticated (only accessible to logged-in admins). Using a native form POST for authenticated admin actions is a standard, secure pattern in Next.js.

frontend/lib/services/products/cart/rehydrate.ts (2)

103-143: LGTM! Robust price validation.

The validation logic correctly ensures priceMinor is:

  1. A finite number (line 106-107)
  2. An integer (line 110)
  3. A safe integer and positive (line 119)

The same validation is applied to the legacy fallback path (line 138), maintaining consistency.


177-189: LGTM! Consistent summary structure.

The summary correctly provides both canonical (totalAmountMinor) and display (totalAmount) values, maintaining the same pattern used for individual items.

frontend/app/[locale]/shop/cart/page.tsx (2)

224-226: LGTM! Correct usage of minor-unit field.

The change from lineTotal to lineTotalMinor aligns with formatMoney's expected amountMinor parameter and the rehydration logic changes.


241-266: LGTM! Consistent minor-unit usage for totals.

Both Subtotal and Total now correctly use cart.summary.totalAmountMinor, matching the updated rehydrateCartItems output and formatMoney's expected input type.

frontend/components/shop/header/nav-links.tsx (3)

42-46: Good extraction of shared link styling.

The baseLink constant consolidates common styling, improving maintainability. The focus-visible states are properly included for keyboard accessibility.


48-64: Home link implementation looks correct.

The conditional rendering with aria-current for active state is properly implemented. The active/inactive styling is consistent with the other navigation links.


80-84: Consistent active state handling across all links.

The aria-current="page" attribute and conditional styling are consistently applied to category links, which improves accessibility and visual feedback.

frontend/components/header/UnifiedHeader.tsx (1)

54-63: Good accessibility and delegation of Home link rendering.

Adding aria-label="Primary" improves navigation landmark identification for screen readers. Passing includeHomeLink to NavLinks when isShop is true properly delegates the Home link rendering to the appropriate component.

frontend/app/[locale]/shop/admin/orders/[id]/page.tsx (2)

36-44: Guard and data fetching correctly implemented.

The guardShopAdminPage() is properly awaited before page rendering, ensuring unauthorized access is blocked early. The notFound() call for missing orders is appropriate.


154-231: Items table implementation is solid.

The table correctly handles:

  • Rendering items with proper fallback for missing data (?? '-')
  • Currency formatting with minor unit handling
  • Empty state with appropriate colspan
  • Hover states for row interactivity
frontend/app/api/shop/catalog/route.ts (2)

7-8: Appropriate cache control configuration.

Setting dynamic = 'force-dynamic' and revalidate = 0 ensures fresh catalog data on each request, which aligns with the Cache-Control: no-store header.


21-38: Overall implementation is functional.

The route correctly:

  • Extracts and validates query parameters
  • Delegates to getCatalogProducts for data fetching
  • Returns JSON with appropriate cache headers
frontend/components/header/AppMobileMenu.tsx (1)

116-118: Event handling works as expected; no issue detected.

The LogoutButton component does not call stopPropagation() or preventDefault(), so click events bubble to the parent <div> normally. Both handleLogout() and close() will execute as intended. The subsequent navigation to the login page does not prevent the close() callback from being called, though the menu state becomes irrelevant after the page navigates.

Likely an incorrect or invalid review comment.

frontend/components/shop/catalog-load-more.tsx (1)

1-25: LGTM!

Clean refactor to a prop-driven presentational component. The separation of concerns is improved—loading state and callback are now controlled by the parent, making this component more reusable and testable.

frontend/components/shop/catalog-products-client.tsx (1)

112-131: LGTM!

The rendering logic is clean with proper grid layout, conditional error display, and integration with the CatalogLoadMore component. The product deduplication in onLoadMore using a Set is a good defensive measure.

frontend/app/[locale]/shop/products/page.tsx (1)

31-45: LGTM on URL canonicalization.

The redirect logic correctly strips the ?page= parameter to ensure infinite-scroll URLs aren't bookmarked with stale page numbers. This aligns with the PR objective of fixing catalog pagination UX.

frontend/app/api/shop/checkout/route.ts (4)

270-296: LGTM on existing order PI retrieval path.

The 502 status for Stripe retrieval failures is appropriate—it correctly signals an upstream service issue rather than a client error.


334-355: Verify: No restock on attach failure for existing orders is intentional?

For new orders (lines 449-454), attach failures trigger a restock attempt. For existing orders here, no restock is performed on attach failure—the order is left for retry or janitor cleanup.

This asymmetry may be intentional (existing orders might have complex state), but please confirm this is the desired behavior.


438-447: Good: Conflict errors preserve inventory reservation.

The decision to skip restock on InvalidPayloadError (conflict) is correct—the inventory remains reserved so the client can retry with the same idempotency key. The comment at lines 439-441 clearly documents this intent.


393-413: LGTM on PI creation failure handling.

The best-effort restock on Stripe PI creation failure prevents stock lock-up when the payment provider is unavailable. Logging the restock error (line 409) ensures visibility if cleanup also fails.

frontend/lib/services/orders/checkout.ts (4)

49-51: LGTM on PaymentStatus documentation.

The comment clearly documents the Stripe payment status state machine: pending → requires_payment → paid/failed/refunded. This helps future maintainers understand the expected flow.


97-114: LGTM on release_pending guard.

This correctly prevents re-reservation attempts while a release is in progress. The restock attempt before throwing ensures the cleanup workflow continues even if this request fails.


413-418: LGTM on initialPaymentStatus logic.

The conditional correctly handles the DB CHECK constraint: provider='none' requires payment_status in ('paid','failed'), so starting at 'paid' is correct. For Stripe, 'pending' is appropriate until the PI is attached.


781-793: LGTM on restock-on-failure pattern.

The best-effort restock with error logging ensures cleanup is attempted without masking the original error. If restock fails, the order remains in release_pending for the janitor to handle—this aligns with the PR's goal of hardening inventory release invariants.

frontend/lib/services/orders/restock.ts (3)

1-1: LGTM: Modern Node.js import style.

Using node:crypto is the recommended approach for Node.js 16+ built-in modules, providing clearer intent and avoiding potential conflicts with npm packages.


106-174: Well-structured orphan order handling with proper guard rails.

The no-reservation path correctly:

  • Throws OrderStateInvalidError for paid orders without refund reason (safety invariant)
  • Uses fail-closed semantics when no reason is provided
  • Derives appropriate failure codes based on reason
  • Guards payment status transitions with extraWhere binding to prevent races

239-271: Critical invariant enforcement looks correct.

The fail-safe block properly preserves the janitor-retryable state by:

  • Keeping inventoryStatus: 'release_pending'
  • Setting stockRestored: false and restockedAt: null
  • Only setting failureCode for failed or stale reasons

One consideration: if a release fails during a refunded or canceled restock, no failureCode is set. This may be intentional (webhook-driven finalization handles those), but verify this aligns with your observability/alerting requirements.

frontend/lib/tests/restock-release-failure-invariant.test.ts (2)

162-176: LGTM: Key invariant assertions are correct.

The test properly validates:

  1. inventoryStatus is NOT 'released'
  2. stockRestored is false
  3. restockedAt is null
  4. Product stock remains at 0 (release not applied)

These assertions directly align with the fail-safe semantics documented in the restock service.


32-34: No issues found — the test setup is correct.

The spy on applyReleaseMove does not affect applyReserveMove because they are completely independent functions with no internal calls between them. Both execute direct database operations without invoking each other. The test correctly:

  1. Sets up the spy on applyReleaseMove before any calls (line 32)
  2. Calls applyReserveMove normally at line 116, which is unaffected by the spy and executes as expected
  3. Calls restockOrder at line 141, which internally triggers the mocked applyReleaseMove, consuming the single mockResolvedValueOnce as intended

The mockResolvedValueOnce only affects the first invocation of the spied function, and the test correctly validates that a failed release does not finalize the order state.

frontend/lib/services/orders/payment-intent.ts (2)

35-42: LGTM: Clean state transition validation.

The explicit allowed states array with backward compatibility for requires_payment is well-documented. The early validation prevents invalid transitions before hitting the guarded update.


58-76: Guarded payment status update correctly enforces atomicity.

Using guardedPaymentStatusUpdate with allowSameStateUpdate: true properly handles:

  • pendingrequires_payment (new orders)
  • requires_paymentrequires_payment (PI update with same state)

The error handling preserves semantics and prevents dangerous transitions like failed/refundedrequires_payment.

frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts (2)

1-67: LGTM! Well-structured test setup for contract testing.

The mock setup cleanly isolates the checkout route from external dependencies (Stripe, auth, DB), enabling focused testing of the error handling path when setOrderPaymentIntent rejects.


69-133: LGTM! Good coverage of both new and existing order scenarios.

Both test cases correctly verify:

  1. 409 status code (not 400) when InvalidPayloadError is thrown
  2. CHECKOUT_CONFLICT error code in the response
  3. restockOrder is not invoked on conflict

This aligns with the PR objective to constrain checkout failure semantics appropriately.

frontend/lib/tests/checkout-stripe-error-contract.test.ts (2)

1-62: LGTM! Test setup correctly simulates Stripe failure after DB write.

The mock configuration ensures createPaymentIntent throws after createOrderWithItems succeeds, validating the post-DB-write error handling path.


64-111: LGTM! Comprehensive coverage of 502 STRIPE_ERROR scenarios.

Both test cases correctly verify:

  1. 502 status code for Stripe PI creation failures
  2. STRIPE_ERROR code in the response body
  3. Message is a string

This aligns with the PR objective to map post-create Stripe failures to 502.

frontend/lib/psp/stripe.ts (5)

23-68: Well-implemented Stripe refund function with proper validation and idempotency support.

The function correctly:

  • Validates PSP availability and at least one refund target
  • Validates amountMinor when provided
  • Supports both payment_intent and charge based refunds
  • Passes idempotencyKey to Stripe for safe retries

70-82: LGTM! Efficient Stripe client caching.

The caching strategy correctly reuses the Stripe instance when the secret key hasn't changed, avoiding unnecessary client instantiation.


84-125: LGTM! Robust payment intent creation with proper validation.

The function correctly validates the amount as a safe positive integer before calling Stripe, preventing issues with floats, NaN, or excessively large values.


127-202: LGTM! Well-implemented retrieval and verification functions.

All functions follow consistent patterns:

  • Validate PSP availability
  • Validate input parameters
  • Log and wrap errors appropriately

77-79: No action needed. The Stripe API version 2025-11-17.clover is the official pinned version for stripe-node v20.0.0 and is fully compatible with the project's current configuration. This is not a future or unreleased version.

Likely an incorrect or invalid review comment.

frontend/app/api/shop/webhooks/stripe/route.ts (7)

12-46: LGTM! Clean refund metadata normalization with backward compatibility.

The normalizeRefundsFromMeta function correctly handles:

  • New array format (refunds[])
  • Legacy single refund format (refund)
  • Empty/missing metadata

48-86: LGTM! Proper idempotency for refund upsert.

The function correctly prevents duplicate refund entries by checking both refundId and idempotencyKey, and preserves refundInitiatedAt if already set.


509-537: LGTM! Proper metadata merging for mismatch scenarios.

The code correctly builds delta metadata with mismatch details and merges it with the previous metadata, preserving the refunds history.


562-607: LGTM! Consistent metadata handling for successful payments.

The payment success path correctly:

  • Builds delta metadata
  • Merges with previous metadata
  • Preserves refunds history
  • Uses guardedPaymentStatusUpdate with proper guards

644-687: LGTM! Proper metadata handling for failed payments.

The failure path correctly merges PSP metadata and triggers restock when appropriate.


714-757: LGTM! Consistent metadata handling for canceled payments.

Follows the same pattern as failed payments, correctly merging metadata and triggering restock.


1013-1091: LGTM! Comprehensive refund event handling with metadata preservation.

The refund paths correctly:

  • Distinguish between partial and full refunds
  • Store refund gate decisions in metadata for partial refunds
  • Trigger guardedPaymentStatusUpdate and restock for full refunds
  • Use the new mergePspMetadata pattern consistently
frontend/lib/services/orders/refund.ts (5)

19-23: LGTM! Clean error helper with code attachment.

The invalid helper provides a consistent way to create InvalidPayloadError with a stable code for programmatic error handling.


52-79: LGTM! Well-designed idempotency key and append logic.

The appendRefund function correctly:

  • Prevents duplicates by checking both idempotencyKey and refundId
  • Preserves existing refunds
  • Sets refundInitiatedAt for UI state

The idempotency key format refund:{orderId}:{amount}:{currency} is deterministic and appropriate.


81-101: LGTM! Proper precondition validation for refund eligibility.

The function correctly validates:

  • Order exists
  • Payment provider is Stripe
  • Order is in paid status

103-149: LGTM! Robust preconditions and domain-level idempotency.

The code correctly:

  • Validates currency and amount before proceeding
  • Requires at least one Stripe identifier
  • Checks for existing refund with same idempotency key before calling Stripe
  • Returns early if refund already initiated

151-184: LGTM! Clean Stripe refund initiation with proper separation of concerns.

The implementation correctly:

  • Calls Stripe createRefund with idempotency support
  • Persists only metadata (not paymentStatus), leaving final state to webhook
  • Returns the order summary for API response

The comment about payment_status source of truth (webhook) is helpful documentation.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +75 to +77
const requestQueryKey = `${baseQuery}|l=${locale}`;
activeQueryRef.current = requestQueryKey;
query.set('locale', locale);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Race condition guard is ineffective as written.

At line 76, activeQueryRef.current is set to requestQueryKey, and then the same requestQueryKey is used for comparison at line 93. This means the check activeQueryRef.current !== requestQueryKey will always be false for the current request.

The intent appears to be: if filters change during the fetch, ignore stale responses. For that to work, the ref should only be updated when filters change (in the useEffect), not at the start of each request.

Proposed fix
     const requestQueryKey = `${baseQuery}|l=${locale}`;
-    activeQueryRef.current = requestQueryKey;
     query.set('locale', locale);

The activeQueryRef is already updated in the useEffect when baseQuery or locale changes, which is the correct place for it.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const requestQueryKey = `${baseQuery}|l=${locale}`;
activeQueryRef.current = requestQueryKey;
query.set('locale', locale);
const requestQueryKey = `${baseQuery}|l=${locale}`;
query.set('locale', locale);
🤖 Prompt for AI Agents
In `@frontend/components/shop/catalog-products-client.tsx` around lines 75 - 77,
The race-condition guard is ineffective because activeQueryRef.current is
overwritten at the start of each request; remove the assignment
activeQueryRef.current = requestQueryKey from the fetch-start path and instead
rely on the existing update in the useEffect that watches baseQuery and locale
so that activeQueryRef.current remains the canonical value to compare against
when resolving responses (ensure the comparison activeQueryRef.current !==
requestQueryKey stays in the response handling branch to drop stale responses).

Comment on lines +205 to +237
// Apply release moves. IMPORTANT invariant:
// do NOT mark released/stockRestored/restockedAt unless all releases are CONFIRMED ok.
const releaseFailures: Array<{ productId: string; reason: string }> = [];

for (const item of reservedMoves) {
try {
const res: unknown = await applyReleaseMove(
orderId,
item.productId,
item.quantity
);

// Support both styles:
// - void return (treat as success)
// - { ok: boolean, reason?: string } return (explicit fail if ok === false)
if (
res &&
typeof res === 'object' &&
'ok' in (res as any) &&
(res as any).ok === false
) {
releaseFailures.push({
productId: item.productId,
reason: String((res as any).reason ?? 'unknown'),
});
}
} catch (err) {
releaseFailures.push({
productId: item.productId,
reason: err instanceof Error ? err.message : String(err),
});
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the applyReleaseMove function definition
ast-grep --pattern 'export async function applyReleaseMove($$$): $_'

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Search for applyReleaseMove function definition
rg 'applyReleaseMove' -A 10 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 5835


🏁 Script executed:

#!/bin/bash
# Get the full implementation of applyReleaseMove
rg 'export async function applyReleaseMove' -A 50 frontend/lib/services/inventory.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1655


🏁 Script executed:

#!/bin/bash
# Get the complete applyReleaseMove function
rg 'export async function applyReleaseMove' -A 80 frontend/lib/services/inventory.ts | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 1956


The type check for ok === false is unreachable in the current implementation.

applyReleaseMove has a return type of Promise<{ ok: true; applied: boolean }> where ok is always the literal true. The function either returns { ok: true, applied: boolean } or throws an error—it never returns { ok: false }.

The defensive as any casts and the check for ok === false are dead code. The only path that populates releaseFailures is the catch block. Either update the applyReleaseMove signature to support ok: false returns, or remove the unnecessary type guards to clarify that failures are caught via exceptions only.

🤖 Prompt for AI Agents
In `@frontend/lib/services/orders/restock.ts` around lines 205 - 237, The check
for a falsey `ok` is dead because applyReleaseMove returns Promise<{ ok: true;
applied: boolean }> or throws, so remove the unreachable branching and casts: in
the loop over reservedMoves delete the whole "if (res && typeof res === 'object'
&& 'ok' in (res as any) && (res as any).ok === false) { ... }" block and rely on
the existing try/catch to push failures into releaseFailures; alternatively, if
you intend applyReleaseMove to sometimes return {ok: false}, update
applyReleaseMove's return type and implementation (and callers) to allow { ok:
false; reason?: string } so the current guard becomes valid—choose one approach
and make sure reservedMoves loop and releaseFailures handling are consistent
with that decision.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@frontend/app/`[locale]/shop/admin/orders/page.tsx:
- Around line 68-75: The reconcile-stale form in
frontend/app/[locale]/shop/admin/orders/page.tsx posts to
/api/shop/admin/orders/reconcile-stale without a CSRF token; add a hidden CSRF
input to that <form> (generate the token on render using your app's CSRF helper,
e.g., getCsrfToken/getServerCsrfToken) and include it as part of the POST
payload, and then update the API handler for
/api/shop/admin/orders/reconcile-stale to validate that token server-side before
calling requireAdminApi or executing the reconcile logic (implement/ call a
verifyCsrfToken or equivalent function in the API route to reject requests with
missing/invalid tokens).
🧹 Nitpick comments (5)
frontend/app/[locale]/shop/admin/orders/page.tsx (2)

93-135: Consider reordering empty state check for clarity.

The empty state check (lines 129-135) comes after items.map(). While functionally correct (React flattens arrays), placing the empty check first would be more readable:

{items.length === 0 ? (
  <tr>...</tr>
) : (
  items.map(order => ...)
)}

This is a minor style preference—the current code works correctly.


16-16: Inconsistent PAGE_SIZE across admin pages.

Orders page uses PAGE_SIZE = 50 while Products page uses PAGE_SIZE = 25. If this is intentional (orders rows are more compact), consider adding a comment explaining the rationale. Otherwise, consider unifying for consistent UX.

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

16-19: Extract shared parsePage helper to avoid duplication.

This function is duplicated in orders/page.tsx. Consider extracting it to a shared utility (e.g., @/lib/pagination) to maintain DRY principles.

Example shared utility
// `@/lib/pagination.ts`
export function parsePage(input: string | undefined): number {
  const n = Number.parseInt(input ?? '1', 10);
  return Number.isFinite(n) && n > 0 ? n : 1;
}

21-24: Inconsistent formatDate implementations across admin pages.

This version correctly uses locale for localized date formatting. However, orders/page.tsx has a different implementation that ignores locale. Consider unifying both to use the locale-aware version for consistent date display.


83-86: Use English for code comments.

The comment "стабільне сортування (tie-breaker)" is in Ukrainian. For consistency and team accessibility, consider using English: "stable sort (tie-breaker)".

-    // стабільне сортування (tie-breaker)
+    // stable sort with tie-breaker
     .orderBy(desc(products.createdAt), desc(products.id))
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8278c1b and e8c5349.

📒 Files selected for processing (3)
  • frontend/app/[locale]/shop/admin/orders/page.tsx
  • frontend/app/[locale]/shop/admin/products/page.tsx
  • frontend/components/shop/admin/admin-pagination.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
frontend/components/shop/admin/admin-pagination.tsx (1)
frontend/lib/utils.ts (1)
  • cn (4-6)
frontend/app/[locale]/shop/admin/products/page.tsx (6)
frontend/lib/auth/guard-shop-admin-page.ts (1)
  • guardShopAdminPage (9-18)
frontend/lib/shop/currency.ts (2)
  • resolveCurrencyFromLocale (23-28)
  • formatMoney (115-128)
frontend/db/schema/shop.ts (2)
  • productPrices (278-332)
  • products (56-105)
frontend/components/shop/admin/shop-admin-topbar.tsx (1)
  • ShopAdminTopbar (3-43)
frontend/components/shop/admin/admin-product-status-toggle.tsx (1)
  • AdminProductStatusToggle (10-67)
frontend/components/shop/admin/admin-pagination.tsx (1)
  • AdminPagination (16-57)
🔇 Additional comments (6)
frontend/components/shop/admin/admin-pagination.tsx (2)

11-14: LGTM on URL canonicalization logic.

The pageHref helper correctly omits ?page=1 for page 1 (and below), producing cleaner canonical URLs. This prevents duplicate content issues for search engines.


29-55: Well-structured pagination with good accessibility.

The component correctly uses aria-label on the nav element, aria-disabled on inactive controls, and rel="prev"/"next" on links for SEO benefits. The conditional rendering pattern is clean.

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

16-21: LGTM on pagination helpers.

parsePage correctly handles edge cases: defaults to 1 for invalid/missing input, rejects NaN and non-positive numbers. The logic is sound.


52-59: Efficient pagination without COUNT query.

The overfetch-by-one strategy correctly determines hasNext without requiring a separate COUNT query, which is good for performance.

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

106-121: Well-structured table with fixed layout.

The table uses table-fixed with explicit column widths summing to 100%, ensuring consistent layout. The truncation with title attributes for hover tooltips is a good accessibility pattern.


197-201: LGTM on pagination integration.

The AdminPagination component is correctly wired with basePath, page, and hasNext props matching the overfetch-based detection logic.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +68 to +75
<form action="/api/shop/admin/orders/reconcile-stale" method="post">
<button
type="submit"
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
>
Reconcile stale
</button>
</form>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the reconcile-stale API route file
find . -type f -path "*/api/shop/admin/orders/*reconcile*" 2>/dev/null

Repository: DevLoversTeam/devlovers.net

Length of output: 132


🏁 Script executed:

#!/bin/bash
# Search for reconcile-stale in the codebase
rg -t ts -t tsx -t js "reconcile-stale" --files-with-matches

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

#!/bin/bash
# Read the reconcile-stale API route
cat -n ./frontend/app/api/shop/admin/orders/reconcile-stale/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1456


🏁 Script executed:

#!/bin/bash
# Find and read the admin auth file
find . -type f -path "*/lib/auth/admin*" | head -5

Repository: DevLoversTeam/devlovers.net

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Search for requireAdminApi definition
rg -A 10 "export.*requireAdminApi|function requireAdminApi" --max-count 1

Repository: DevLoversTeam/devlovers.net

Length of output: 689


Add CSRF protection to the reconcile-stale form submission.

The form POSTs to /api/shop/admin/orders/reconcile-stale without a CSRF token. The API route only validates authentication/authorization via requireAdminApi, which does not protect against CSRF attacks. Add a CSRF token to the form and validate it server-side to prevent cross-site request forgery on this admin state-changing operation.

🤖 Prompt for AI Agents
In `@frontend/app/`[locale]/shop/admin/orders/page.tsx around lines 68 - 75, The
reconcile-stale form in frontend/app/[locale]/shop/admin/orders/page.tsx posts
to /api/shop/admin/orders/reconcile-stale without a CSRF token; add a hidden
CSRF input to that <form> (generate the token on render using your app's CSRF
helper, e.g., getCsrfToken/getServerCsrfToken) and include it as part of the
POST payload, and then update the API handler for
/api/shop/admin/orders/reconcile-stale to validate that token server-side before
calling requireAdminApi or executing the reconcile logic (implement/ call a
verifyCsrfToken or equivalent function in the API route to reject requests with
missing/invalid tokens).

isFeatured: products.isFeatured,
createdAt: products.createdAt,
price: productPrices.price, // numeric (major) from product_prices
price: productPrices.price,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, check how productPrices.price and productPrices.priceMinor are used across the codebase
rg -n 'productPrices\.(price|priceMinor)' --type ts --type tsx -B 2 -A 2

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Find the schema definitions for productPrices
fd -t ts -t tsx | xargs grep -l "priceMinor\|productPrices" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 240


🏁 Script executed:

# Look at the specific file and context around line 73
cat -n frontend/app/\[locale\]/shop/admin/products/page.tsx | sed -n '65,85p'

Repository: DevLoversTeam/devlovers.net

Length of output: 825


🏁 Script executed:

# Search for productPrices field usage across TypeScript/TSX files
rg 'productPrices\.(price|priceMinor)' -B 2 -A 2

Repository: DevLoversTeam/devlovers.net

Length of output: 4276


🏁 Script executed:

# Find where productPrices schema/type is defined
rg 'productPrices.*=|export.*productPrices|const productPrices' -B 1 -A 3

Repository: DevLoversTeam/devlovers.net

Length of output: 1621


🏁 Script executed:

# Check the broader context of the file to understand the schema and conversion logic
head -100 frontend/app/\[locale\]/shop/admin/products/page.tsx | tail -50

Repository: DevLoversTeam/devlovers.net

Length of output: 1583


🏁 Script executed:

# Look at the admin queries to understand the pattern for admin pages
cat -n frontend/lib/services/products/admin/queries.ts | head -80

Repository: DevLoversTeam/devlovers.net

Length of output: 2969


🏁 Script executed:

# Check if the admin products page is using the admin queries or doing its own query
cat -n frontend/app/\[locale\]/shop/admin/products/page.tsx | head -120

Repository: DevLoversTeam/devlovers.net

Length of output: 5334


🏁 Script executed:

# Find the schema definition for productPrices to see all fields available
cat -n frontend/db/schema/shop.ts | grep -A 20 "export const productPrices"

Repository: DevLoversTeam/devlovers.net

Length of output: 898


🏁 Script executed:

# Look at how the price field is rendered in the admin products page
cat -n frontend/app/\[locale\]/shop/admin/products/page.tsx | sed -n '120,180p'

Repository: DevLoversTeam/devlovers.net

Length of output: 3341


🏁 Script executed:

# Check if there's currency info also selected that would be needed for priceMinor conversion
cat -n frontend/app/\[locale\]/shop/admin/products/page.tsx | sed -n '61,90p'

Repository: DevLoversTeam/devlovers.net

Length of output: 1082


🏁 Script executed:

# Look for the rendering/formatting of the price in the table rows
cat -n frontend/app/\[locale\]/shop/admin/products/page.tsx | sed -n '140,200p'

Repository: DevLoversTeam/devlovers.net

Length of output: 3326


🏁 Script executed:

# Check the fromDbMoney function to understand what conversion is happening
rg -A 5 'export.*function fromDbMoney'

Repository: DevLoversTeam/devlovers.net

Length of output: 429


Use priceMinor instead of the legacy price field.

The schema designates priceMinor as the canonical field (integer, minor units), while price is a legacy mirror. The current code selects the legacy field and converts it at runtime via safeFromDbMoney. Since displayCurrency is already available from context, you can select and use priceMinor directly to eliminate the conversion overhead:

Changes needed

In the select statement (line 73):

-      price: productPrices.price,
+      priceMinor: productPrices.priceMinor,

In the rendering logic (lines 125-141):

-                const priceMinor = safeFromDbMoney(row.price, {
-                  productId: row.id,
-                  currency: displayCurrency,
-                });
-
                return (
                  <tr key={row.id} className="hover:bg-muted/50">
                    ...
                    <td className="px-3 py-2 text-foreground whitespace-nowrap">
-                      {priceMinor === null ? '-' : formatMoney(priceMinor, displayCurrency, locale)}
+                      {row.priceMinor === null ? '-' : formatMoney(row.priceMinor, displayCurrency, locale)}
                    </td>

This aligns with the schema's design and the PR's standardization to minor units.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants