Skip to content

(SP: 3) [Backend] add Nova Poshta shipping foundation + checkout persistence + async label workflow#364

Merged
ViktorSvertoka merged 44 commits intodevelopfrom
lso/feat/shop
Feb 25, 2026
Merged

(SP: 3) [Backend] add Nova Poshta shipping foundation + checkout persistence + async label workflow#364
ViktorSvertoka merged 44 commits intodevelopfrom
lso/feat/shop

Conversation

@liudmylasovetovs
Copy link
Collaborator

@liudmylasovetovs liudmylasovetovs commented Feb 24, 2026

Description

This PR delivers a production-grade, end-to-end shipping foundation for the Shop module (Nova Poshta, warehouse/locker delivery). It introduces DB-backed shipping state, Nova Poshta catalog caching, checkout shipping persistence, payment → shipment enqueue correctness, an internal worker to create shipment labels asynchronously, admin/UI shipping actions, and PII-safe sanitization + retention.


Related Issue

Issue: #<issue_number>


Changes

  • Phase 1 — Schema + migrations foundation

    • Added shipping domain schema + migrations: order_shipping, shipping_shipments, np_cities, np_warehouses, shipping enums, and nullable shipping fields on orders.
    • Enforced Phase 1 policy: shipping CHECK constraints live in SQL migrations (NOT VALID), not in Drizzle check() declarations.
    • Kept Drizzle meta changes limited to drizzle-kit output.
  • Phase 2 — Public shipping API + NP catalog cache

    • Implemented public shipping API endpoints (methods, NP cities, NP warehouses).
    • Added local-first cache behavior and degraded responses when NP is unavailable (no hard failures for common user paths).
  • Phase 3 — Checkout contract + shipping persistence

    • Extended checkout contract to accept shipping payload (NP refs + recipient).
    • Persisted shipping snapshot: orders shipping fields + order_shipping.shipping_address (PII snapshot).
    • Kept shipping cost out of payment totals; idempotency hashing includes NP refs but excludes PII.
  • Phase 4 — Payments correctness: enqueue shipment when PAID applied

    • Hardened webhook apply so “PAID applied” enqueues shipments idempotently (no duplicate shipments on repeated webhooks).
    • Ensured PSP fields handling remains consistent and fail-closed where required.
  • Phase 5 — Worker: create label (TTN) async

    • Added internal shipment runner route + worker lease/claim semantics.
    • Implemented retry/backoff with attempt limits and sanitized error messages (PII-safe).
    • Added supporting tooling for local success-path testing/mocking where applicable.
  • Phase 6 — UI + Admin shipping actions

    • Added Cart UI delivery step (cities/warehouses autocomplete) and ensured checkout includes shipping payload.
    • Added Admin order shipping block + actions via admin routes with CSRF/guardrails.
    • Added contract tests for admin API posture and shipping payload handling.
  • Phase 7 — Sanitizer + retention

    • Added log/DB sanitization to avoid leaking NP payloads or PII into error surfaces.
    • Added internal retention route to anonymize order_shipping.shipping_address after the retention window for delivered/cancelled orders.
    • Added test coverage for retention behavior and log-sanitizer guarantees.

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)

Notes:

  • Local DB safety posture verified before running tests (“do not touch prod”, force local DB via DATABASE_URL_LOCAL, sanity select 1).
  • Key validations performed by phase:
    • Phase 1: migration present in drizzle.__drizzle_migrations; shipping constraints created as NOT VALID per policy; no manual edits to Drizzle meta beyond drizzle-kit output.
    • Phase 2: feature OFF returns available:false; feature ON returns NP methods; local-first catalog serves cached items even when NP is down; cache-miss + NP down returns degraded NP_UNAVAILABLE with no-store.
    • Phase 3: checkout persists shipping fields on orders + PII snapshot in order_shipping; idempotency excludes PII and includes NP refs.
    • Phase 4: webhook apply enqueues shipping_shipments once; repeated webhook does not duplicate shipments.
    • Phase 5: internal worker processes queued shipments with lease + backoff; failures are sanitized and do not leak NP payload/PII.
    • Phase 6: Cart delivery UI passes shipping payload; admin actions are guarded and CSRF-protected; admin contract posture validated.
    • Phase 7: sanitization + retention behavior validated; PII does not leak into worker/webhook logs or persisted error fields.

Relevant test runs included:

  • lib/tests/shop/shipping-methods-route-p2.test.ts
  • lib/tests/shop/shipping-np-cities-route-p2.test.ts
  • lib/tests/shop/shipping-np-warehouses-route-p2.test.ts
  • lib/tests/shop/shipping-internal-np-sync-route-p2.test.ts
  • lib/tests/shop/nova-poshta-client-network-failure.test.ts
  • lib/tests/shop/checkout-shipping-phase3.test.ts
  • lib/tests/shop/checkout-currency-policy.test.ts
  • lib/tests/shop/stripe-webhook-psp-fields.test.ts
  • lib/tests/shop/monobank-webhook-apply.test.ts
  • lib/tests/shop/shipping-shipments-worker-phase5.test.ts
  • lib/tests/shop/admin-csrf-contract.test.ts
  • lib/tests/shop/admin-api-killswitch.test.ts
  • lib/tests/shop/shipping-checkout-payload-phase6.test.ts
  • lib/tests/shop/shipping-log-sanitizer-phase7.test.ts
  • lib/tests/shop/shipping-retention-phase7.test.ts
  • lib/tests/shop/shipping-internal-retention-route-phase7.test.ts

Screenshots (if applicable)


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

    • Nova Poshta shipping: choose city/warehouse or courier at checkout; orders record shipping method, status, and tracking.
    • Admin shipping actions: retry label creation, mark shipped, mark delivered from order view.
  • Updates

    • Cart/checkout: Delivery section with method selection, city/warehouse search, courier/address and recipient fields; checkout gated by shipping availability and validation.
    • Order details: show shipping status, tracking number, compact item labels.
    • Minor dependency updates (TipTap).
  • Tests

    • Extensive shipping-related and webhook tests added.

…wnership test and pass all pre-prod invariants
…obank logging safety invariant, namespace localStorage cart by user and reset on auth change
@vercel
Copy link
Contributor

vercel bot commented Feb 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
devlovers-net Ready Ready Preview, Comment Feb 25, 2026 3:14am

@netlify
Copy link

netlify bot commented Feb 24, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit e4bf2f4
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/699e68b1bfbf620008107af1
😎 Deploy Preview https://deploy-preview-364--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
Contributor

coderabbitai bot commented Feb 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds end-to-end Nova Poshta shipping: DB schema/migrations, NP client and cache, shipping tables/workers/retention, admin shipping actions with CSRF, cart/checkout shipping UI and validation, API routes (public/admin/internal), logging/metrics/sanitizers, env updates, and extensive tests.

Changes

Cohort / File(s) Summary
Schema & Migrations
frontend/db/schema/shop.ts, frontend/drizzle/0017_shop_shipping_core.sql, frontend/drizzle/0018_shop_orders_shipping_invariants.sql, frontend/drizzle/0019_p2_shop_invariants.sql, frontend/drizzle/meta/_journal.json
Add shipping enums/tables (np_cities, np_warehouses, order_shipping, shipping_shipments), extend orders with shipping columns/indexes/checks, and update migration journal.
DB Queries / Types
frontend/db/queries/shop/admin-orders.ts, frontend/lib/types/shop.ts, frontend/app/api/shop/orders/[id]/route.ts, frontend/app/api/shop/admin/orders/[id]/route.ts
Expand order/detail types and queries to include shipping and PSP fields; add masking for shippingAddress in admin GET.
Shipping Core Services
frontend/lib/services/shop/shipping/* (nova-poshta-client.ts, nova-poshta-catalog.ts, availability.ts, checkout-payload.ts, inventory-eligibility.ts, admin-actions.ts, shipments-worker.ts, retention.ts, metrics.ts, log-sanitizer.ts)
New Nova Poshta client, cache/upsert catalog, availability resolver, checkout shipping payload builder, inventory eligibility SQL helpers, admin action engine, shipments worker, retention anonymizer, metrics and log sanitization.
Order & Checkout Services
frontend/lib/services/orders/checkout.ts, frontend/lib/services/orders/_shared.ts, frontend/lib/services/orders/monobank-webhook.ts, frontend/lib/services/orders/monobank-janitor.ts, frontend/lib/services/orders/payment-attempts.ts
Integrate shipping into order creation/idempotency, persist order_shipping snapshots, thread shipping into webhooks/janitor flows, and update payment-attempt syncing.
API Routes — Public & Admin
frontend/app/api/shop/shipping/methods/route.ts, .../np/cities/route.ts, .../np/warehouses/route.ts, frontend/app/api/shop/admin/orders/[id]/shipping/route.ts, frontend/app/api/shop/checkout/route.ts, frontend/app/api/shop/webhooks/stripe/route.ts
New shipping endpoints (methods, cities, warehouses) with rate-limits and caching; admin shipping action route with CSRF/auth and structured error handling; checkout/webhook flows extended for shipping-aware errors and enqueueing.
API Routes — Internal Janitor
frontend/app/api/shop/internal/shipping/np/sync/route.ts, .../shipments/run/route.ts, .../retention/run/route.ts
Protected internal endpoints for NP sync, shipments worker runs, and retention with job-slot gating, auth, and structured responses.
Frontend UI
frontend/app/[locale]/shop/cart/CartPageClient.tsx, frontend/app/[locale]/shop/orders/[id]/page.tsx, frontend/app/[locale]/admin/shop/orders/[id]/page.tsx, frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx, frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx
Client-side shipping flow (methods, city/warehouse search, selection, validation), checkout gating, show shippingStatus/tracking on orders; admin page and ShippingActions component; RefundButton now accepts CSRF token header.
Validation & Types
frontend/lib/validation/shop-shipping.ts, frontend/lib/validation/shop.ts
Add zod schemas for shipping queries, checkout shipping payload, NP sync/worker/retention payloads; extend checkout item schema with variantKey/options.
Env & Config
frontend/lib/env/index.ts, frontend/lib/env/nova-poshta.ts, frontend/.env.example
Add SHOP_SHIPPING_* flags, NP_* envs, retention and rate-limit envs, and Nova Poshta config helpers with validation.
DB Catalog & Utilities
frontend/lib/services/shop/shipping/nova-poshta-catalog.ts, frontend/lib/env/nova-poshta.ts, frontend/lib/shop/locale.ts
Cache-on-miss catalog helpers, config/readers, and locale→country helper.
Tests
frontend/lib/tests/shop/* (many new/updated tests)
Add/extend extensive tests for NP client, routes, checkout shipping, shipments worker, retention, admin CSRF, webhooks, and related helpers.
Tools & Scripts
frontend/scripts/np-mock-server.mjs, frontend/scripts/seed-np-local.*, frontend/scripts/seed-np-local.sql, frontend/package.json
Add NP mock server, local seed scripts/SQL, and bump TipTap deps.
Messages / i18n
frontend/messages/*.json
Add delivery/cart translations and admin order labels (shippingStatus, trackingNumber, short quantity/unit/line strings).

Sequence Diagram(s)

sequenceDiagram
    rect rgba(200,220,255,0.5)
    participant Browser as Client
    participant Cart as CartPageClient
    participant ShippingAPI as /api/shop/shipping/*
    participant CheckoutAPI as /api/shop/checkout
    participant DB as Database
    end

    Browser->>Cart: load checkout page
    Cart->>ShippingAPI: GET /methods or /np/cities (q)
    ShippingAPI->>DB: check flags/cache
    alt cache miss
        ShippingAPI->>NPAPI: Nova Poshta client search
        ShippingAPI->>DB: upsert cities/warehouses
    end
    ShippingAPI-->>Cart: return methods/cities
    Browser->>Cart: submit checkout with shipping
    Cart->>CheckoutAPI: POST /api/shop/checkout (shipping payload)
    CheckoutAPI->>DB: validate refs, persist order + order_shipping snapshot
    CheckoutAPI-->>Browser: order created
Loading
sequenceDiagram
    rect rgba(220,255,220,0.5)
    participant Provider as Payment Provider
    participant WebhookAPI as /api/shop/webhooks/*
    participant DB as Database
    participant Worker as Shipments Worker
    participant NPAPI as Nova Poshta API
    end

    Provider->>WebhookAPI: payment succeeded webhook
    WebhookAPI->>DB: load order + inventoryStatus
    alt inventory committed
        WebhookAPI->>DB: set order.shippingStatus=queued, insert shipping_shipments
    else not committed
        WebhookAPI->>DB: set order.shippingStatus=pending
    end
    Worker->>DB: claim queued shipment
    Worker->>DB: load order_shipping snapshot
    Worker->>NPAPI: createInternetDocument (TTN)
    alt success
        Worker->>DB: mark shipment succeeded, update order.trackingNumber/status
        Worker->>DB: record metric succeeded
    else transient error
        Worker->>DB: increment attemptCount, schedule retry, record metric retries
    else final failure
        Worker->>DB: mark needs_attention, record metric failed
    end
Loading
sequenceDiagram
    rect rgba(255,230,200,0.5)
    participant AdminUI as Admin
    participant AdminAPI as /api/shop/admin/orders/[id]/shipping
    participant Auth as Auth/CSRF
    participant DB as Database
    end

    AdminUI->>AdminAPI: POST action (retry|mark_shipped|mark_delivered) + CSRF
    AdminAPI->>Auth: validate CSRF & admin auth
    AdminAPI->>DB: load order & shipment state
    AdminAPI->>DB: validate transition and apply updates (audit psp_metadata)
    alt retry_label_creation
        AdminAPI->>DB: reset shipment & requeue
    else mark_shipped/mark_delivered
        AdminAPI->>DB: update shipment & order shippingStatus
    end
    AdminAPI-->>AdminUI: return result
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • AM1007
  • ViktorSvertoka

"🐰
I hopped through code with eager paws,
Cities, warehouses, and TTN laws,
I queued and retried with thumps and delight,
Masked a phone, recorded a metric tonight,
Shipment bound — carrots cheer, shipments take flight!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 changes: adding Nova Poshta shipping foundation with checkout persistence and async label workflow.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch lso/feat/shop

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

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c77e1912fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 491 to 495
await markSucceeded({
shipmentId: args.claim.id,
runId: args.runId,
providerRef: created.providerRef,
trackingNumber: created.trackingNumber,

Choose a reason for hiding this comment

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

P1 Badge Validate success transition before returning worker success

processClaimedShipment calls markSucceeded but ignores its return value, even though markSucceeded only updates rows when lease_owner = runId. If the NP API call takes longer than leaseSeconds, another worker can reclaim the shipment after lease expiry; in that case this update becomes a no-op, yet this run still records a succeeded metric and returns 'succeeded', leaving DB state unadvanced and risking duplicate label creation on retries.

Useful? React with 👍 / 👎.

Comment on lines 180 to 181
<main className="mx-auto max-w-6xl px-4 py-8" aria-labelledby="order-title">
<header className="flex items-start justify-between gap-4">

Choose a reason for hiding this comment

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

P2 Badge Remove duplicated admin order page markup block

A second <main> tree starts immediately after the first one begins, and large parts of the page are duplicated. This renders duplicate controls/content and repeats landmark IDs like order-title/items-title, which breaks page structure and makes the admin order detail view confusing and error-prone to use.

Useful? React with 👍 / 👎.

Copy link
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: 12

Caution

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

⚠️ Outside diff range comments (3)
frontend/lib/services/orders/monobank-janitor.ts (2)

332-347: ⚠️ Potential issue | 🟡 Minor

toJob3CandidateRow does not map the new raw_sha256 field.

Job3CandidateRow now includes raw_sha256: string | null (line 57), but toJob3CandidateRow omits it from the returned object. The TypeScript compiler should flag this as a type error.

This function is currently dead code (only called from the now-unused claimJob3Events), so it has no runtime impact, but it will cause a compile error.

Proposed fix
 function toJob3CandidateRow(row: MonobankEventRow): Job3CandidateRow {
   return {
     id: row.id,
     invoice_id:
       typeof row.invoice_id === 'string'
         ? row.invoice_id
         : (row.invoice_id ?? null),
     attempt_id:
       typeof row.attempt_id === 'string'
         ? row.attempt_id
         : (row.attempt_id ?? null),
     provider_modified_at: row.provider_modified_at,
     received_at: row.received_at,
     raw_payload: row.raw_payload,
+    raw_sha256: typeof row.raw_sha256 === 'string' ? row.raw_sha256 : null,
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 332 - 347, The
function toJob3CandidateRow is missing the new raw_sha256 field from
Job3CandidateRow; update toJob3CandidateRow to include raw_sha256 by reading
row.raw_sha256 from the MonobankEventRow and mapping it the same way as
invoice_id/attempt_id (e.g., return the string when typeof row.raw_sha256 ===
'string' otherwise use (row.raw_sha256 ?? null)) so the returned object matches
the Job3CandidateRow type and the TypeScript error is resolved.

349-381: 🛠️ Refactor suggestion | 🟠 Major

Remove dead code: claimJob3Events, markJob3EventFailed, toJob3CandidateRow, and releaseEventLease

These functions are not called anywhere in the codebase. They depend on claimNextMonobankEvent, MonobankEventRow, and JOB3_EVENT_ERROR_CODE which are also unreachable. Removing them will reduce maintenance burden and eliminate unused code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 349 - 381,
Remove the unused dead code by deleting the functions claimJob3Events,
markJob3EventFailed, toJob3CandidateRow, and releaseEventLease from
monobank-janitor.ts, and also remove any now-unreferenced constants/types they
depend on (claimNextMonobankEvent, MonobankEventRow, JOB3_EVENT_ERROR_CODE) if
those are not used elsewhere; ensure imports or references to these symbols are
cleaned up so there are no unused-import/type lint errors and run tests/TS build
to confirm nothing else depends on them.
frontend/app/[locale]/admin/shop/orders/[id]/page.tsx (1)

170-280: ⚠️ Potential issue | 🔴 Critical

Critical: Duplicate JSX blocks will cause build/render failure.

The file contains both old and new versions of multiple JSX blocks, resulting in malformed JSX. Biome confirms 9 parse errors (unclosed <header>, <div>, <dl>, <article>, <section>, <table>, etc.). Examples of duplication:

  • Lines 170–179 (new <main>/<header>) and 180–189 (old identical block)
  • Lines 191–197 (new Back link <div>) and 198–204 (old identical block)
  • Lines 223–227 (new <dl>) and 228–232 (old identical block)
  • Lines 461–488 (new <thead>) and 489–516 (old identical block)
  • Lines 562–573 and 549–561 (duplicate closing tags)

Remove the duplicate (old) blocks so only one version of each JSX section remains. The file will not compile in its current state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx around lines 170 -
280, The file contains duplicated JSX blocks (e.g., the duplicated <main> /
<header> block, the repeated Back link <div>, the repeated <dl> Summary section,
duplicated <thead> and duplicated closing tags) which breaks the JSX parse; open
frontend/app/[locale]/admin/shop/orders/[id]/page.tsx, locate the duplicated
sections around the Order header, the Back Link block, the Summary <dl> (where
order.paymentStatus, totalFormatted, paymentProvider, paymentIntentId,
idempotencyKey are rendered) and the table <thead>, and remove the
older/duplicate copies so each JSX element (main, header, div, dl, article,
section, thead, etc.) appears exactly once and remains properly nested/closed
(keeping the version that includes RefundButton, order, and totalFormatted).
Ensure no leftover duplicate closing tags remain and the component compiles.
🧹 Nitpick comments (28)
frontend/package.json (1)

37-40: Align @tiptap/core version floor with the other TipTap packages.

@tiptap/core@3.20.0 is the current latest on npm. The three bumped packages now declare ^3.20.0, but @tiptap/core is left at ^3.19.0. While npm's semver resolution will typically install 3.20.0 for ^3.19.0, the declared minimum is inconsistent and may produce peer-dependency warnings in strict package managers (e.g., pnpm) if the updated packages' peer specs require ^3.20.0. The 3.20.0 release notably fixed a runtime error when initializing CodeBlockLowlight by switching to a named export, preventing extend is not a function errors caused by ESM/CJS interop issues.

♻️ Proposed fix
-    "@tiptap/core": "^3.19.0",
+    "@tiptap/core": "^3.20.0",
     "@tiptap/extension-code-block-lowlight": "^3.20.0",
     "@tiptap/react": "^3.20.0",
     "@tiptap/starter-kit": "^3.20.0",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/package.json` around lines 37 - 40, Update the `@tiptap/core`
dependency in package.json from "^3.19.0" to "^3.20.0" so it matches the other
TipTap packages (`@tiptap/extension-code-block-lowlight`, `@tiptap/react`,
`@tiptap/starter-kit`) and avoids peer-resolution/runtime issues; after editing
the dependency entry for "@tiptap/core" run your package manager install
(npm/yarn/pnpm) to refresh lockfiles and verify no peer-dependency warnings.
frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts (1)

145-151: Consider DRYing the worker invocation options.

The same runShippingShipmentsWorker argument block is repeated across tests. A tiny helper reduces repetition and keeps defaults consistent.

♻️ Suggested refactor
+const baseWorkerArgs = {
+  limit: 10,
+  leaseSeconds: 120,
+  baseBackoffSeconds: 10,
+};
+
+async function runWorker(
+  overrides: Partial<Parameters<typeof runShippingShipmentsWorker>[0]> = {}
+) {
+  return runShippingShipmentsWorker({
+    runId: crypto.randomUUID(),
+    maxAttempts: 5,
+    ...baseWorkerArgs,
+    ...overrides,
+  });
+}
-      const result = await runShippingShipmentsWorker({
-        runId: crypto.randomUUID(),
-        limit: 10,
-        leaseSeconds: 120,
-        maxAttempts: 5,
-        baseBackoffSeconds: 10,
-      });
+      const result = await runWorker();
-      const result = await runShippingShipmentsWorker({
-        runId: crypto.randomUUID(),
-        limit: 10,
-        leaseSeconds: 120,
-        maxAttempts: 5,
-        baseBackoffSeconds: 10,
-      });
+      const result = await runWorker();
-      const result = await runShippingShipmentsWorker({
-        runId: crypto.randomUUID(),
-        limit: 10,
-        leaseSeconds: 120,
-        maxAttempts: 3,
-        baseBackoffSeconds: 10,
-      });
+      const result = await runWorker({ maxAttempts: 3 });

Also applies to: 207-213, 262-268

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts` around
lines 145 - 151, The repeated runShippingShipmentsWorker argument block should
be extracted into a small helper to DRY the tests: add a function (e.g.,
makeRunShippingOpts or defaultRunShippingOpts) that returns the common object {
runId: crypto.randomUUID(), limit: 10, leaseSeconds: 120, maxAttempts: 5,
baseBackoffSeconds: 10 } and update each test invocation of
runShippingShipmentsWorker to use that helper with object spread to override
values when needed (e.g., runShippingShipmentsWorker({
...defaultRunShippingOpts(), runId: crypto.randomUUID() })). Locate usages
around the existing runShippingShipmentsWorker calls (current repeats at lines
near the shown block and also at the other two occurrences) and replace them to
keep defaults consistent.
frontend/scripts/seed-np-local.ps1 (1)

24-25: Prefer Write-Output/Write-Information over Write-Host.
Write-Host can’t be redirected or captured cleanly and is flagged by PSScriptAnalyzer.

Suggested adjustment
-  Write-Host "Seeding NP local catalog from: $SeedPath"
+  Write-Information "Seeding NP local catalog from: $SeedPath"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/scripts/seed-np-local.ps1` around lines 24 - 25, Replace the
non-redirectable Write-Host call with a redirectable cmdlet: use Write-Output
(or Write-Information if you want a different stream) for the message that
includes $SeedPath; update the line that currently calls Write-Host "Seeding NP
local catalog from: $SeedPath" to use Write-Output (or Write-Information) so the
message can be captured by redirection and PSScriptAnalyzer will not flag it.
frontend/lib/services/shop/shipping/inventory-eligibility.ts (1)

24-28: Refactor to accept typed SQL expression instead of raw string.
The current API accepts columnReference: string and uses sql.raw(), which bypasses parameterization. Drizzle's recommended pattern is to interpolate Drizzle column/table objects directly into sql\``, which automatically escapes them as identifiers. This type-safe approach prevents accidental SQL injection if the function is called with untrusted input.

Suggested refactor
-export function inventoryCommittedForShippingSql(columnReference: string): SQL {
-  return sql`${sql.raw(columnReference)} in (${sql.join(
+export function inventoryCommittedForShippingSql(columnReference: SQL): SQL {
+  return sql`${columnReference} in (${sql.join(
     INVENTORY_COMMITTED_FOR_SHIPPING.map(value => sql`${value}`),
     sql`, `
   )})`;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/inventory-eligibility.ts` around lines 24
- 28, Change inventoryCommittedForShippingSql to accept a typed Drizzle SQL
expression instead of a raw string: replace the parameter type from string to
the appropriate SQL expression type (e.g., SQL or the Drizzle column type used
in your codebase) and stop using sql.raw. Interpolate the passed expression
directly into the template (e.g., sql`${columnReference}`) and keep returning
SQL; retain the use of INVENTORY_COMMITTED_FOR_SHIPPING.map(...) for the values.
Update any callers to pass the Drizzle column/table expression rather than a raw
string.
frontend/lib/services/orders/_shared.ts (1)

69-96: Prefer normalizing empty strings to undefined to avoid leaking empty values downstream.
This keeps semantics consistent with “absent” vs “present but empty” and avoids persisting empty strings.

Suggested adjustment
-  const selectedSize =
-    normVariant(item.selectedSize) ||
-    optionValue(normalizedOptions, ['size', 'selectedsize']);
-  const selectedColor =
-    normVariant(item.selectedColor) ||
-    optionValue(normalizedOptions, ['color', 'selectedcolor']);
+  const selectedSizeRaw =
+    normVariant(item.selectedSize) ||
+    optionValue(normalizedOptions, ['size', 'selectedsize']);
+  const selectedColorRaw =
+    normVariant(item.selectedColor) ||
+    optionValue(normalizedOptions, ['color', 'selectedcolor']);
+  const selectedSize = selectedSizeRaw || undefined;
+  const selectedColor = selectedColorRaw || undefined;

-  const variantKey = normVariant(item.variantKey);
+  const variantKey = normVariant(item.variantKey) || undefined;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/_shared.ts` around lines 69 - 96,
normalizeCheckoutItem currently lets empty strings flow through (for
selectedSize, selectedColor, variantKey and option values) which can leak empty
values downstream; update the function to treat empty-string results as
undefined by wrapping/normalizing outputs from normVariant and optionValue (or
use a small helper like normalizeEmpty) so selectedSize, selectedColor,
variantKey and any size/color entries placed into options become undefined
instead of "". Call normVariant on item.variantKey and on optionValue results
and ensure when building the options object you only spread size/color when the
normalized value is truthy (not an empty string).
frontend/lib/tests/shop/shipping-internal-np-sync-route-p2.test.ts (2)

20-28: Consider calling resetEnvCache() in beforeEach for consistency with sibling tests.

The retention route test (shipping-internal-retention-route-phase7.test.ts) imports and calls resetEnvCache() before each test to ensure cached env values are flushed after vi.unstubAllEnvs() + re-stubbing. This test file omits it. If more test cases are added later (e.g., testing the happy path with different flag combinations), stale cached env values could cause flaky results.

Suggested change
+import { resetEnvCache } from '@/lib/env';
+
 // ...
 
 describe('internal shipping np sync route (phase 2)', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.unstubAllEnvs();
+    resetEnvCache();
     vi.stubEnv('INTERNAL_JANITOR_SECRET', 'test-secret');
     vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true');
     vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true');
     vi.stubEnv('SHOP_SHIPPING_SYNC_ENABLED', 'true');
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/shipping-internal-np-sync-route-p2.test.ts` around
lines 20 - 28, The beforeEach setup in the test suite should call
resetEnvCache() after vi.unstubAllEnvs() to flush any cached environment
lookups; update the beforeEach in the describe block (the beforeEach function
that currently calls vi.clearAllMocks(), vi.unstubAllEnvs(), and
vi.stubEnv(...)) to invoke resetEnvCache() immediately after vi.unstubAllEnvs()
so subsequent vi.stubEnv calls are applied to a clean env cache and avoid
flakiness.

30-51: Test coverage is minimal — only the unauthorized path is exercised.

The single test case validates the 401 response when the internal secret is missing, which is a good guard-rail test. However, there's no coverage for the happy path, feature-flag-disabled path, invalid payload, or rate-limiting behavior of this route. Consider adding at least a feature-disabled test (returns 200 with success: false, code: 'FEATURE_DISABLED') and a valid-sync test to confirm end-to-end wiring.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/shipping-internal-np-sync-route-p2.test.ts` around
lines 30 - 51, Add tests in shipping-internal-np-sync-route-p2.test.ts to cover
the happy and feature-disabled paths: create one test that stubs the
feature-flag to disabled (the same setup that the route's POST handler checks)
and asserts the response is 200 with JSON { success: false, code:
'FEATURE_DISABLED' } and that dbExecuteMock is not called, and another test that
sends a valid POST to the POST handler with the internal secret header present
and a valid payload and then asserts a 200 success response (or the expected
success payload) and that dbExecuteMock was called with the expected params;
reference the existing POST test harness and dbExecuteMock to reuse mocks and
request construction.
frontend/app/api/shop/admin/orders/[id]/route.ts (1)

50-56: maskEmail exposes the full domain — verify this is intentional.

For "ivan@example.com", maskEmail produces "i***n@example.com", exposing the complete domain. This is common practice (domains are less sensitive), but worth confirming it aligns with your privacy policy, especially for custom/corporate email domains that could identify an organization.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/admin/orders/`[id]/route.ts around lines 50 - 56,
maskEmail currently reveals the full domain (maskEmail) — confirm this is
intentional per privacy policy or change the implementation to also obfuscate
the domain; specifically update the maskEmail function to either (a) leave as-is
after a PR note confirming policy alignment, or (b) when masking the domain
replace the domain part with a masked string (e.g., keep only the TLD or replace
everything before/after the dot with ***) so "ivan@example.com" no longer
exposes "example.com", and update any callers/tests that expect the old output
accordingly.
frontend/scripts/seed-np-local.sql (1)

1-3: Consider adding inline comments with decoded city/warehouse names for readability.

The hex-encoded values are opaque. A short comment next to each record (e.g., -- Kyiv Local Test, -- Lviv Local Test) would help developers quickly understand the seed data without decoding hex manually.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/scripts/seed-np-local.sql` around lines 1 - 3, The seed file uses
hex-encoded Cyrillic values (note the comment "Cyrillic values are injected via
hex->UTF8" and the SET client_encoding = 'UTF8') which are hard to read; update
each INSERT/record line in seed-np-local.sql to append a short inline comment
with the decoded city/warehouse names (for example "-- Kyiv Local Test", "--
Lviv Local Test") so developers can understand entries at a glance without
decoding the hex; keep comments brief and placed immediately after the hex
value(s) for each record.
frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts (1)

33-51: Consider using Vitest's rejects matcher for more idiomatic async error assertions.

The try/catch + sentinel throw pattern works, but Vitest provides expect(...).rejects which is more concise and avoids the risk of the sentinel being silently swallowed if the assertion logic itself throws.

For example:

await expect(searchSettlements({ q: 'kyiv', page: 1, limit: 10 }))
  .rejects.toThrow(NovaPoshtaApiError);

That said, the current approach does allow richer multi-property assertions on the caught error. If you want to keep the detailed checks, the existing pattern is acceptable.

Also applies to: 53-71

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts` around
lines 33 - 51, Replace the try/catch + sentinel pattern with Vitest's async
rejects matchers: call the promise once (e.g., const p = searchSettlements({ q:
'kyiv', page: 1, limit: 10 })), then use await
expect(p).rejects.toBeInstanceOf(NovaPoshtaApiError) and await
expect(p).rejects.toMatchObject({ code: 'NP_FETCH_FAILED', status: 0, message:
'fetch failed' }); keep the existing fetch mock (fetchMock) assertion with
expect(fetchMock).toHaveBeenCalledTimes(1), and assert the cause by awaiting
p.catch(e => expect((e as NovaPoshtaApiError & { cause?: unknown
}).cause).toBe(fetchError)); apply the same change to the other test block that
covers lines 53-71.
frontend/app/api/shop/internal/shipping/shipments/run/route.ts (1)

27-44: Consider extracting shared internal-route helpers.

normalizeDate, noStoreJson, retryAfterSeconds, and acquireJobSlot are duplicated verbatim between this file and frontend/app/api/shop/internal/shipping/np/sync/route.ts (and likely the retention route). Extracting them into a shared module (e.g., @/lib/services/shop/shipping/internal-route-utils) would reduce duplication and make future changes easier to apply consistently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/internal/shipping/shipments/run/route.ts` around lines
27 - 44, The four helper functions normalizeDate, noStoreJson, retryAfterSeconds
and acquireJobSlot are duplicated across internal shipping routes; extract them
into a single shared module (e.g., create a new module like
"@/lib/services/shop/shipping/internal-route-utils") and replace the local
definitions in route.ts and the other files with imports from that module;
ensure the shared module exports these named functions and that noStoreJson
still sets the same headers (Cache-Control and X-Request-Id) and
retryAfterSeconds and normalizeDate preserve their existing behavior and types
so callers (including acquireJobSlot) continue to work unchanged.
frontend/lib/services/shop/shipping/admin-actions.ts (2)

114-131: LIMIT 1 on the shipments join may pick an arbitrary row if multiple shipments exist.

loadShippingState joins shipping_shipments and takes LIMIT 1. If an order ever has multiple shipment records (e.g., a retry created a second row), the returned shipment_id/shipment_status may not correspond to the most relevant one. Consider adding ORDER BY s.created_at DESC before the LIMIT 1 to ensure the latest shipment is used.

♻️ Suggested fix
     from orders o
     left join shipping_shipments s on s.order_id = o.id
     where o.id = ${orderId}::uuid
+    order by s.created_at desc nulls last
     limit 1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/admin-actions.ts` around lines 114 - 131,
The query in loadShippingState returns an arbitrary shipment when multiple
shipping_shipments rows exist; update the SQL in the sql`...` block inside
loadShippingState to order shipments by newest first (e.g., add "ORDER BY
s.created_at DESC" — or "ORDER BY s.created_at DESC NULLS LAST" if created_at
can be NULL) before the "LIMIT 1" so the returned shipment_id/shipment_status in
ShippingStateRow corresponds to the latest shipment record.

359-421: Add an explicit guard for mark_delivered action.

After the retry_label_creation and mark_shipped branches return, the remaining code implicitly handles mark_delivered without checking args.action. While TypeScript's union type currently guarantees only three values, this is fragile — adding a new ShippingAdminAction variant would silently fall into the delivery path.

♻️ Suggested fix
+  if (args.action !== 'mark_delivered') {
+    throw new ShippingAdminActionError(
+      'UNSUPPORTED_ACTION',
+      `Unsupported shipping admin action: ${args.action}`,
+      400
+    );
+  }
+
   if (state.shipping_status === 'delivered') {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/admin-actions.ts` around lines 359 - 421,
The code currently assumes any fall-through path is the mark_delivered action;
add an explicit guard that checks args.action === 'mark_delivered' before
running the delivery logic (the block that calls appendAuditEntry,
updateOrderShippingStatus, loadShipmentStatus, and returns the delivered
response). If args.action is not 'mark_delivered', throw a
ShippingAdminActionError (e.g., 'UNSUPPORTED_SHIPPING_ACTION' or similar) so new
ShippingAdminAction variants can't silently be handled as delivery; update the
existing error messages accordingly and keep usage of updateOrderShippingStatus,
appendAuditEntry, and loadShipmentStatus unchanged inside the guarded branch.
frontend/app/[locale]/shop/cart/CartPageClient.tsx (1)

145-178: Consider extracting shipping state into a custom hook or useReducer.

There are 15+ new useState calls for shipping-related state (methods, city query/options/selection, warehouse query/options/selection, recipient fields, delivery error). Extracting this into a useShippingForm() custom hook or collapsing related state into a useReducer would significantly reduce complexity and improve testability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx around lines 145 - 178,
The component currently contains many related shipping state variables
(shippingMethods, shippingMethodsLoading, shippingAvailable, shippingReasonCode,
selectedShippingMethod, cityQuery, cityOptions, selectedCityRef,
selectedCityName, citiesLoading, warehouseQuery, warehouseOptions,
selectedWarehouseRef, selectedWarehouseName, warehousesLoading,
courierAddressLine1/2, recipientName/Phone/Email/Comment, deliveryUiError) which
should be collapsed into a single custom hook (e.g. useShippingForm) or a
useReducer-based state slice; implement useShippingForm to own all these pieces
and expose state and updater handlers (getShippingState, setShippingState /
specific setters like setCityQuery, selectCity, setWarehouseQuery,
selectWarehouse, setRecipientField, clearDeliveryError, fetchShippingMethods) so
the CartPageClient.tsx replaces the 15+ useState calls with a single hook call
and uses the returned handlers; ensure the hook encapsulates async loading flags
and error handling, keeps component API unchanged where possible, and add unit
tests for the hook to validate state transitions (selectCity/selectWarehouse,
fetchShippingMethods success/failure, recipient field updates).
frontend/lib/services/shop/shipping/checkout-payload.ts (2)

73-78: Third copy of locale→country inference.

countryFromLocale is the third implementation of the same 'uk' → 'UA' mapping (also in availability.ts as inferCountryFromLocale and checkout.ts as mapLocaleToCountry). All three normalize, split on [-_], and check for 'uk'.

This file is likely client-importable (no 'server-only'), so it can't import from server modules — but a shared utility in a common/shared location would prevent drift across the three implementations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/checkout-payload.ts` around lines 73 -
78, countryFromLocale duplicates logic already present as inferCountryFromLocale
and mapLocaleToCountry; extract the locale→country mapping into a single shared
utility (e.g., a new client-safe module under a common/shared location) and
replace the three implementations to import and call that function; ensure the
new function preserves normalization/splitting behavior (handles trimOrNull
semantics or inline equivalent) and the specific mapping ('uk' -> 'UA') and
verify the module is client-importable so frontend code can safely import it
instead of keeping three copies.

6-12: ShippingUnavailableReasonCode includes 'OK', which is not an "unavailable" reason.

The type name suggests it represents reasons for unavailability, but 'OK' and 'INTERNAL_ERROR' don't fit that semantic. Consider renaming to ShippingAvailabilityReasonCode (matching availability.ts) or ShippingReasonCode to better reflect the full range of values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/checkout-payload.ts` around lines 6 - 12,
The type name ShippingUnavailableReasonCode is misleading because it includes
non-unavailable values like 'OK' and 'INTERNAL_ERROR'; rename the type to a name
reflecting both available and unavailable states (e.g.,
ShippingAvailabilityReasonCode or ShippingReasonCode) and update all
references/imports/usages in this module and across the codebase (search for
ShippingUnavailableReasonCode) to the new name, making sure any related files
such as availability.ts that use the same enum-like values are aligned to the
chosen name.
frontend/db/schema/shop.ts (1)

160-168: Consider a CHECK constraint for shipping field consistency on orders.

All shipping columns are nullable, which is correct for orders that don't require shipping. However, there's no DB-level guard ensuring that when shipping_required = true, the essential fields (shipping_provider, shipping_method_code, shipping_status) are also populated, and conversely that when shipping_required is false/null, no shipping fields are set.

This is not blocking — the application layer enforces this today — but a CHECK constraint would provide defense-in-depth consistent with the pattern already used in this table (e.g., orders_psp_fields_null_when_none).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/schema/shop.ts` around lines 160 - 168, Add a DB-level CHECK
constraint on the orders schema to enforce shipping field consistency: when
shippingRequired (shipping_required) is true, require shippingProvider
(shipping_provider), shippingMethodCode (shipping_method_code) and
shippingStatus (shipping_status) to be non-null, and when shippingRequired is
false or null ensure those shipping fields are all null; implement this as a
named CHECK similar to orders_psp_fields_null_when_none so it lives alongside
existing constraints and validates shippingRequired, shipping_provider,
shipping_method_code, and shipping_status together.
frontend/lib/services/shop/shipping/availability.ts (2)

49-54: Unreachable fallback on currency

input.currency is typed as CurrencyCode (non-nullable, non-optional), so the ?? resolveCurrencyFromLocale(locale) branch on line 54 can never execute. This is dead code that might mislead readers into thinking currency can be absent.

If the intent is to defensively handle untyped callers, consider widening the input type to CurrencyCode | null | undefined and documenting it; otherwise, drop the fallback.

Proposed fix
-  const currency = input.currency ?? resolveCurrencyFromLocale(locale);
+  const currency = input.currency;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/availability.ts` around lines 49 - 54,
The fallback for currency is dead code because input.currency is typed
non-nullable CurrencyCode; update resolveShippingAvailability to stop using the
nullish fallback — replace "const currency = input.currency ??
resolveCurrencyFromLocale(locale);" with "const currency = input.currency;" (or
alternatively widen the ShippingAvailabilityInput currency type to CurrencyCode
| null | undefined if you intend to accept missing currencies and keep
resolveCurrencyFromLocale). Ensure changes are applied in the
resolveShippingAvailability function and adjust types or remove the
resolveCurrencyFromLocale call accordingly.

42-47: Duplicate locale→country inference logic

inferCountryFromLocale here duplicates mapLocaleToCountry in frontend/lib/services/orders/checkout.ts (lines 327-331). Both split on [-_], lower-case, and map 'uk''UA'. Consider extracting a shared helper to avoid drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/availability.ts` around lines 42 - 47,
The function inferCountryFromLocale duplicates logic from mapLocaleToCountry;
extract a single shared helper (e.g., localeToCountry or mapLocaleToCountry)
into a common utility module and have both inferCountryFromLocale and the
checkout usage import and call that helper, preserving the behavior (split on
/[-_]/, toLowerCase, map 'uk' -> 'UA' and return null otherwise); update imports
in frontend/lib/services/shop/shipping/availability.ts and
frontend/lib/services/orders/checkout.ts to use the shared function and run
tests to ensure behavior is unchanged.
frontend/lib/services/shop/shipping/shipments-worker.ts (3)

121-135: All non-HTTP NP API errors are classified as transient — may cause unnecessary retries for permanent failures.

Lines 130–134: any NovaPoshtaApiError that isn't NP_HTTP_ERROR or NP_INVALID_JSON falls through to the default branch, which sets transient: true. However, some NP API error codes indicate permanent failures (e.g., invalid address, recipient data errors). These will be retried up to maxAttempts before finally being marked needs_attention.

Consider checking for known permanent NP error codes (e.g., validation-related errorCodes from the NP envelope) and marking those as transient: false for faster escalation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts` around lines 121 -
135, The current NovaPoshtaApiError handler in the shipments worker treats all
non-HTTP/invalid-json NP errors as transient; update the NovaPoshtaApiError
branch (the code that returns { code: error.code || 'NP_API_ERROR', message:
..., transient: true }) to detect known permanent NP error indicators (e.g.,
validation/recipient/address error codes found on the error object like
error.errorCodes, error.errors or error.data?.errorCodes) and set transient:
false for those codes; implement a small set/list of permanent NP error codes
and check membership before returning, so permanent failures return transient:
false and others keep transient: true.

573-617: Worker processes claims sequentially — acceptable for now but may become a bottleneck.

The for loop on line 587 processes each claimed shipment serially. With the NP API call in each iteration, throughput is bounded by NP response latency × claim count. If the batch size grows, consider Promise.allSettled with a concurrency limiter, though the current sequential approach is simpler and avoids overwhelming the NP API.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts` around lines 573 -
617, The loop in runShippingShipmentsWorker processes claimed shipments
sequentially via awaiting processClaimedShipment for each claim, which limits
throughput; change this to process claims in parallel with bounded concurrency
(e.g., use Promise.allSettled combined with a concurrency limiter like p-limit
or a simple worker-pool) when calling processClaimedShipment for each item
returned by claimQueuedShipmentsForProcessing, collect all outcomes from the
settled promises and then aggregate processed/succeeded/retried/needsAttention
counts the same way before logging and returning the result to preserve behavior
while increasing throughput.

268-270: Hardcoded brand name in shipment description.

"DevLovers order ${args.order.order_id}" embeds a brand name. Consider pulling this from config or an environment variable for portability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts` around lines 268 -
270, The shipment payload is using a hardcoded brand in the description
("DevLovers order ${args.order.order_id}"); update the description to read the
brand from configuration/environment instead (e.g., use config.brandName or
process.env.BRAND_NAME) so it becomes `${brand} order ${args.order.order_id}`;
modify the code that builds the description field in shipments-worker.ts (the
description property) to use that config value and provide a sensible default
fallback if the config key is missing.
frontend/lib/services/orders/checkout.ts (2)

711-746: Complex idempotency hash reconstruction with shipping — consider documenting the derivation logic.

The hash derivation for existing orders (lines 711–746) reconstructs a normalized representation of the order, including the new shipping refs, from disparate sources (order row, order items, and the shipping snapshot). The options object construction (lines 719–726) with conditional spread and repeated normVariant calls is difficult to follow.

This is correct but brittle — any change to how hashIdempotencyRequest normalizes its input must be mirrored here. Consider adding a comment or extracting a deriveExistingOrderHash(row, items, shipping) helper to isolate this logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/checkout.ts` around lines 711 - 746, The
idempotency hash reconstruction in the derivedExistingHash computation is
complex and brittle; extract this logic into a dedicated helper (e.g.,
deriveExistingOrderHash) that accepts row, existing/items, existingCityRef,
existingWarehouseRef and returns the normalized payload currently passed to
hashIdempotencyRequest, reusing normVariant and resolvePaymentProvider inside
the helper and centralizing the conditional options/size/color spreads and
shipping branch; replace the inline block in the derivedExistingHash assignment
with a call to that helper to improve readability and make future changes to
hashIdempotencyRequest normalization easier to maintain.

650-652: Fix the type mismatch causing unsafe casting.

mergeCheckoutItems returns CheckoutItem[] by its type signature but internally builds CheckoutItemWithVariant objects. This forces the caller to cast at line 652. Since normalizeCheckoutItem requires CheckoutItemWithVariant, change mergeCheckoutItems to return CheckoutItemWithVariant[] to eliminate the unsafe cast:

Type-safe fix
-export function mergeCheckoutItems(items: CheckoutItem[]): CheckoutItem[] {
+export function mergeCheckoutItems(items: CheckoutItem[]): CheckoutItemWithVariant[] {

Then in checkout.ts:650-652, remove the cast:

  const normalizedItems = mergeCheckoutItems(
    items
- ).map(item => normalizeCheckoutItem(item as CheckoutItemWithVariant));
+ ).map(item => normalizeCheckoutItem(item));

This also eliminates the duplicate cast already present in mergeCheckoutItems line 142.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/checkout.ts` around lines 650 - 652,
mergeCheckoutItems is typed to return CheckoutItem[] but actually builds
CheckoutItemWithVariant objects, forcing unsafe casts where it's used; update
the mergeCheckoutItems function signature to return CheckoutItemWithVariant[]
and remove the redundant casts inside mergeCheckoutItems (the duplicate cast
mentioned) and at its call site in checkout.ts where you currently cast to
CheckoutItemWithVariant before calling normalizeCheckoutItem; this makes
normalizeCheckoutItem(item: CheckoutItemWithVariant) type-safe without any
manual casting.
frontend/lib/services/shop/shipping/nova-poshta-client.ts (1)

279-285: isPostMachine relies on string heuristics in category/type descriptions.

If Nova Poshta changes their CategoryOfWarehouse or TypeOfWarehouse text (e.g., uses Ukrainian-language descriptions), this check will silently stop detecting parcel lockers. Consider also checking for PostFinance type or the Ukrainian equivalents (поштомат), or logging when neither category nor type matches known patterns so drift is observable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts` around lines 279 -
285, The isPostMachine heuristic in nova-poshta-client.ts currently only checks
English substrings from readString(item.CategoryOfWarehouse) and
readString(item.TypeOfWarehouse), which will break if Nova Poshta uses other
terms; update isPostMachine to match against a localized set of patterns (e.g.,
include 'поштомат', 'поштомат', 'postfinance', 'postomat', 'parcel locker',
etc.) by constructing a normalized array of known tokens and testing
category/type membership, and add a lightweight diagnostic log (or telemetry
hit) when neither field matches any known pattern so drift is observable; keep
references to isPostMachine, CategoryOfWarehouse, TypeOfWarehouse, and
readString to locate the change.
frontend/lib/services/shop/shipping/nova-poshta-catalog.ts (2)

101-125: Avoid full-table scans when q is blank/whitespace.
If q is empty, the query becomes ILIKE '%%', which can scan the full active table. Consider short-circuiting to an empty result (or a dedicated “popular cities” path).

♻️ Suggested guard
 export async function findCachedCities(args: {
   q: string;
   limit: number;
 }): Promise<ShippingCity[]> {
-  const like = `%${args.q}%`;
+  const q = args.q.trim();
+  if (!q) return [];
+  const like = `%${q}%`;
   const res = await db.execute<CityRow>(sql`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts` around lines 101
- 125, The findCachedCities function currently runs an ILIKE '%...%' query even
when args.q is empty/whitespace causing full-table scans; add a guard at the
start of findCachedCities to trim args.q and if it's empty return an empty
ShippingCity[] (or route to a dedicated “popular cities” path) instead of
executing the SQL against np_cities; keep the rest of the logic (mapCityRow,
ordering, limit) unchanged and ensure the check uses args.q.trim() to catch
whitespace-only inputs.

355-389: Consider avoiding refresh when q misses but cache is already populated.
As written, any q with zero matches will trigger a full NP fetch for the settlement, even if the local cache already contains warehouses. You could skip refresh when a settlement already has cached entries, and only refresh on true cache misses.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts` around lines 355
- 389, The function findWarehousesWithCacheOnMiss currently triggers cache
refresh for any q that returns zero results even when the settlement cache is
already populated; change the logic to avoid calling cacheWarehousesBySettlement
when the settlement already has cached entries. Specifically, before calling
cacheWarehousesBySettlement, call findCachedWarehouses for the same settlement
with no q (or q undefined) and limit 1 to detect if any cached entries exist;
only invoke cacheWarehousesBySettlement when that check returns zero (true cache
miss). If the settlement has cached entries but the q search returned empty,
return the empty result slice without refreshing; keep using the same
identifiers (findWarehousesWithCacheOnMiss, findCachedWarehouses,
cacheWarehousesBySettlement, runId) and preserve the existing error logging
behavior when a refresh is actually attempted.
frontend/drizzle/0017_shop_shipping_core.sql (1)

60-67: Consider defaults/backfill to avoid tri‑state ambiguity.
Adding nullable shipping_required/shipping_status can leave legacy rows in an undefined state. If the app treats null as false/pending, consider backfilling and setting defaults (e.g., shipping_required = false, shipping_status = 'pending').

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/drizzle/0017_shop_shipping_core.sql` around lines 60 - 67, The
migration adds nullable shipping columns that will leave existing rows
ambiguous; update the 0017_shop_shipping_core.sql migration to backfill and set
sensible defaults for the new fields on the "orders" table: set
"shipping_required" default to false and backfill existing rows to false, set
"shipping_status" default to 'pending' and backfill existing rows to 'pending'
(and consider making them NOT NULL), and similarly consider default 0 for
"shipping_amount_minor" if appropriate; perform the UPDATE/backfill before
altering constraints so legacy rows are deterministic and then alter the columns
to include DEFAULT and NOT NULL as needed.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b90a96a and c77e191.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (64)
  • frontend/.env.example
  • frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx
  • frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx
  • frontend/app/[locale]/admin/shop/orders/[id]/page.tsx
  • frontend/app/[locale]/shop/cart/CartPageClient.tsx
  • frontend/app/[locale]/shop/orders/[id]/page.tsx
  • frontend/app/api/shop/admin/orders/[id]/route.ts
  • frontend/app/api/shop/admin/orders/[id]/shipping/route.ts
  • frontend/app/api/shop/checkout/route.ts
  • frontend/app/api/shop/internal/shipping/np/sync/route.ts
  • frontend/app/api/shop/internal/shipping/retention/run/route.ts
  • frontend/app/api/shop/internal/shipping/shipments/run/route.ts
  • frontend/app/api/shop/orders/[id]/route.ts
  • frontend/app/api/shop/shipping/methods/route.ts
  • frontend/app/api/shop/shipping/np/cities/route.ts
  • frontend/app/api/shop/shipping/np/warehouses/route.ts
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/db/queries/shop/admin-orders.ts
  • frontend/db/schema/shop.ts
  • frontend/drizzle/0017_shop_shipping_core.sql
  • frontend/drizzle/meta/0017_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/env/index.ts
  • frontend/lib/env/nova-poshta.ts
  • frontend/lib/services/orders/_shared.ts
  • frontend/lib/services/orders/checkout.ts
  • frontend/lib/services/orders/monobank-janitor.ts
  • frontend/lib/services/orders/monobank-webhook.ts
  • frontend/lib/services/orders/payment-attempts.ts
  • frontend/lib/services/shop/shipping/admin-actions.ts
  • frontend/lib/services/shop/shipping/availability.ts
  • frontend/lib/services/shop/shipping/checkout-payload.ts
  • frontend/lib/services/shop/shipping/inventory-eligibility.ts
  • frontend/lib/services/shop/shipping/log-sanitizer.ts
  • frontend/lib/services/shop/shipping/metrics.ts
  • frontend/lib/services/shop/shipping/nova-poshta-catalog.ts
  • frontend/lib/services/shop/shipping/nova-poshta-client.ts
  • frontend/lib/services/shop/shipping/retention.ts
  • frontend/lib/services/shop/shipping/shipments-worker.ts
  • frontend/lib/tests/shop/admin-api-killswitch.test.ts
  • frontend/lib/tests/shop/admin-csrf-contract.test.ts
  • frontend/lib/tests/shop/checkout-currency-policy.test.ts
  • frontend/lib/tests/shop/checkout-shipping-phase3.test.ts
  • frontend/lib/tests/shop/monobank-janitor-job3.test.ts
  • frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts
  • frontend/lib/tests/shop/monobank-webhook-apply.test.ts
  • frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts
  • frontend/lib/tests/shop/shipping-checkout-payload-phase6.test.ts
  • frontend/lib/tests/shop/shipping-internal-np-sync-route-p2.test.ts
  • frontend/lib/tests/shop/shipping-internal-retention-route-phase7.test.ts
  • frontend/lib/tests/shop/shipping-log-sanitizer-phase7.test.ts
  • frontend/lib/tests/shop/shipping-methods-route-p2.test.ts
  • frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts
  • frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts
  • frontend/lib/tests/shop/shipping-retention-phase7.test.ts
  • frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts
  • frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts
  • frontend/lib/types/shop.ts
  • frontend/lib/validation/shop-shipping.ts
  • frontend/lib/validation/shop.ts
  • frontend/package.json
  • frontend/scripts/np-mock-server.mjs
  • frontend/scripts/seed-np-local.ps1
  • frontend/scripts/seed-np-local.sql

Comment on lines 241 to +249
const snapshot = await readStripePaymentIntentParams(orderId);
await db
.update(paymentAttempts)
.set({
currency: snapshot.currency,
expectedAmountMinor: snapshot.amountMinor,
updatedAt: new Date(),
})
.where(eq(paymentAttempts.id, nextAttempt.id));
Copy link
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
# First, let's find the file and understand its structure
find . -name "payment-attempts.ts" -type f

Repository: DevLoversTeam/devlovers.net

Length of output: 121


🏁 Script executed:

#!/bin/bash
# Look at the function ensureStripePaymentIntentForOrder and surrounding context
rg -n "ensureStripePaymentIntentForOrder" -t ts --max-count=20

Repository: DevLoversTeam/devlovers.net

Length of output: 1761


🏁 Script executed:

#!/bin/bash
# Find the implementation of readStripePaymentIntentParams
rg -n "readStripePaymentIntentParams" -t ts -A 10 --max-count=30

Repository: DevLoversTeam/devlovers.net

Length of output: 6860


🏁 Script executed:

#!/bin/bash
# Search for OrderStateInvalidError to understand when it's thrown
rg -n "OrderStateInvalidError" -t ts -B 2 -A 2

Repository: DevLoversTeam/devlovers.net

Length of output: 12076


🏁 Script executed:

#!/bin/bash
# Let's look at the full readStripePaymentIntentParams function to understand when it throws
sed -n '90,120p' frontend/lib/services/orders/payment-intent.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1058


🏁 Script executed:

#!/bin/bash
# Look at the ensureStripePaymentIntentForOrder function and understand its structure
sed -n '187,320p' frontend/lib/services/orders/payment-attempts.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 3888


🏁 Script executed:

#!/bin/bash
# Check the first call site in checkout/route.ts
sed -n '800,850p' frontend/app/api/shop/checkout/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1712


🏁 Script executed:

#!/bin/bash
# Check the second call site in checkout/route.ts
sed -n '950,1000p' frontend/app/api/shop/checkout/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1470


🏁 Script executed:

#!/bin/bash
# Check the call site in payment page
sed -n '230,250p' frontend/app/\[locale\]/shop/checkout/payment/\[orderId\]/page.tsx

Repository: DevLoversTeam/devlovers.net

Length of output: 702


🏁 Script executed:

#!/bin/bash
# Let's verify what happens at line 299 with the try-catch to confirm the pattern
sed -n '295,320p' frontend/lib/services/orders/payment-attempts.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 728


🏁 Script executed:

#!/bin/bash
# Check if there are any explicit paymentStatus checks in the function
grep -n "paymentStatus" frontend/lib/services/orders/payment-attempts.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Let's check the try-catch error handling at line 299 to see if it handles OrderStateInvalidError specifically
sed -n '295,340p' frontend/lib/services/orders/payment-attempts.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1208


Guard snapshot backfill against non-payable order states.
readStripePaymentIntentParams throws OrderStateInvalidError when paymentStatus is not in ['pending', 'requires_payment']. At lines 241 and 279, this call lacks error handling; if the order transitions to a non-payable state (e.g., already paid), the function will fail even though a PaymentIntent already exists. Line 299 includes a try-catch, creating inconsistency. Consider catching OrderStateInvalidError at lines 241 and 279 to allow the function to return the existing PI, matching the error handling pattern at line 299.

Potential fix (example)
-    if (!attempt.currency || attempt.expectedAmountMinor === null) {
-      const snapshot = await readStripePaymentIntentParams(orderId);
-      await db
-        .update(paymentAttempts)
-        .set({
-          currency: snapshot.currency,
-          expectedAmountMinor: snapshot.amountMinor,
-          updatedAt: new Date(),
-        })
-        .where(eq(paymentAttempts.id, attempt.id));
-    }
+    if (!attempt.currency || attempt.expectedAmountMinor === null) {
+      try {
+        const snapshot = await readStripePaymentIntentParams(orderId);
+        await db
+          .update(paymentAttempts)
+          .set({
+            currency: snapshot.currency,
+            expectedAmountMinor: snapshot.amountMinor,
+            updatedAt: new Date(),
+          })
+          .where(eq(paymentAttempts.id, attempt.id));
+      } catch (error) {
+        if (!(error instanceof OrderStateInvalidError)) throw error;
+      }
+    }

Also applies to: 278-287, 299-307

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/payment-attempts.ts` around lines 241 - 249,
Guard the call to readStripePaymentIntentParams against OrderStateInvalidError:
wrap the calls used when backfilling snapshot data (the invocation that feeds
the update on paymentAttempts and the other call before creating/returning a
PaymentIntent) in a try/catch that specifically catches OrderStateInvalidError,
and on that error reuse/return the existing PaymentIntent (instead of throwing)
so the update of paymentAttempts (the .update().set({...}) where
eq(paymentAttempts.id, nextAttempt.id)) proceeds when the order is in a
non-payable state; keep other exceptions propagated.

Comment on lines 307 to 321
export async function cacheWarehousesBySettlement(args: {
settlementRef: string;
runId: string;
}): Promise<{ upserted: number; deactivated: number }> {
const warehouses = await getWarehousesBySettlementRef(args.settlementRef);
await upsertWarehouses(warehouses, args.runId);
await deactivateMissingWarehouses({
settlementRef: args.settlementRef,
keepRefs: warehouses.map(x => x.ref),
runId: args.runId,
});
return {
upserted: warehouses.length,
deactivated: 0,
};
Copy link
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:

# First, find the file and examine the deactivateMissingWarehouses function
find . -name "nova-poshta-catalog.ts" -type f

Repository: DevLoversTeam/devlovers.net

Length of output: 131


🏁 Script executed:

# Look at the file to see the full implementation
head -400 frontend/lib/services/shop/shipping/nova-poshta-catalog.ts | tail -150

Repository: DevLoversTeam/devlovers.net

Length of output: 3912


🏁 Script executed:

# Search for the deactivateMissingWarehouses function definition
rg -A 30 "async function deactivateMissingWarehouses" frontend/lib/services/shop/shipping/nova-poshta-catalog.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 835


🏁 Script executed:

# Check db.execute usage patterns and imports
head -50 frontend/lib/services/shop/shipping/nova-poshta-catalog.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1028


🏁 Script executed:

# Find the db module
find . -path "*/@/db*" -o -path "*/db.ts" -o -path "*/db.js" | grep -E "(db\.ts|db\.js|db/index)" | head -10

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Search for db.execute usage patterns to see if anyone captures the result
rg "db\.execute" --type ts --type tsx -B 2 -A 2 | head -80

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Check the db definition
find . -name "db.ts" -o -name "db.js" | xargs cat 2>/dev/null | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Search for where db is exported from
rg "export.*db" --type ts -l | head -10

Repository: DevLoversTeam/devlovers.net

Length of output: 277


🏁 Script executed:

# Try to find db-related files
fd "db" --type f | grep -E "\.(ts|js)$" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 110


🏁 Script executed:

# Search for db.execute more broadly to see result patterns
rg "db\.execute" -A 3 -B 1 | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 7026


🏁 Script executed:

# Look for db module initialization/export
rg "from.*@/db" -A 2 -B 2 | head -50

Repository: DevLoversTeam/devlovers.net

Length of output: 3216


🏁 Script executed:

# Check the db module to understand db.execute return type
cat frontend/db/index.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1283


🏁 Script executed:

# Look at how db.execute results are checked in inventory.ts for more context
rg -B 5 -A 10 "unexpected db.execute result shape" frontend/lib/services/inventory.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 499


🏁 Script executed:

# Look at a concrete example of db.execute result usage with type checking
rg -B 3 -A 10 "const res = await db.execute" frontend/lib/services/inventory.ts | head -50

Repository: DevLoversTeam/devlovers.net

Length of output: 862


🏁 Script executed:

# Check for rowCount usage in the codebase
rg "rowCount" --type ts | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Look at drizzle-orm execute documentation or type definitions by checking imports
rg "drizzle-orm" frontend/db/index.ts -A 5

Repository: DevLoversTeam/devlovers.net

Length of output: 355


🏁 Script executed:

# Search for how UPDATE queries handle result count elsewhere in the codebase
rg "UPDATE.*SET.*WHERE" -A 5 | grep -E "(rowCount|\.rows|rows\.length)" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Check the shipments-worker to see how db.execute results are used with type generics
rg -B 2 -A 15 "const res = await db.execute<.*UpdatedRow" frontend/lib/services/shop/shipping/shipments-worker.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Look at a complete example from retention.ts
rg -B 3 -A 20 "const res = await db.execute<UpdatedRow" frontend/lib/services/shop/shipping/retention.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1079


🏁 Script executed:

# Check what properties the result object has by looking at complete usage
cat frontend/lib/services/shop/shipping/retention.ts | head -150

Repository: DevLoversTeam/devlovers.net

Length of output: 2379


🏁 Script executed:

# Check if there's any rowCount usage pattern in drizzle with UPDATE operations
rg "rowCount|rows\.length|\.count" frontend/lib/services --type ts | grep -E "(UPDATE|execute)" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Look for RETURNING usage in UPDATE queries
rg "UPDATE.*RETURNING" frontend/lib/services --type ts -A 2 | head -40

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Check the exact result type when executing UPDATEs without type parameter
rg "await db.execute\(sql" frontend/lib/services/shop/shipping/nova-poshta-catalog.ts -B 2 -A 8

Repository: DevLoversTeam/devlovers.net

Length of output: 981


🏁 Script executed:

# Check the full shipments-worker to see complete patterns with UPDATE and RETURNING
cat frontend/lib/services/shop/shipping/shipments-worker.ts | head -200

Repository: DevLoversTeam/devlovers.net

Length of output: 5321


🏁 Script executed:

# Search for UPDATE queries with RETURNING in the entire codebase
rg "UPDATE.*RETURNING" frontend --type ts -B 2 -A 5 | head -80

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Check drizzle documentation or usage by looking at how UPDATE results are structured
# Try checking neon HTTP driver behavior with UPDATE
rg "UPDATE" frontend/lib/services/shop/shipping/nova-poshta-catalog.ts -B 1 -A 10 | grep -E "(UPDATE|const|return|rowCount|rows|\.rows)"

Repository: DevLoversTeam/devlovers.net

Length of output: 247


🏁 Script executed:

# Search for any error handling or result inspection after db.execute
rg "await db.execute" frontend/lib/services -A 5 | grep -E "(rows|rowCount|length|status)" | head -30

Repository: DevLoversTeam/devlovers.net

Length of output: 1710


🏁 Script executed:

# Check package.json to see drizzle-orm version
cat package.json | grep -A 5 -B 5 "drizzle-orm"

Repository: DevLoversTeam/devlovers.net

Length of output: 115


🏁 Script executed:

# Find package.json in the repository
find . -name "package.json" -type f | head -5

Repository: DevLoversTeam/devlovers.net

Length of output: 116


🏁 Script executed:

# Check one of those package.json files for drizzle-orm version
head -80 frontend/package.json | grep -A 2 -B 2 "drizzle"

Repository: DevLoversTeam/devlovers.net

Length of output: 548


🏁 Script executed:

# Look at monobank-webhook.ts which seems to handle result.rows
rg -B 5 -A 10 "as unknown as.*rows" frontend/lib/services/orders/monobank-webhook.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1878


deactivated is always reported as 0.

The core issue is valid—the return value is hardcoded instead of reflecting actual deactivation activity. However, the proposed fix using rowCount will not work with drizzle-orm; db.execute() returns { rows?: Array<T> }, not a result object with rowCount.

To fix this, add a RETURNING clause to both UPDATE queries in deactivateMissingWarehouses, capture the returned rows (which represent deactivated records), and return their count to the caller:

🛠️ Corrected fix
-async function deactivateMissingWarehouses(args: {
+async function deactivateMissingWarehouses(args: {
   settlementRef: string;
   keepRefs: string[];
   runId: string;
-}) {
+}): Promise<number> {
   const keepRefs = args.keepRefs.filter(x => x.trim().length > 0);

   if (!keepRefs.length) {
-    await db.execute(sql`
+    const res = await db.execute<{ ref: string }>(sql`
       UPDATE np_warehouses
       SET
         is_active = false,
         last_sync_run_id = ${args.runId}::uuid,
         updated_at = now()
       WHERE settlement_ref = ${args.settlementRef}
+      RETURNING ref
     `);
-    return;
+    return Array.isArray(res) ? res.length : res.rows?.length ?? 0;
   }

   const refs = keepRefs.map(ref => sql`${ref}`);
-  await db.execute(sql`
+  const res = await db.execute<{ ref: string }>(sql`
     UPDATE np_warehouses
     SET
       is_active = false,
       last_sync_run_id = ${args.runId}::uuid,
       updated_at = now()
     WHERE settlement_ref = ${args.settlementRef}
       AND ref NOT IN (${sql.join(refs, sql`, `)})
+    RETURNING ref
   `);
+  return Array.isArray(res) ? res.length : res.rows?.length ?? 0;
 }

 export async function cacheWarehousesBySettlement(args: {
   settlementRef: string;
   runId: string;
 }): Promise<{ upserted: number; deactivated: number }> {
   const warehouses = await getWarehousesBySettlementRef(args.settlementRef);
   await upsertWarehouses(warehouses, args.runId);
-  await deactivateMissingWarehouses({
+  const deactivated = await deactivateMissingWarehouses({
     settlementRef: args.settlementRef,
     keepRefs: warehouses.map(x => x.ref),
     runId: args.runId,
   });
   return {
     upserted: warehouses.length,
-    deactivated: 0,
+    deactivated,
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts` around lines 307
- 321, cacheWarehousesBySettlement currently returns deactivated: 0 because
deactivateMissingWarehouses is not returning the actual count; update
deactivateMissingWarehouses so its UPDATE queries include a RETURNING clause,
capture the returned rows from db.execute (note drizzle-orm returns { rows?:
Array<T> }), count those returned rows as the number of deactivated records,
have deactivateMissingWarehouses return that count, and then change
cacheWarehousesBySettlement to return deactivated: <value returned from
deactivateMissingWarehouses> instead of the hardcoded 0; ensure upsertWarehouses
and getWarehousesBySettlementRef usages remain unchanged.

Copy link
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: 4

Caution

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

⚠️ Outside diff range comments (1)
frontend/lib/services/orders/monobank-janitor.ts (1)

831-864: ⚠️ Potential issue | 🔴 Critical

Job3 can enter an infinite loop if event hashes change during reconstruction, not due to insert conflicts.

The analysis of the deadlock requires deeper examination than the conflict behavior alone.

When Job3 reads stored events with applied_at IS NULL, it reconstructs the rawBody via JSON.stringify(toPayloadRecord(eventRow.raw_payload)). If this reconstructed JSON differs in any way from the originally stored payload (e.g., key ordering, whitespace, null handling), the computed SHA256 will not match the stored eventRow.raw_sha256.

Job3 then calls applyMonoWebhookEvent with eventKey: rawSha256 (the new computed hash). Inside insertEvent, the conflict is resolved as follows:

  • The insert fails on the raw_sha256 unique constraint (the new hash is unique, not a conflict here)
  • But when trying to match by the reconstructed eventKey, the fallback query where event_key = newHash or raw_sha256 = newHash finds nothing—the stored row has the original hashes
  • insertEvent returns { eventId: null, deduped: true }
  • applyMonoWebhookEvent returns { appliedResult: 'deduped', eventId: null } without setting applied_at
  • The event remains a Job3 candidate forever

Additionally, if eventRow.raw_sha256 is invalid or malformed (despite schema constraints), the fallback to sha256Utf8(rawBody) produces a new hash, and the same deadlock occurs.

Recommendation: Either store and re-use the original raw_sha256 during apply without reconstruction, or ensure insertEvent performs an upsert (returning the existing row's ID on conflict) so business logic can proceed on re-applied stored events.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 831 - 864, The
code recomputes rawBody/rawSha256 from toPayloadRecord(eventRow.raw_payload) and
passes eventKey: rawSha256 to applyMonoWebhookEvent, which can change the hash
and cause Job3 rows to never get marked applied; fix by using the original
stored hash when available (use eventRow.raw_sha256 if it matches the
/^[0-9a-f]{64}$/i test) instead of always recomputing, or modify insertEvent
(the conflict resolution path) to perform an upsert/RETURNING that returns the
existing event id on conflict so applyMonoWebhookEvent can mark applied; adjust
the call site in monobank-janitor (around readDryRunJob3Candidates,
toPayloadRecord, sha256Utf8, applyMonoWebhookEvent) or update insertEvent’s
conflict handling accordingly to ensure existing rows are identified and
applied_at gets set.
♻️ Duplicate comments (1)
frontend/lib/services/orders/monobank-janitor.ts (1)

331-340: clearAppliedEventClaim is now defined — past concern resolved.

The implementation is correct: the AND applied_at IS NOT NULL guard ensures claims are only cleared after the event has been applied, making spurious no-op calls (e.g., applied_noop path) safe. Omitting a runId filter is appropriate here since you want any applied-event claim cleared regardless of which instance set it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 331 - 340,
clearAppliedEventClaim is implemented correctly: it should leave the applied-at
guard and no runId filter so any instance's claim for an already-applied event
is cleared; no code changes required to the clearAppliedEventClaim function (the
update in clearAppliedEventClaim that sets claimed_at, claim_expires_at,
claimed_by to null where id = ${args.eventId}::uuid and applied_at is not null
is the intended behavior).
🧹 Nitpick comments (3)
frontend/app/[locale]/admin/shop/orders/[id]/page.tsx (2)

29-29: noStore() is redundant alongside dynamic = 'force-dynamic'.

export const dynamic = 'force-dynamic' already forces dynamic rendering and disables the full-route cache on every request; calling noStore() inside the component body has no additional effect.

♻️ Proposed fix
- import { unstable_noStore as noStore } from 'next/cache';
  ...
  export default async function OrderDetailPage(...) {
-   noStore();

Also applies to: 120-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx at line 29, The file
redundantly calls noStore() inside the component while also exporting export
const dynamic = 'force-dynamic'; remove the noStore() calls (and any unused
noStore import) and rely on export const dynamic = 'force-dynamic' to enforce
dynamic rendering—search for noStore() usages in this module (e.g., the
component body near the top and the repeated occurrence later) and delete them
so only the dynamic export remains.

200-200: Currency fallback silently maps any non-UAH value to 'USD'.

If the schema ever gains a third currency (e.g. EUR), this ternary will silently format it as USD, producing wrong amounts with no runtime warning.

♻️ Proposed fix — narrow via the inferred schema union
- const currency: CurrencyCode = order.currency === 'UAH' ? 'UAH' : 'USD';
+ const SUPPORTED_CURRENCIES = new Set<CurrencyCode>(['UAH', 'USD']);
+ const currency: CurrencyCode = SUPPORTED_CURRENCIES.has(order.currency as CurrencyCode)
+   ? (order.currency as CurrencyCode)
+   : 'USD';

Better still, if OrderCurrency and CurrencyCode share the same union, cast directly and let TypeScript enforce exhaustiveness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx at line 200, The
current ternary mapping for currency (`const currency: CurrencyCode =
order.currency === 'UAH' ? 'UAH' : 'USD';`) silently maps any unknown
`order.currency` to 'USD'; instead, use the schema's union types (e.g.
`OrderCurrency` and `CurrencyCode`) and perform a direct typed assignment or an
exhaustive switch so TypeScript enforces validity: cast `order.currency` to
`CurrencyCode` (or validate via a switch over possible `OrderCurrency` values
and throw for unexpected cases) so new currencies like 'EUR' fail at
compile-time or surface a runtime error rather than being mis-mapped.
frontend/lib/services/orders/monobank-janitor.ts (1)

831-834: readDryRunJob3Candidates used in the live processing path — misleading name.

Unlike Job1/Job2, which use distinct claim-based functions for their live paths, Job3's live path reuses the dry-run read function. The name readDryRunJob3Candidates implies it is only safe/intended for dry-run inspection, which will confuse future readers or refactorers.

Consider renaming to something like readJob3Candidates (used by both paths) or readUnclaimedJob3Candidates to accurately reflect that it reads unclaimed events without acquiring a lease.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 831 - 834, The
helper name readDryRunJob3Candidates is misleading because it's used in the live
processing path; rename the function to a neutral name (e.g. readJob3Candidates
or readUnclaimedJob3Candidates) and update all call sites (including the live
processing path in monobank-janitor.ts and any tests/imports) so the behavior
(reading unclaimed Job3 events without acquiring a lease) remains unchanged;
update the exported identifier, any JSDoc/comments referencing "dry run", and
imports/exports so compilation and runtime linkage are preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx:
- Around line 127-132: The redirect currently builds returnTo as
`/${locale}/shop/orders/${id}` which points to the non-admin route; update the
redirect call so the encoded returnTo includes the `/admin` segment (i.e.,
`/${locale}/admin/shop/orders/${id}`) using the existing variables `locale` and
`id` in the same `redirect(...)` invocation so after login the user returns to
the admin order page; adjust the encodeURIComponent argument only and keep the
surrounding redirect logic unchanged.
- Around line 276-288: The two hardcoded labels around order.shippingStatus and
order.trackingNumber must be replaced with localized strings (e.g.
t('shippingStatus') and t('trackingNumber')) and the visible item labels "Qty:",
"Unit:", and "Line:" in the items list should use the same translation keys as
their sr-only counterparts (t('quantity'), t('unitPrice'), t('lineTotal')) so
the page is fully localized; update the JSX where order.shippingStatus and
order.trackingNumber are rendered and the item list label nodes to call the
existing t(...) translator used elsewhere in this component.
- Around line 141-198: The 404 signal from notFound() is being swallowed by the
surrounding try/catch; split the logic so DB I/O is guarded but the empty-rows
check lives outside the DB catch. Specifically, wrap only the db.select(...)
call (the code that assigns rows from db.select in the try) in a try/catch that
uses logError and throws ORDER_DETAIL_LOAD_FAILED on DB errors, then immediately
after that try do if (rows.length === 0) notFound(); keep the mapping/assembly
that uses toOrderItem inside its own try (or the original try if you prefer) so
genuine toOrderItem failures remain caught by logError; reference these symbols:
db.select, rows, notFound(), toOrderItem, and logError.

In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 866-874: The code increments applied/noop then calls
clearAppliedEventClaim inside the main try, so if clearAppliedEventClaim throws
the outer catch will also increment failed and double-count the event; fix by
isolating clearAppliedEventClaim in its own try/catch after you update
applied/noop: after the existing if (isAppliedResult(result.appliedResult)) {
applied += 1 } else { noop += 1 } block, wrap the clearAppliedEventClaim({
eventId: result.eventId }) call in a separate try { await
clearAppliedEventClaim(...) } catch (err) { /* log or warn about claim release
failure for result.eventId but do NOT increment failed or rethrow */ } so
failures to clear the claim don't mark the event as failed in the main counters
(reference: isAppliedResult, applied, noop, failed, result.eventId,
clearAppliedEventClaim).

---

Outside diff comments:
In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 831-864: The code recomputes rawBody/rawSha256 from
toPayloadRecord(eventRow.raw_payload) and passes eventKey: rawSha256 to
applyMonoWebhookEvent, which can change the hash and cause Job3 rows to never
get marked applied; fix by using the original stored hash when available (use
eventRow.raw_sha256 if it matches the /^[0-9a-f]{64}$/i test) instead of always
recomputing, or modify insertEvent (the conflict resolution path) to perform an
upsert/RETURNING that returns the existing event id on conflict so
applyMonoWebhookEvent can mark applied; adjust the call site in monobank-janitor
(around readDryRunJob3Candidates, toPayloadRecord, sha256Utf8,
applyMonoWebhookEvent) or update insertEvent’s conflict handling accordingly to
ensure existing rows are identified and applied_at gets set.

---

Duplicate comments:
In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 331-340: clearAppliedEventClaim is implemented correctly: it
should leave the applied-at guard and no runId filter so any instance's claim
for an already-applied event is cleared; no code changes required to the
clearAppliedEventClaim function (the update in clearAppliedEventClaim that sets
claimed_at, claim_expires_at, claimed_by to null where id =
${args.eventId}::uuid and applied_at is not null is the intended behavior).

---

Nitpick comments:
In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx:
- Line 29: The file redundantly calls noStore() inside the component while also
exporting export const dynamic = 'force-dynamic'; remove the noStore() calls
(and any unused noStore import) and rely on export const dynamic =
'force-dynamic' to enforce dynamic rendering—search for noStore() usages in this
module (e.g., the component body near the top and the repeated occurrence later)
and delete them so only the dynamic export remains.
- Line 200: The current ternary mapping for currency (`const currency:
CurrencyCode = order.currency === 'UAH' ? 'UAH' : 'USD';`) silently maps any
unknown `order.currency` to 'USD'; instead, use the schema's union types (e.g.
`OrderCurrency` and `CurrencyCode`) and perform a direct typed assignment or an
exhaustive switch so TypeScript enforces validity: cast `order.currency` to
`CurrencyCode` (or validate via a switch over possible `OrderCurrency` values
and throw for unexpected cases) so new currencies like 'EUR' fail at
compile-time or surface a runtime error rather than being mis-mapped.

In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 831-834: The helper name readDryRunJob3Candidates is misleading
because it's used in the live processing path; rename the function to a neutral
name (e.g. readJob3Candidates or readUnclaimedJob3Candidates) and update all
call sites (including the live processing path in monobank-janitor.ts and any
tests/imports) so the behavior (reading unclaimed Job3 events without acquiring
a lease) remains unchanged; update the exported identifier, any JSDoc/comments
referencing "dry run", and imports/exports so compilation and runtime linkage
are preserved.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c77e191 and 96e2303.

📒 Files selected for processing (2)
  • frontend/app/[locale]/admin/shop/orders/[id]/page.tsx
  • frontend/lib/services/orders/monobank-janitor.ts

Comment on lines 276 to 288
<div>
<dt className="text-muted-foreground text-xs">Shipping status</dt>
<dd className="text-sm font-medium">
{order.shippingStatus ?? '-'}
</dd>
</div>

<div>
<dt className="text-muted-foreground text-xs">Tracking number</dt>
<dd className="text-sm font-medium break-all">
{order.trackingNumber ?? '-'}
</dd>
</div>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

"Shipping status" and "Tracking number" are hardcoded English — missing translation keys.

Every other label in the summary section uses t(...), but these two are bare string literals. Given that this page serves a localized audience via next-intl, they need translation keys too.

♻️ Proposed fix
- <dt className="text-muted-foreground text-xs">Shipping status</dt>
+ <dt className="text-muted-foreground text-xs">{t('shippingStatus')}</dt>
  ...
- <dt className="text-muted-foreground text-xs">Tracking number</dt>
+ <dt className="text-muted-foreground text-xs">{t('trackingNumber')}</dt>

The same applies to the inline "Qty:", "Unit:", and "Line:" visible labels in the items list (lines 383, 388, 395); their sr-only counterparts already use t('quantity'), t('unitPrice'), and t('lineTotal').

📝 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
<div>
<dt className="text-muted-foreground text-xs">Shipping status</dt>
<dd className="text-sm font-medium">
{order.shippingStatus ?? '-'}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">Tracking number</dt>
<dd className="text-sm font-medium break-all">
{order.trackingNumber ?? '-'}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t('shippingStatus')}</dt>
<dd className="text-sm font-medium">
{order.shippingStatus ?? '-'}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t('trackingNumber')}</dt>
<dd className="text-sm font-medium break-all">
{order.trackingNumber ?? '-'}
</dd>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx around lines 276 -
288, The two hardcoded labels around order.shippingStatus and
order.trackingNumber must be replaced with localized strings (e.g.
t('shippingStatus') and t('trackingNumber')) and the visible item labels "Qty:",
"Unit:", and "Line:" in the items list should use the same translation keys as
their sr-only counterparts (t('quantity'), t('unitPrice'), t('lineTotal')) so
the page is fully localized; update the JSX where order.shippingStatus and
order.trackingNumber are rendered and the item list label nodes to call the
existing t(...) translator used elsewhere in this component.

Copy link
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: 6

Caution

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

⚠️ Outside diff range comments (1)
frontend/app/[locale]/shop/orders/[id]/page.tsx (1)

141-198: ⚠️ Potential issue | 🔴 Critical

notFound() inside try is swallowed — users will see a 500 instead of 404 for missing orders.

notFound() on line 180 throws a Next.js sentinel error, which gets caught by the catch on line 195 and re-thrown as Error('ORDER_DETAIL_LOAD_FAILED'). This produces a 500 error page instead of a proper 404. The same bug was identified and fixed in the admin page (admin/shop/orders/[id]/page.tsx) using an IIFE pattern, but this user-facing page still has it.

🐛 Proposed fix — hoist notFound() out of try/catch
+ let rows: Awaited<ReturnType<typeof db.select>>; // adjust type as needed
  try {
    const whereClause = isAdmin
      ? eq(orders.id, parsed.data.id)
      : and(eq(orders.id, parsed.data.id), eq(orders.userId, user.id));

-   const rows = await db
+   rows = await db
      .select({ ... })
      .from(orders)
      .leftJoin(orderItems, eq(orderItems.orderId, orders.id))
      .where(whereClause)
      .orderBy(orderItems.id);
-
-   if (rows.length === 0) notFound();
-
-   const base = rows[0]!.order;
-   ...
-   order = { ... };
  } catch (error) {
    logError('User order detail page failed', error);
    throw new Error('ORDER_DETAIL_LOAD_FAILED');
  }

+ if (rows.length === 0) notFound();
+
+ try {
+   const base = rows[0]!.order;
+   const items = rows.map(r => toOrderItem(r.item)).filter(...);
+   order = { ...base, ... items };
+ } catch (error) {
+   logError('User order detail page failed', error);
+   throw new Error('ORDER_DETAIL_LOAD_FAILED');
+ }

Or replicate the IIFE pattern already used in the admin page.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/orders/[id]/page.tsx around lines 141 - 198, The
try/catch is currently catching Next.js's notFound() sentinel and converting it
into a 500; move the notFound() call outside the try block (or wrap the DB logic
in an IIFE like the admin page) so the sentinel can propagate. Concretely,
inside the code that queries rows (the block using
db.select(...).from(orders)... and the variable rows), ensure you perform the
rows.length === 0 check and call notFound() before the outer catch that logs via
logError('User order detail page failed', error) and throws
Error('ORDER_DETAIL_LOAD_FAILED'); keep the rest of order assembly (base, items,
toOrderItem, createdAt/updatedAt/restockedAt conversion) inside the try so only
real errors are caught.
♻️ Duplicate comments (6)
frontend/lib/services/shop/shipping/nova-poshta-catalog.ts (1)

310-325: deactivated is still hardcoded to 0.

This was flagged in a previous review. deactivateMissingWarehouses doesn't return the count of rows it deactivated, so cacheWarehousesBySettlement always reports deactivated: 0 regardless of how many warehouses were actually marked inactive.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts` around lines 310
- 325, The function cacheWarehousesBySettlement currently hardcodes deactivated:
0; change it to return the actual number of deactivated rows by having
deactivateMissingWarehouses return its deactivated count (e.g., Promise<number>
or {deactivated:number}) and await that result here; update
cacheWarehousesBySettlement to call getWarehousesBySettlementRef,
upsertWarehouses, then const deactivated = await
deactivateMissingWarehouses({...}) and return { upserted: warehouses.length,
deactivated }; modify the deactivateMissingWarehouses implementation/signature
to compute and return the count of rows it marks inactive so the value is
accurate.
frontend/lib/services/shop/shipping/nova-poshta-client.ts (2)

439-442: ⚠️ Potential issue | 🟠 Major

addressLine2 is still silently dropped when addressLine1 is present.

The || chain means RecipientAddressName only gets addressLine1 when truthy; addressLine2 (apartment, building, etc.) is never concatenated. For courier delivery, the full address typically requires both lines.

🐛 Proposed fix
-      RecipientAddressName:
-        input.recipient.addressLine1 ||
-        input.recipient.addressLine2 ||
-        undefined,
+      RecipientAddressName:
+        [input.recipient.addressLine1, input.recipient.addressLine2]
+          .filter(Boolean)
+          .join(', ') || undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts` around lines 439 -
442, RecipientAddressName currently uses an || chain that drops addressLine2
when addressLine1 is present; update the assignment for RecipientAddressName
(the object property where input.recipient is used) to join
input.recipient.addressLine1 and input.recipient.addressLine2 (e.g., concatenate
non-empty parts with a space or comma, trim final string) and fall back to
undefined if both are empty so the courier receives the full combined address
line.

237-267: ⚠️ Potential issue | 🟠 Major

callNp still lacks a fetch timeout.

If the NP API hangs, this fetch call will block indefinitely (or until the runtime's default timeout). This was flagged in a previous review and remains unaddressed.

Add an AbortController with a reasonable timeout:

⏱️ Proposed fix
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), 15_000);
+
   let response: Response;
   try {
     response = await fetch(config.apiBaseUrl, {
       method: 'POST',
       headers: { ... },
       body: JSON.stringify({ ... }),
       cache: 'no-store',
+      signal: controller.signal,
     });
   } catch (error) {
+    clearTimeout(timeout);
+    if (error instanceof DOMException && error.name === 'AbortError') {
+      throw new NovaPoshtaApiError('NP_TIMEOUT', 'NP request timed out', 504);
+    }
     throw new NovaPoshtaApiError('NP_FETCH_FAILED', 'fetch failed', 0, {
       cause: error,
     });
+  } finally {
+    clearTimeout(timeout);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts` around lines 237 -
267, callNp lacks a fetch timeout and can hang; wrap the fetch in an
AbortController with a reasonable timeout (e.g., 5–15s): create an
AbortController before calling fetch, pass controller.signal to fetch, set a
timer (via setTimeout) that calls controller.abort() after the timeout, and
clear the timer after a successful response; update the catch branch to treat
AbortError distinctly when throwing NovaPoshtaApiError. Ensure changes are made
inside the callNp function around the fetch call and reference the existing
NovaPoshtaApiError and response handling logic.
frontend/lib/services/orders/monobank-janitor.ts (1)

361-370: Past review concerns addressed: clearAppliedEventClaim is now defined and isolated.

The function exists (lines 361–370) and its call site (lines 912–930) is wrapped in a dedicated try/catch inside the main processing block, so a failure here cannot corrupt the applied/failed counters.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 361 - 370,
clearAppliedEventClaim is correctly defined and isolated; no code change
required—just verify the call site in the main processing block that invokes
clearAppliedEventClaim is wrapped in its dedicated try/catch so failures cannot
affect the applied/failed counters (the function name clearAppliedEventClaim and
its invocation in the main processing loop should be used to locate both
definitions).
frontend/app/[locale]/admin/shop/orders/[id]/page.tsx (1)

282-294: ⚠️ Potential issue | 🟡 Minor

"Shipping status" and "Tracking number" are still hardcoded English — translation keys exist but aren't used.

The pl.json (and presumably en.json/uk.json) already define shippingStatus and trackingNumber under shop.orders.detail, and this page loads translations from that namespace (getTranslations('shop.orders.detail')). Replace the bare strings:

-            <dt className="text-muted-foreground text-xs">Shipping status</dt>
+            <dt className="text-muted-foreground text-xs">{t('shippingStatus')}</dt>
             ...
-            <dt className="text-muted-foreground text-xs">Tracking number</dt>
+            <dt className="text-muted-foreground text-xs">{t('trackingNumber')}</dt>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx around lines 282 -
294, The dt labels "Shipping status" and "Tracking number" are hardcoded; use
the loaded translations from getTranslations('shop.orders.detail') instead.
Replace the literal strings in the two <dt> elements with the translation keys
t('shippingStatus') and t('trackingNumber') (keeping the surrounding markup and
classes intact) so the page uses the locale-specific strings from the
shop.orders.detail namespace; ensure t is the translation function returned
where getTranslations was called and used in this component.
frontend/lib/services/shop/shipping/shipments-worker.ts (1)

256-265: ⚠️ Potential issue | 🟠 Major

Enforce Nova Poshta minimum declared cost.

declaredCostUahFromMinor can return values below Nova Poshta’s minimum declared cost, which will cause TTN creation to fail. Clamp to the documented minimum after rounding.

🛠️ Suggested fix
-  return Math.floor((Math.trunc(totalMinor) + 50) / 100);
+  const rounded = Math.floor((Math.trunc(totalMinor) + 50) / 100);
+  return Math.max(300, rounded);
Nova Poshta InternetDocument API minimum declared cost (Cost) value
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts` around lines 256 -
265, The function declaredCostUahFromMinor can produce values below Nova
Poshta’s minimum declared cost; update it to clamp the final rounded result to a
documented minimum (e.g., introduce a constant like
MIN_NOVA_POSHTA_DECLARED_COST_UAH) after the existing rounding logic and before
returning, so return Math.max(roundedValue, MIN_NOVA_POSHTA_DECLARED_COST_UAH);
keep the existing input validation and error (buildFailure) unchanged and ensure
the new constant is clearly named and documented for future reference.
🧹 Nitpick comments (13)
frontend/.env.example (4)

105-107: Swap webhook instance ID order to satisfy dotenv-linter.

Place STRIPE_WEBHOOK_INSTANCE_ID before WEBHOOK_INSTANCE_ID.

♻️ Proposed reordering
-WEBHOOK_INSTANCE_ID=
-STRIPE_WEBHOOK_INSTANCE_ID=
+STRIPE_WEBHOOK_INSTANCE_ID=
+WEBHOOK_INSTANCE_ID=
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/.env.example` around lines 105 - 107, Reorder the two environment
variable declarations so STRIPE_WEBHOOK_INSTANCE_ID appears before
WEBHOOK_INSTANCE_ID in frontend/.env.example to satisfy dotenv-linter; locate
the block containing WEBHOOK_INSTANCE_ID and STRIPE_WEBHOOK_INSTANCE_ID and swap
their lines so STRIPE_WEBHOOK_INSTANCE_ID is listed first.

66-69: Reorder Monobank webhook keys to match dotenv-linter rules.

Place MONO_REFUND_ENABLED first, then MONO_WEBHOOK_CLAIM_TTL_MS, then MONO_WEBHOOK_MODE.

♻️ Proposed reordering
-# Optional webhook/runtime tuning (defaults in code if omitted)
-MONO_WEBHOOK_MODE=
-MONO_WEBHOOK_CLAIM_TTL_MS=
-MONO_REFUND_ENABLED=0
+# Optional webhook/runtime tuning (defaults in code if omitted)
+MONO_REFUND_ENABLED=0
+MONO_WEBHOOK_CLAIM_TTL_MS=
+MONO_WEBHOOK_MODE=
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/.env.example` around lines 66 - 69, Reorder the Monobank env keys in
.env.example to satisfy dotenv-linter: place MONO_REFUND_ENABLED first, then
MONO_WEBHOOK_CLAIM_TTL_MS, and finally MONO_WEBHOOK_MODE; update the block
containing MONO_WEBHOOK_MODE, MONO_WEBHOOK_CLAIM_TTL_MS, and MONO_REFUND_ENABLED
so the variables appear in the new order and preserve their values/comments.

48-55: Fix env key order in Stripe block to satisfy dotenv-linter.

Reorder to place STRIPE_MODE before STRIPE_PAYMENTS_ENABLED per lint rule.

♻️ Proposed reordering
 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
-STRIPE_PAYMENTS_ENABLED=
 # Options: test, live (defaults to test in development, live in production)
 STRIPE_MODE=
+STRIPE_PAYMENTS_ENABLED=
 STRIPE_SECRET_KEY=
 STRIPE_WEBHOOK_SECRET=
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/.env.example` around lines 48 - 55, The Stripe env block violates
dotenv-linter ordering; swap the lines so STRIPE_MODE appears before
STRIPE_PAYMENTS_ENABLED. Edit the frontend/.env.example block containing
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, STRIPE_PAYMENTS_ENABLED, STRIPE_MODE,
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and move the STRIPE_MODE entry to
precede STRIPE_PAYMENTS_ENABLED so the keys read
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, STRIPE_MODE, STRIPE_PAYMENTS_ENABLED,
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET to satisfy the linter.

81-93: Reorder NP_ keys to satisfy dotenv-linter ordering rules.*

Several NP keys are out of order per lint rules; reorder as below.

♻️ Proposed reordering
-NP_API_KEY=
-# Optional if code has a default; set explicitly in production for clarity
-NP_API_BASE=
-NP_SENDER_REF=
-NP_SENDER_CONTACT_REF=
-NP_SENDER_ADDRESS_REF=
-NP_SENDER_CITY_REF=
-NP_SENDER_PHONE=
+# Optional if code has a default; set explicitly in production for clarity
+NP_API_BASE=
+NP_API_KEY=
+NP_SENDER_ADDRESS_REF=
+NP_SENDER_CITY_REF=
+NP_SENDER_CONTACT_REF=
+NP_SENDER_PHONE=
+NP_SENDER_REF=
@@
-# Optional tuning (override only if needed; otherwise code defaults apply)
-NP_TIMEOUT_MS=
-NP_MAX_RETRIES=
-NP_RETRY_DELAY_MS=
+# Optional tuning (override only if needed; otherwise code defaults apply)
+NP_MAX_RETRIES=
+NP_RETRY_DELAY_MS=
+NP_TIMEOUT_MS=
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/.env.example` around lines 81 - 93, The NP_ environment variable
block in .env.example is not in the dotenv-linter expected order; reorder the
NP_* keys into alphabetical order so the linter passes. Specifically locate the
NP_* entries (NP_API_KEY, NP_API_BASE, NP_SENDER_REF, NP_SENDER_CONTACT_REF,
NP_SENDER_ADDRESS_REF, NP_SENDER_CITY_REF, NP_SENDER_PHONE, NP_TIMEOUT_MS,
NP_MAX_RETRIES, NP_RETRY_DELAY_MS) and sort them alphabetically (e.g.,
NP_API_BASE before NP_API_KEY, NP_MAX_RETRIES before NP_RETRY_DELAY_MS, etc.),
keeping the same comments and blank lines semantics. Ensure no duplicates or
spacing changes beyond reordering.
frontend/lib/services/orders/monobank-webhook.ts (1)

476-569: Keep shipping_status updates aligned with DB eligibility to avoid drift.
Because shipping_status is set from a precomputed flag, it can diverge from actual eligibility if order fields change between the read and the atomic update. Consider gating the status update on the same DB-side predicates used for enqueueing.

♻️ Suggested adjustment to keep status in sync
      update orders
      set status = 'PAID',
-         shipping_status = case
-           when ${args.enqueueShipment} then 'queued'::shipping_status
-           else shipping_status
-         end,
+         shipping_status = case
+           when ${args.enqueueShipment}
+            and shipping_required = true
+            and shipping_provider = 'nova_poshta'
+            and shipping_method_code is not null
+            and ${inventoryCommittedForShippingSql(sql`orders.inventory_status`)}
+           then 'queued'::shipping_status
+           else shipping_status
+         end,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-webhook.ts` around lines 476 - 569, The
update currently sets shipping_status based on the precomputed boolean
args.enqueueShipment which can drift from DB-side eligibility; inside
atomicMarkPaidOrderAndSucceedAttempt update the orders.shipping_status using the
same database predicates as the eligible_for_enqueue CTE (i.e. payment_status =
'paid', shipping_required = true, shipping_provider = 'nova_poshta',
shipping_method_code IS NOT NULL, and the inventoryCommittedForShippingSql(...)
predicate against updated_order.inventory_status) so the CASE in the orders
UPDATE (in updated_order) mirrors the eligible_for_enqueue logic and keeps
shipping_status consistent with inserted_shipment/eligible_for_enqueue and the
inserted_shipment CTE.
frontend/lib/services/shop/shipping/nova-poshta-catalog.ts (1)

163-206: Bulk upsert has no batch-size guard.

upsertCities builds a single INSERT ... VALUES statement for all rows. While NP API responses are typically small (~20-50 settlements), if this is ever called with a larger dataset, the generated SQL could exceed driver/connection limits.

Consider chunking if the input size might grow, or add a comment noting the expected upper bound.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts` around lines 163
- 206, upsertCities currently builds one giant INSERT VALUES block (values,
sql.join, db.execute) which can exceed SQL/driver limits for large inputs;
modify upsertCities to process rows in bounded batches (e.g., chunk size
200–500) and perform the same INSERT ... ON CONFLICT call per chunk inside a
loop, reusing the same VALUES/sql templates and runId, or if you intend to rely
on small responses, add a clear comment near upsertCities explaining the
expected upper bound and why batching is unnecessary.
frontend/lib/services/orders/_shared.ts (1)

148-170: Minor: double normalization per item in mergeCheckoutItems.

Each item is normalized via normalizeCheckoutItem on line 154, then checkoutItemMergeKey (line 155) normalizes the same item again internally. Consider passing the already-normalized item to a key-builder that skips re-normalization.

Also, line 162 uses item.quantity (the original) instead of it.quantity (the normalized copy). They're equivalent since normalization preserves quantity, but using it.quantity would be more consistent with the rest of the loop body.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/_shared.ts` around lines 148 - 170,
mergeCheckoutItems currently normalizes each item with normalizeCheckoutItem
then calls checkoutItemMergeKey which re-normalizes the same item; to fix, call
checkoutItemMergeKey with the already-normalized variable (it) so the key
builder doesn't re-run normalization, and when computing mergedQty use
it.quantity (the normalized copy) instead of item.quantity; keep the rest of the
logic (map, MAX_QUANTITY_PER_LINE check, InvalidPayloadError throw) unchanged.
frontend/lib/services/orders/monobank-janitor.ts (1)

860-942: Job3 processes unclaimed candidates without per-event leasing — relies on idempotent apply.

readUnclaimedJob3Candidates filters on applied_at is null and expired/null claim_expires_at, but there is no atomic claim step before applyMonoWebhookEvent. Concurrent janitor runs could select and attempt to apply the same event. This is safe only if applyMonoWebhookEvent is fully idempotent (which appears to be the case from the mode: 'apply' path).

Worth noting: under high concurrency, this can lead to duplicate work and redundant clearAppliedEventClaim calls. If this becomes a concern, adding FOR UPDATE SKIP LOCKED semantics to the read query would eliminate the overlap.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 860 - 942, The
loop reads candidates via readUnclaimedJob3Candidates then applies each with
applyMonoWebhookEvent without an atomic per-event claim, allowing concurrent
janitor runs to process the same event; fix by making the candidate selection
atomic—either change readUnclaimedJob3Candidates to SELECT ... FOR UPDATE SKIP
LOCKED (or use a transaction that updates claim_expires_at/claimer and returns
the rows) so rows are locked/marked exclusively before calling
applyMonoWebhookEvent, or add an immediate atomic update-by-id claim step before
applyMonoWebhookEvent that returns whether the claim succeeded and skip
processing if not; ensure clearAppliedEventClaim logic still runs only for
successfully claimed/applied events (use eventId + claim ownership) to avoid
redundant clear attempts.
frontend/lib/services/shop/shipping/nova-poshta-client.ts (1)

360-413: Implement pagination for warehouse fetching or document the 500-warehouse limit.

The function uses a hardcoded Limit: 500 and Page: 1, fetching only the first page of results. If a settlement (especially large cities like Kyiv or Kharkiv) has more than 500 warehouses, they will be silently omitted. Consider either looping through all pages until results are exhausted (similar to how searchSettlements handles pagination) or add a comment explaining why 500 is sufficient for all Ukrainian settlements.

frontend/db/schema/shop.ts (3)

594-625: Shipment worker table is well-designed for lease-based processing.

The (status, nextAttemptAt) index and leaseExpiresAt index align well with the claim/retry pattern described in the PR. The unique constraint on orderId cleanly prevents duplicate shipment rows.

Minor: consider adding a CHECK(attempt_count >= 0) for consistency with other non-negative guards in this schema (e.g., stock, quantity, amounts).

Optional defensive constraint
   table => [
     uniqueIndex('shipping_shipments_order_id_uq').on(table.orderId),
     index('shipping_shipments_queue_idx').on(table.status, table.nextAttemptAt),
     index('shipping_shipments_lease_idx').on(table.leaseExpiresAt),
     index('shipping_shipments_provider_ref_idx').on(table.providerRef),
+    check('shipping_shipments_attempt_count_non_negative', sql`${table.attemptCount} >= 0`),
   ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/schema/shop.ts` around lines 594 - 625, Add a non-negative check
constraint for attemptCount on the shippingShipments table: ensure the schema
definition for shippingShipments includes a CHECK(attempt_count >= 0) (either on
the attemptCount column or as a table-level constraint) so attemptCount (field
name attemptCount / DB column attempt_count) cannot be negative; update the
table definition where shippingShipments is declared to include this constraint
alongside the existing indexes and unique index.

240-261: Shipping invariant constraints look solid, but shippingPayer is not guarded.

The bidirectional CHECK pair correctly enforces that shippingProvider, shippingMethodCode, and shippingStatus must all be present (or all absent) based on shippingRequired. However, shippingPayer is omitted from both checks — if shipping is required, it can still be NULL, which may lead to ambiguity about who pays for shipping.

Is this intentional (e.g., payer is resolved later in the flow)? If it should always be set when shipping is required, consider adding it to the orders_shipping_present_when_required_chk constraint.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/schema/shop.ts` around lines 240 - 261, The CHECK pair for
shipping fields enforces presence/absence of shippingProvider,
shippingMethodCode, and shippingStatus based on shippingRequired but omits
shippingPayer, allowing NULL payer when shippingRequired is true; update the
'orders_shipping_present_when_required_chk' (and symmetrically
'orders_shipping_null_when_not_required_chk' if you want strict parity) to
include ${table.shippingPayer} in the AND conditions so that when
shippingRequired is true shippingPayer IS NOT NULL (and when shippingRequired is
TRUE in the null-check ensure shippingPayer IS NULL) — modify the check() calls
around 'orders_shipping_present_when_required_chk' and
'orders_shipping_null_when_not_required_chk' to add ${table.shippingPayer} to
the grouped predicates.

627-650: NP cities catalog table looks good.

The schema captures the essential NP settlement data and the lastSyncRunId pattern enables efficient staleness detection during sync runs.

One consideration: if city autocomplete will use LIKE 'prefix%' queries, the default B-tree operator class on nameUa won't accelerate prefix searches unless the database collation is C or POSIX. You could add text_pattern_ops to the index, but given the catalog size (~30k Ukrainian settlements), this is unlikely to be a bottleneck.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/schema/shop.ts` around lines 627 - 650, The npCities table's
index np_cities_active_name_idx currently uses the default B-tree operator class
which won't help prefix LIKE searches on nameUa in non-C collations; update the
index definition on npCities (np_cities_active_name_idx) so the nameUa/name_ua
column uses the text_pattern_ops operator class (or create a separate index with
text_pattern_ops on nameUa) to accelerate prefix queries like 'prefix%'. Ensure
the change targets the index construction in the pgTable call that references
table.isActive and table.nameUa.
frontend/app/api/shop/webhooks/stripe/route.ts (1)

1007-1028: No log/warning when applyResult.applied is false

If the atomic CTE's WHERE conditions reject the row (e.g., order is already fully paid, or stock has been restored), applied is false and the handler silently continues to markStripeAttemptFinal and logWebhookEvent. Adding a structured warn log here makes idempotent replays and unexpected duplicate deliveries visible in the observability pipeline.

💡 Suggested addition
+      if (!applyResult.applied) {
+        logWarn('stripe_webhook_paid_apply_noop', {
+          ...eventMeta(),
+          code: 'PAID_APPLY_NOOP',
+          orderId: order.id,
+          paymentStatus: order.paymentStatus,
+        });
+      }
       if (applyResult.shipmentQueued) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 1007 - 1028,
When applyStripePaidAndQueueShipmentAtomic returns with applyResult.applied ===
false, add a structured warning log before proceeding to
markStripeAttemptFinal/logWebhookEvent so duplicate or idempotent webhook
replays are visible; locate the call to applyStripePaidAndQueueShipmentAtomic
and insert a warning using the existing logger (include order.id, requestId,
pspChargeId/pspPaymentMethod, and reason like "applyResult.applied=false") and
only then continue to call markStripeAttemptFinal and logWebhookEvent; also
ensure the warn is emitted even when applyResult.shipmentQueued is false so
suppressed applies are observable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/.env.example`:
- Line 164: The file ends without a trailing newline which fails dotenv-linter;
add a single ending blank line by ensuring the last line (GROQ_API_KEY=) is
followed by a newline character so the file terminates with an empty line.

In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx:
- Around line 147-151: The checkout can be triggered before shipping
availability resolves because shippingMethodsLoading is initialized false;
initialize shippingMethodsLoading to true (useState<ShippingMethod[]>(...?) ->
useState(true)) so the UI treats shipping as loading until the first fetch
completes, and ensure any checkout gating logic (e.g., canPlaceOrder) checks
shippingMethodsLoading or shippingAvailable before allowing submission; update
usage of setShippingMethodsLoading in your fetch/shipping methods (e.g., where
you call fetchShippingMethods or loadShippingOptions) to set false when the
initial load finishes or to set shippingAvailable/shippingReasonCode
accordingly.

In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 421-436: The update currently sets shipping_status = 'queued'
based on the pre-computed shouldAttemptEnqueue flag (see shouldAttemptEnqueue
and updated_order), which can become stale and create an orphaned queued status
without a shipping_shipments row; change the SQL so shipping_status is derived
from the actual insertion result (inserted_shipment) instead of the pre-read
flag: remove the CASE branch that uses ${shouldAttemptEnqueue} from the
updated_order SET clause and add a follow-up CTE that updates
orders.shipping_status to 'queued' only when inserted_shipment exists (and keep
eligible_for_enqueue / inventoryCommittedForShippingSql checks in the insertion
CTE), ensuring shipping_status is tied to the real inserted_shipment outcome.

In `@frontend/db/schema/shop.ts`:
- Around line 160-168: shippingAmountMinor lacks a non-negative CHECK constraint
which allows invalid negative shipping costs; add a CHECK on shippingAmountMinor
to enforce >= 0 (mirror the pattern used by
orders_total_amount_minor_non_negative) and name it e.g.
shipping_amount_minor_non_negative so it sits alongside the existing order
checks in the schema; update the schema entry for shippingAmountMinor and the
table-level constraints block where other checks (like
orders_total_amount_minor_non_negative) are declared.

In `@frontend/lib/services/shop/shipping/checkout-payload.ts`:
- Around line 75-163: The functions shippingUnavailableMessage and
buildCheckoutShippingPayload are returning hardcoded English strings for
user-facing errors; change them to return error keys (or accept a translator
function) so localization occurs in the UI. Specifically, update
buildCheckoutShippingPayload to return only code and a new messageKey (or remove
message text) for all failure branches (codes like 'SHIPPING_UNAVAILABLE',
'MISSING_SHIPPING_METHOD', 'MISSING_SHIPPING_CITY',
'MISSING_SHIPPING_WAREHOUSE', 'MISSING_SHIPPING_ADDRESS',
'MISSING_RECIPIENT_NAME', 'INVALID_RECIPIENT_PHONE'), and refactor
shippingUnavailableMessage to return a key (e.g.
'shipping.unavailable.shop_disabled' etc.) or accept a translator param
t(messageKey) to produce localized text; ensure callers in the UI map these
messageKey/code values to t(...) for display.

In `@frontend/messages/pl.json`:
- Around line 419-422: The Polish locale's recipientPhone placeholder uses the
Poland code (+48) but should match the Ukraine format used in en.json/uk.json;
update the "recipientPhone" -> "placeholder" value to use the Ukrainian example
(e.g. "+380XXXXXXXXX або 0XXXXXXXXX" or simply "+380XXXXXXXXX") so it aligns
with Nova Poshta requirements and other locales.

---

Outside diff comments:
In `@frontend/app/`[locale]/shop/orders/[id]/page.tsx:
- Around line 141-198: The try/catch is currently catching Next.js's notFound()
sentinel and converting it into a 500; move the notFound() call outside the try
block (or wrap the DB logic in an IIFE like the admin page) so the sentinel can
propagate. Concretely, inside the code that queries rows (the block using
db.select(...).from(orders)... and the variable rows), ensure you perform the
rows.length === 0 check and call notFound() before the outer catch that logs via
logError('User order detail page failed', error) and throws
Error('ORDER_DETAIL_LOAD_FAILED'); keep the rest of order assembly (base, items,
toOrderItem, createdAt/updatedAt/restockedAt conversion) inside the try so only
real errors are caught.

---

Duplicate comments:
In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx:
- Around line 282-294: The dt labels "Shipping status" and "Tracking number" are
hardcoded; use the loaded translations from
getTranslations('shop.orders.detail') instead. Replace the literal strings in
the two <dt> elements with the translation keys t('shippingStatus') and
t('trackingNumber') (keeping the surrounding markup and classes intact) so the
page uses the locale-specific strings from the shop.orders.detail namespace;
ensure t is the translation function returned where getTranslations was called
and used in this component.

In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 361-370: clearAppliedEventClaim is correctly defined and isolated;
no code change required—just verify the call site in the main processing block
that invokes clearAppliedEventClaim is wrapped in its dedicated try/catch so
failures cannot affect the applied/failed counters (the function name
clearAppliedEventClaim and its invocation in the main processing loop should be
used to locate both definitions).

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts`:
- Around line 310-325: The function cacheWarehousesBySettlement currently
hardcodes deactivated: 0; change it to return the actual number of deactivated
rows by having deactivateMissingWarehouses return its deactivated count (e.g.,
Promise<number> or {deactivated:number}) and await that result here; update
cacheWarehousesBySettlement to call getWarehousesBySettlementRef,
upsertWarehouses, then const deactivated = await
deactivateMissingWarehouses({...}) and return { upserted: warehouses.length,
deactivated }; modify the deactivateMissingWarehouses implementation/signature
to compute and return the count of rows it marks inactive so the value is
accurate.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts`:
- Around line 439-442: RecipientAddressName currently uses an || chain that
drops addressLine2 when addressLine1 is present; update the assignment for
RecipientAddressName (the object property where input.recipient is used) to join
input.recipient.addressLine1 and input.recipient.addressLine2 (e.g., concatenate
non-empty parts with a space or comma, trim final string) and fall back to
undefined if both are empty so the courier receives the full combined address
line.
- Around line 237-267: callNp lacks a fetch timeout and can hang; wrap the fetch
in an AbortController with a reasonable timeout (e.g., 5–15s): create an
AbortController before calling fetch, pass controller.signal to fetch, set a
timer (via setTimeout) that calls controller.abort() after the timeout, and
clear the timer after a successful response; update the catch branch to treat
AbortError distinctly when throwing NovaPoshtaApiError. Ensure changes are made
inside the callNp function around the fetch call and reference the existing
NovaPoshtaApiError and response handling logic.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts`:
- Around line 256-265: The function declaredCostUahFromMinor can produce values
below Nova Poshta’s minimum declared cost; update it to clamp the final rounded
result to a documented minimum (e.g., introduce a constant like
MIN_NOVA_POSHTA_DECLARED_COST_UAH) after the existing rounding logic and before
returning, so return Math.max(roundedValue, MIN_NOVA_POSHTA_DECLARED_COST_UAH);
keep the existing input validation and error (buildFailure) unchanged and ensure
the new constant is clearly named and documented for future reference.

---

Nitpick comments:
In `@frontend/.env.example`:
- Around line 105-107: Reorder the two environment variable declarations so
STRIPE_WEBHOOK_INSTANCE_ID appears before WEBHOOK_INSTANCE_ID in
frontend/.env.example to satisfy dotenv-linter; locate the block containing
WEBHOOK_INSTANCE_ID and STRIPE_WEBHOOK_INSTANCE_ID and swap their lines so
STRIPE_WEBHOOK_INSTANCE_ID is listed first.
- Around line 66-69: Reorder the Monobank env keys in .env.example to satisfy
dotenv-linter: place MONO_REFUND_ENABLED first, then MONO_WEBHOOK_CLAIM_TTL_MS,
and finally MONO_WEBHOOK_MODE; update the block containing MONO_WEBHOOK_MODE,
MONO_WEBHOOK_CLAIM_TTL_MS, and MONO_REFUND_ENABLED so the variables appear in
the new order and preserve their values/comments.
- Around line 48-55: The Stripe env block violates dotenv-linter ordering; swap
the lines so STRIPE_MODE appears before STRIPE_PAYMENTS_ENABLED. Edit the
frontend/.env.example block containing NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
STRIPE_PAYMENTS_ENABLED, STRIPE_MODE, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
and move the STRIPE_MODE entry to precede STRIPE_PAYMENTS_ENABLED so the keys
read NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, STRIPE_MODE, STRIPE_PAYMENTS_ENABLED,
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET to satisfy the linter.
- Around line 81-93: The NP_ environment variable block in .env.example is not
in the dotenv-linter expected order; reorder the NP_* keys into alphabetical
order so the linter passes. Specifically locate the NP_* entries (NP_API_KEY,
NP_API_BASE, NP_SENDER_REF, NP_SENDER_CONTACT_REF, NP_SENDER_ADDRESS_REF,
NP_SENDER_CITY_REF, NP_SENDER_PHONE, NP_TIMEOUT_MS, NP_MAX_RETRIES,
NP_RETRY_DELAY_MS) and sort them alphabetically (e.g., NP_API_BASE before
NP_API_KEY, NP_MAX_RETRIES before NP_RETRY_DELAY_MS, etc.), keeping the same
comments and blank lines semantics. Ensure no duplicates or spacing changes
beyond reordering.

In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 1007-1028: When applyStripePaidAndQueueShipmentAtomic returns with
applyResult.applied === false, add a structured warning log before proceeding to
markStripeAttemptFinal/logWebhookEvent so duplicate or idempotent webhook
replays are visible; locate the call to applyStripePaidAndQueueShipmentAtomic
and insert a warning using the existing logger (include order.id, requestId,
pspChargeId/pspPaymentMethod, and reason like "applyResult.applied=false") and
only then continue to call markStripeAttemptFinal and logWebhookEvent; also
ensure the warn is emitted even when applyResult.shipmentQueued is false so
suppressed applies are observable.

In `@frontend/db/schema/shop.ts`:
- Around line 594-625: Add a non-negative check constraint for attemptCount on
the shippingShipments table: ensure the schema definition for shippingShipments
includes a CHECK(attempt_count >= 0) (either on the attemptCount column or as a
table-level constraint) so attemptCount (field name attemptCount / DB column
attempt_count) cannot be negative; update the table definition where
shippingShipments is declared to include this constraint alongside the existing
indexes and unique index.
- Around line 240-261: The CHECK pair for shipping fields enforces
presence/absence of shippingProvider, shippingMethodCode, and shippingStatus
based on shippingRequired but omits shippingPayer, allowing NULL payer when
shippingRequired is true; update the 'orders_shipping_present_when_required_chk'
(and symmetrically 'orders_shipping_null_when_not_required_chk' if you want
strict parity) to include ${table.shippingPayer} in the AND conditions so that
when shippingRequired is true shippingPayer IS NOT NULL (and when
shippingRequired is TRUE in the null-check ensure shippingPayer IS NULL) —
modify the check() calls around 'orders_shipping_present_when_required_chk' and
'orders_shipping_null_when_not_required_chk' to add ${table.shippingPayer} to
the grouped predicates.
- Around line 627-650: The npCities table's index np_cities_active_name_idx
currently uses the default B-tree operator class which won't help prefix LIKE
searches on nameUa in non-C collations; update the index definition on npCities
(np_cities_active_name_idx) so the nameUa/name_ua column uses the
text_pattern_ops operator class (or create a separate index with
text_pattern_ops on nameUa) to accelerate prefix queries like 'prefix%'. Ensure
the change targets the index construction in the pgTable call that references
table.isActive and table.nameUa.

In `@frontend/lib/services/orders/_shared.ts`:
- Around line 148-170: mergeCheckoutItems currently normalizes each item with
normalizeCheckoutItem then calls checkoutItemMergeKey which re-normalizes the
same item; to fix, call checkoutItemMergeKey with the already-normalized
variable (it) so the key builder doesn't re-run normalization, and when
computing mergedQty use it.quantity (the normalized copy) instead of
item.quantity; keep the rest of the logic (map, MAX_QUANTITY_PER_LINE check,
InvalidPayloadError throw) unchanged.

In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 860-942: The loop reads candidates via readUnclaimedJob3Candidates
then applies each with applyMonoWebhookEvent without an atomic per-event claim,
allowing concurrent janitor runs to process the same event; fix by making the
candidate selection atomic—either change readUnclaimedJob3Candidates to SELECT
... FOR UPDATE SKIP LOCKED (or use a transaction that updates
claim_expires_at/claimer and returns the rows) so rows are locked/marked
exclusively before calling applyMonoWebhookEvent, or add an immediate atomic
update-by-id claim step before applyMonoWebhookEvent that returns whether the
claim succeeded and skip processing if not; ensure clearAppliedEventClaim logic
still runs only for successfully claimed/applied events (use eventId + claim
ownership) to avoid redundant clear attempts.

In `@frontend/lib/services/orders/monobank-webhook.ts`:
- Around line 476-569: The update currently sets shipping_status based on the
precomputed boolean args.enqueueShipment which can drift from DB-side
eligibility; inside atomicMarkPaidOrderAndSucceedAttempt update the
orders.shipping_status using the same database predicates as the
eligible_for_enqueue CTE (i.e. payment_status = 'paid', shipping_required =
true, shipping_provider = 'nova_poshta', shipping_method_code IS NOT NULL, and
the inventoryCommittedForShippingSql(...) predicate against
updated_order.inventory_status) so the CASE in the orders UPDATE (in
updated_order) mirrors the eligible_for_enqueue logic and keeps shipping_status
consistent with inserted_shipment/eligible_for_enqueue and the inserted_shipment
CTE.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts`:
- Around line 163-206: upsertCities currently builds one giant INSERT VALUES
block (values, sql.join, db.execute) which can exceed SQL/driver limits for
large inputs; modify upsertCities to process rows in bounded batches (e.g.,
chunk size 200–500) and perform the same INSERT ... ON CONFLICT call per chunk
inside a loop, reusing the same VALUES/sql templates and runId, or if you intend
to rely on small responses, add a clear comment near upsertCities explaining the
expected upper bound and why batching is unnecessary.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 96e2303 and 444aa4e.

📒 Files selected for processing (24)
  • frontend/.env.example
  • frontend/app/[locale]/admin/shop/orders/[id]/page.tsx
  • frontend/app/[locale]/shop/cart/CartPageClient.tsx
  • frontend/app/[locale]/shop/orders/[id]/page.tsx
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/db/schema/shop.ts
  • frontend/drizzle/0018_shop_orders_shipping_invariants.sql
  • frontend/drizzle/meta/0018_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/services/orders/_shared.ts
  • frontend/lib/services/orders/checkout.ts
  • frontend/lib/services/orders/monobank-janitor.ts
  • frontend/lib/services/orders/monobank-webhook.ts
  • frontend/lib/services/shop/shipping/admin-actions.ts
  • frontend/lib/services/shop/shipping/availability.ts
  • frontend/lib/services/shop/shipping/checkout-payload.ts
  • frontend/lib/services/shop/shipping/inventory-eligibility.ts
  • frontend/lib/services/shop/shipping/nova-poshta-catalog.ts
  • frontend/lib/services/shop/shipping/nova-poshta-client.ts
  • frontend/lib/services/shop/shipping/shipments-worker.ts
  • frontend/lib/shop/locale.ts
  • frontend/messages/en.json
  • frontend/messages/pl.json
  • frontend/messages/uk.json
✅ Files skipped from review due to trivial changes (1)
  • frontend/messages/uk.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/services/shop/shipping/inventory-eligibility.ts

Copy link
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: 1

♻️ Duplicate comments (1)
frontend/app/[locale]/shop/cart/CartPageClient.tsx (1)

147-153: ⚠️ Potential issue | 🟡 Minor

Initialize shippingMethodsLoading to true to avoid premature checkout.

Line 148 starts it as false, so canPlaceOrder can go true before the first shipping-methods fetch completes.

🔧 Suggested fix
-  const [shippingMethodsLoading, setShippingMethodsLoading] = useState(false);
+  const [shippingMethodsLoading, setShippingMethodsLoading] = useState(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx around lines 147 - 153,
The shippingMethodsLoading state is initialized false which allows canPlaceOrder
to become true before the first fetch finishes; change the initializer of
shippingMethodsLoading in CartPageClient (the useState call named
shippingMethodsLoading/setShippingMethodsLoading) to true and ensure any
existing fetchShippingMethods logic sets setShippingMethodsLoading(false) on
both success and error so loading reflects the actual fetch lifecycle.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx:
- Around line 1141-1160: The warehouse search input with id
"shipping-warehouse-search" lacks an accessible label; add one so screen readers
can identify it: either render a <label htmlFor="shipping-warehouse-search">
(use a visually-hidden CSS class if you don't want it visible) with localized
text from t('delivery.warehouse.label') or add an aria-label/aria-labelledby
that uses the same localized string. Update the JSX near the input (the block
using warehouseQuery, setWarehouseQuery, setSelectedWarehouseRef,
setSelectedWarehouseName, and selectedCityRef) to include the label approach you
choose.

---

Duplicate comments:
In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx:
- Around line 147-153: The shippingMethodsLoading state is initialized false
which allows canPlaceOrder to become true before the first fetch finishes;
change the initializer of shippingMethodsLoading in CartPageClient (the useState
call named shippingMethodsLoading/setShippingMethodsLoading) to true and ensure
any existing fetchShippingMethods logic sets setShippingMethodsLoading(false) on
both success and error so loading reflects the actual fetch lifecycle.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 444aa4e and 047f705.

📒 Files selected for processing (2)
  • frontend/app/[locale]/shop/cart/CartPageClient.tsx
  • frontend/lib/shop/locale.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/lib/shop/locale.ts

Copy link
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: 4

Caution

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

⚠️ Outside diff range comments (1)
frontend/lib/services/orders/monobank-webhook.ts (1)

489-540: ⚠️ Potential issue | 🟠 Major

Duplicate eligibility conditions create a DB consistency hazard.

The 6-condition gate (Lines 489–498 CASE and Lines 529–540 eligible_for_enqueue) is copy-pasted. If they ever diverge — a common outcome during future edits — the order row will have shipping_status = 'queued' with no corresponding shipping_shipments record, or vice versa.

The safer pattern is to make eligible_for_enqueue the single source of truth and derive the shipping_status update from it:

🛡️ Proposed fix — derive shipping_status from eligible_for_enqueue rather than duplicating conditions
    updated_order as (
      update orders
      set status = 'PAID',
-         shipping_status = case
-           when ${args.enqueueShipment}
-             and payment_status = 'paid'
-             and shipping_required = true
-             and shipping_provider = 'nova_poshta'
-             and shipping_method_code is not null
-             and ${inventoryCommittedForShippingSql(sql`orders.inventory_status`)}
-           then 'queued'::shipping_status
-           else shipping_status
-         end,
          psp_charge_id = ${args.invoiceId},
          psp_metadata = ${args.mergedMetaSql},
          updated_at = ${args.now}
      where id = ${args.orderId}::uuid
        and payment_provider = 'monobank'
        and exists (
          select 1
          from payment_attempts
          where id = ${args.attemptId}::uuid
        )
      returning
        id,
        payment_status,
        inventory_status,
        shipping_required,
        shipping_provider,
        shipping_method_code
    ),
    ...
    eligible_for_enqueue as (
      select id
      from updated_order
      where ${args.enqueueShipment}
        and payment_status = 'paid'
        and shipping_required = true
        and shipping_provider = 'nova_poshta'
        and shipping_method_code is not null
        and ${inventoryCommittedForShippingSql(
          sql`updated_order.inventory_status`
        )}
    ),
    inserted_shipment as (
      insert into shipping_shipments (...)
      select id, 'nova_poshta', 'queued', 0, ${args.now}, ${args.now}
      from eligible_for_enqueue
      on conflict (order_id) do nothing
      returning order_id
    ),
+   shipping_status_update as (
+     update orders
+     set shipping_status = 'queued'::shipping_status,
+         updated_at = ${args.now}
+     where id in (select order_id from inserted_shipment)
+     returning id
+   )
    select
      (select id from updated_order) as order_id,
      (select id from updated_attempt) as attempt_id,
      (select count(*)::int from inserted_shipment) as inserted_shipment_count
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-webhook.ts` around lines 489 - 540, The
update duplicates the 6-condition gate in the CASE and in eligible_for_enqueue;
make eligible_for_enqueue the single source of truth and derive shipping_status
from it by changing the CASE in the orders update (the CTE producing
updated_order) to set shipping_status = 'queued' only when the row exists in
eligible_for_enqueue (e.g. replace the six-condition expression that uses
args.enqueueShipment and
inventoryCommittedForShippingSql(sql`orders.inventory_status`) with an existence
test against the eligible_for_enqueue CTE for that order id), leaving
eligible_for_enqueue unchanged (it still uses args.enqueueShipment and
inventoryCommittedForShippingSql(sql`updated_order.inventory_status`) so the
logic is centralized).
♻️ Duplicate comments (5)
frontend/app/[locale]/admin/shop/orders/[id]/page.tsx (1)

392-408: ⚠️ Potential issue | 🟡 Minor

Hardcoded "Qty:", "Unit:", "Line:" visible labels — i18n incomplete.

The screen-reader <dt> counterparts already use t('quantity'), t('unitPrice'), and t('lineTotal'), but the visible <dd> prefixes are bare English string literals. These need localized short-form keys (e.g. t('qtyShort'), t('unitShort'), t('lineShort')) or the existing keys if a short form exists.

♻️ Proposed fix
-<dd className="text-sm">Qty: {it.quantity}</dd>
+<dd className="text-sm">{t('qtyShort')}: {it.quantity}</dd>
-<dd className="text-muted-foreground text-sm">
-  Unit:{' '}
-  {safeFormatMoneyMajor(it.unitPrice, currency, locale)}
-</dd>
+<dd className="text-muted-foreground text-sm">
+  {t('unitShort')}:{' '}
+  {safeFormatMoneyMajor(it.unitPrice, currency, locale)}
+</dd>
-<dd className="text-sm font-medium">
-  Line:{' '}
-  {safeFormatMoneyMajor(it.lineTotal, currency, locale)}
-</dd>
+<dd className="text-sm font-medium">
+  {t('lineShort')}:{' '}
+  {safeFormatMoneyMajor(it.lineTotal, currency, locale)}
+</dd>

Add the corresponding keys to the shop.orders.detail translation namespace.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx around lines 392 -
408, Replace the hardcoded visible prefixes "Qty:", "Unit:", and "Line:" inside
the dd elements that render quantities and prices with localized keys (e.g. use
t('qtyShort'), t('unitShort'), t('lineShort') where the current dd nodes use
"Qty:", "Unit:", "Line:"); update the corresponding translation namespace
(shop.orders.detail) to include these short-form keys; ensure the dt elements
remain using t('quantity'), t('unitPrice'), t('lineTotal') and that
safeFormatMoneyMajor is still used to format amounts.
frontend/lib/services/orders/monobank-janitor.ts (2)

375-388: clearAppliedEventClaim resolved and correctly guarded.

The function is now defined (resolving the prior undefined-symbol issue) and the and applied_at is not null guard on line 386 is correctly defensive: if persistEventOutcome never persisted applied_at, the claim silently expires rather than being spuriously cleared. The ownership check (claimed_by = ${args.runId}) further prevents cross-run interference.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 375 - 388,
Define the previously-missing function clearAppliedEventClaim to update
monobank_events and clear claimed_at, claim_expires_at, and claimed_by only for
the given eventId/runId when applied_at is not null; ensure the SQL uses the
arguments eventId and runId (as in the function signature) and preserves the
defensive guard "and applied_at is not null" plus the ownership check "and
claimed_by = ${args.runId}" so claims are only cleared for events that were
actually applied and only by the owning run.

912-936: Double-counting fix confirmed — clearAppliedEventClaim isolated from the main error path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 912 - 936, The
change correctly isolates clearAppliedEventClaim from the main applied/noop
counting path; ensure the call to clearAppliedEventClaim remains outside the
logic that increments applied and noop (see
isAppliedResult(result.appliedResult), applied, noop) so that any errors from
clearAppliedEventClaim do not affect counts, and keep the catch logging via
logWarn('internal_monobank_janitor_job3_clear_claim_failed', { ...args.baseMeta,
code: 'JANITOR_JOB3_CLEAR_CLAIM_FAILED', runId: args.runId, eventId:
eventRow.id, invoiceId: eventRow.invoice_id, attemptId: eventRow.attempt_id,
error: clearError instanceof Error ? clearError.message : String(clearError) })
unchanged.
frontend/lib/services/shop/shipping/nova-poshta-catalog.ts (1)

279-309: ⚠️ Potential issue | 🟠 Major

deactivated count still depends on rowCount, which is adapter-shape fragile.

Lines 289-290 and 306-307 assume db.execute() exposes rowCount. If that shape isn’t provided by your drizzle driver, deactivated stays 0 even when rows are updated.

🐛 Proposed fix (use RETURNING rows as the count source)
 async function deactivateMissingWarehouses(args: {
   settlementRef: string;
   keepRefs: string[];
   runId: string;
 }): Promise<number> {
@@
   if (!keepRefs.length) {
-    const res = await db.execute(sql`
+    const res = await db.execute<{ ref: string }>(sql`
       UPDATE np_warehouses
       SET
         is_active = false,
         last_sync_run_id = ${args.runId}::uuid,
         updated_at = now()
       WHERE settlement_ref = ${args.settlementRef}
         AND is_active = true
+      RETURNING ref
     `);
-
-    const rowCount =
-      typeof (res as any)?.rowCount === 'number' ? (res as any).rowCount : 0;
-
-    return rowCount;
+    return Array.isArray((res as any).rows) ? (res as any).rows.length : 0;
   }
@@
-  const res = await db.execute(sql`
+  const res = await db.execute<{ ref: string }>(sql`
     UPDATE np_warehouses
     SET
       is_active = false,
       last_sync_run_id = ${args.runId}::uuid,
       updated_at = now()
     WHERE settlement_ref = ${args.settlementRef}
       AND is_active = true
       AND ref NOT IN (${sql.join(refs, sql`, `)})
+    RETURNING ref
   `);
-
-  const rowCount =
-    typeof (res as any)?.rowCount === 'number' ? (res as any).rowCount : 0;
-
-  return rowCount;
+  return Array.isArray((res as any).rows) ? (res as any).rows.length : 0;
 }

Run this to verify result-shape assumptions before finalizing:

#!/bin/bash
set -euo pipefail

echo "=== drizzle version in frontend/package.json ==="
rg -n '"drizzle-orm"' frontend/package.json

echo
echo "=== DB adapter implementation (inspect execute wiring) ==="
cat frontend/db/index.ts

echo
echo "=== rowCount usage vs rows usage around shipping services ==="
rg -n -C2 'rowCount|\.rows' frontend/lib/services/shop/shipping

Expected: if rowCount is not part of your adapter result contract, this function should switch to RETURNING-based counting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts` around lines 279
- 309, The deactivated count logic in the block using db.execute (inside the
function that builds refs from keepRefs and the earlier single-update branch)
relies on a driver-specific res.rowCount shape; instead modify both UPDATE
statements (the single-branch and the NOT IN branch that uses sql.join(refs,
sql`, `)) to append a RETURNING clause (e.g. RETURNING id or ref) and then
derive deactivated from the returned rows length (inspect res.rows or the
adapter's returned array shape) rather than referencing (res as any).rowCount;
update references to args.runId, args.settlementRef, keepRefs, db.execute and
sql.join accordingly so both paths use the RETURNING-based count.
frontend/.env.example (1)

80-90: ⚠️ Potential issue | 🟠 Major

Required Nova Poshta sender keys in the example are mismatched/missing.

Line 85 documents NP_SENDER_ADDRESS_REF, but config validation expects NP_SENDER_WAREHOUSE_REF, and required NP_SENDER_NAME is missing. With shipping enabled, this can fail fast with NovaPoshtaConfigError.

🐛 Proposed fix
 # Required when shipping is enabled (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1).
 # If shipping is enabled without required NP config, app throws NovaPoshtaConfigError at runtime.
 NP_API_KEY=
 # Optional if code has a default; set explicitly in production for clarity
 NP_API_BASE=
-NP_SENDER_ADDRESS_REF=
+NP_SENDER_WAREHOUSE_REF=
 NP_SENDER_CITY_REF=
 NP_SENDER_CONTACT_REF=
+NP_SENDER_NAME=
 NP_SENDER_PHONE=
 NP_SENDER_REF=
+NP_SENDER_EDRPOU=
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/.env.example` around lines 80 - 90, Update the example environment
variables to match the config validation: replace the incorrect
NP_SENDER_ADDRESS_REF with NP_SENDER_WAREHOUSE_REF and add the missing required
NP_SENDER_NAME entry; ensure NP_API_KEY, NP_API_BASE, NP_SENDER_WAREHOUSE_REF,
NP_SENDER_CITY_REF, NP_SENDER_CONTACT_REF, NP_SENDER_PHONE, NP_SENDER_REF and
the new NP_SENDER_NAME are present in the example so NovaPoshtaConfigError isn’t
triggered when shipping is enabled.
🧹 Nitpick comments (6)
frontend/drizzle/0019_p2_shop_invariants.sql (1)

33-40: Consider the cumulative scan cost on large orders tables.

Three consecutive VALIDATE CONSTRAINT calls against orders each perform a full sequential scan. On a modest-sized table this is fine, but if orders grows large, you may want to combine the three order constraints into a single scan by adding them in one ALTER TABLE ... ADD CONSTRAINT ... ADD CONSTRAINT ... statement (PostgreSQL 12+ supports multiple sub-commands per ALTER). Alternatively, just be aware of the migration runtime.

Not a blocker — the current approach is correct and safe.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/drizzle/0019_p2_shop_invariants.sql` around lines 33 - 40, Multiple
VALIDATE CONSTRAINT commands on "orders" cause repeated full-table scans;
combine the three validations into a single ALTER TABLE "orders" statement that
lists the constraints orders_shipping_amount_minor_non_negative_chk,
orders_shipping_payer_null_when_not_required_chk, and
orders_shipping_payer_present_when_required_chk as multiple sub-commands in one
ALTER to perform a single scan (or leave as-is if longer runtime is acceptable)
— update the migration to use one ALTER TABLE "orders" ... VALIDATE CONSTRAINT
... VALIDATE CONSTRAINT ... sequence instead of three separate ALTERs.
frontend/app/[locale]/admin/shop/orders/[id]/page.tsx (2)

5-5: noStore() is redundant alongside dynamic = 'force-dynamic'.

dynamic = 'force-dynamic' at module level already opts the entire route out of caching. The unstable_noStore import and its noStore() call inside the component body have no additional effect.

♻️ Proposed cleanup
-import { unstable_noStore as noStore } from 'next/cache';
  noStore();
-
  const { locale, id } = await params;

(Remove the noStore() call at line 120 and the now-unused import.)

Also applies to: 120-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx at line 5, Remove the
redundant unstable_noStore import and noStore() call since the module already
sets dynamic = 'force-dynamic'; specifically delete the import statement "import
{ unstable_noStore as noStore } from 'next/cache';" and remove the noStore()
invocation inside the page component (the default export in page.tsx), leaving
dynamic = 'force-dynamic' as the single mechanism opting out of caching.

305-349: {isAdmin && ...} guards are always true and can be removed.

notFound() is called on line 138 when !isAdmin, so every code path reaching the JSX already guarantees isAdmin === true. The three conditional blocks add noise without any runtime effect.

♻️ Proposed cleanup (example for first block; apply similarly to the other two)
-{isAdmin && (
-  <div>
-    <dt className="text-muted-foreground text-xs">{t('provider')}</dt>
-    <dd className="text-sm">{String(order.paymentProvider)}</dd>
-  </div>
-)}
+<div>
+  <dt className="text-muted-foreground text-xs">{t('provider')}</dt>
+  <dd className="text-sm">{String(order.paymentProvider)}</dd>
+</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx around lines 305 -
349, The three JSX blocks are wrapped in redundant {isAdmin && ...} checks
because notFound() already prevents non-admins from reaching this render; remove
the conditional guards and render the dl blocks unconditionally. Specifically,
update the sections using isAdmin around order.paymentProvider,
paymentIntentId/idempotencyKey, and stockRestored/restockedFormatted so they
render directly (keep the dt/dd contents and classNames intact) and delete the
{isAdmin && ( ... )} wrappers; no other logic changes required.
frontend/lib/services/orders/monobank-janitor.ts (1)

937-947: No early claim release on failure — consider the retry-latency implication.

Unlike Job1/Job2, which call releaseAttemptLease in a finally block so a failed row is immediately eligible for the next run, Job3 leaves the claim locked until claim_expires_at. This means a transiently-failing event is retried only after the full leaseSeconds window (15 s–30 min depending on MONO_JANITOR_LEASE_SECONDS), rather than on the next janitor invocation.

If fast recovery from transient errors (e.g., DB blip) is important for Job3, add an explicit release on the error path:

♻️ Optional: release claim on failure to align with Job1/Job2 pattern
    } catch (error) {
      failed += 1;
      logError('internal_monobank_janitor_job3_event_failed', error, {
        ...args.baseMeta,
        code: 'JANITOR_JOB3_EVENT_FAILED',
        runId: args.runId,
        eventId: eventRow.id,
        invoiceId: eventRow.invoice_id,
        attemptId: eventRow.attempt_id,
      });
+      // Release claim so the event can be retried on the next run
+      // rather than waiting for lease expiry.
+      try {
+        await db.execute(sql`
+          update monobank_events
+          set claimed_at = null,
+              claim_expires_at = null,
+              claimed_by = null
+          where id = ${eventRow.id}::uuid
+            and claimed_by = ${args.runId}
+            and applied_at is null
+        `);
+      } catch (_releaseErr) {
+        // best-effort
+      }
    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 937 - 947,
Job3 currently leaves the claim locked on error, causing retries to wait until
claim_expires_at; mirror Job1/Job2 behavior by calling releaseAttemptLease(...)
when handling failures in the catch block for Job3 (the same attempt identifiers
used for logging: eventRow.id, eventRow.attempt_id, args.runId, etc.), or move
the releaseAttemptLease call into a finally block around the try/catch in
monobank-janitor.ts so that failed rows are released immediately instead of
waiting for MONO_JANITOR_LEASE_SECONDS.
frontend/lib/services/shop/shipping/nova-poshta-catalog.ts (1)

214-269: Chunk warehouse upserts to avoid oversized SQL payloads.

upsertWarehouses() sends all rows in a single INSERT ... VALUES. Large settlements can produce very large statements/parameter counts and hurt reliability.

♻️ Proposed refactor
 async function upsertWarehouses(rows: NovaPoshtaWarehouse[], runId: string) {
   if (!rows.length) return;
-
-  const values = rows.map(item => {
-    return sql`(
-      ${item.ref},
-      ${item.cityRef},
-      ${item.settlementRef},
-      ${item.number},
-      ${item.type},
-      ${item.name},
-      ${item.nameRu},
-      ${item.address},
-      ${item.addressRu},
-      ${item.isPostMachine},
-      true,
-      ${runId}::uuid,
-      now(),
-      now()
-    )`;
-  });
-
-  await db.execute(sql`
+  const CHUNK_SIZE = 300;
+  for (let i = 0; i < rows.length; i += CHUNK_SIZE) {
+    const chunk = rows.slice(i, i + CHUNK_SIZE);
+    const values = chunk.map(item => sql`(
+      ${item.ref},
+      ${item.cityRef},
+      ${item.settlementRef},
+      ${item.number},
+      ${item.type},
+      ${item.name},
+      ${item.nameRu},
+      ${item.address},
+      ${item.addressRu},
+      ${item.isPostMachine},
+      true,
+      ${runId}::uuid,
+      now(),
+      now()
+    )`);
+
+    await db.execute(sql`
     INSERT INTO np_warehouses (
       ref,
       city_ref,
@@
     ON CONFLICT (ref) DO UPDATE
       SET
         city_ref = EXCLUDED.city_ref,
@@
         is_active = true,
         last_sync_run_id = EXCLUDED.last_sync_run_id,
         updated_at = now()
-  `);
+    `);
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts` around lines 214
- 269, upsertWarehouses currently builds one giant INSERT ... VALUES for all
rows which can exceed DB parameter limits; modify upsertWarehouses to chunk rows
(e.g., 500–1000 items per batch) and run the existing INSERT ... ON CONFLICT
logic for each chunk in a loop: split the incoming rows array into batches, map
each batch to its values array (same mapping logic currently using sql`(...)`),
call db.execute with sql.join(values, sql`, `) per batch, and await each
execution; keep the early return when rows.length is zero and reuse the same
column list and ON CONFLICT update block for each chunk to preserve behavior.
frontend/lib/services/shop/shipping/shipments-worker.ts (1)

518-530: Validate snapshot method against persisted shipping_method_code.

You currently only check presence of shipping_method_code. A mismatch between DB column and snapshot methodCode can slip through and build NP payload from inconsistent data.

♻️ Proposed guard
     if (!details.shipping_method_code) {
       throw buildFailure(
         'SHIPPING_METHOD_MISSING',
         'Shipping method is missing.',
         false
       );
     }
 
     const parsedSnapshot = parseSnapshot(details.shipping_address);
+    if (parsedSnapshot.methodCode !== details.shipping_method_code) {
+      throw buildFailure(
+        'SHIPPING_METHOD_MISMATCH',
+        'Shipping method does not match persisted order method.',
+        false
+      );
+    }
+
     const payload = toNpPayload({
       order: details,
       snapshot: parsedSnapshot,
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts` around lines 518 -
530, The code only checks that details.shipping_method_code exists but does not
ensure it matches the parsed snapshot, so
parseSnapshot(details.shipping_address) may contain a different methodCode;
update the guard to parse the snapshot first (parseSnapshot), compare
parsedSnapshot.methodCode (or equivalent property) to
details.shipping_method_code, and if they differ throw a failure (e.g.,
SHIPPING_METHOD_MISMATCH) before calling toNpPayload(order: details, snapshot:
parsedSnapshot) so the NP payload is built only from consistent data.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/.env.example`:
- Around line 82-84: The dotenv-linter warning is due to ordering of the NP
keys; move the NP_API_BASE declaration so it appears before NP_API_KEY in
frontend/.env.example (ensure the comment about optional/defaults remains with
NP_API_BASE if needed) so the two entries read NP_API_BASE then NP_API_KEY; no
other changes to values or comments are required.

In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx:
- Around line 293-299: The current parsing of the /shipping/methods response may
silently treat malformed HTTP 200 payloads as available=false but leave
shippingUnavailableHardBlock false; update the validation in CartPageClient
(variables available, reasonCode, methods) to fail closed: explicitly verify
types for data.available (boolean), data.reasonCode (matches
ShippingAvailabilityReasonCode) and data.methods (Array of ShippingMethod), and
if any check fails set available = false and set a hard block flag
(shippingUnavailableHardBlock = true) and populate reasonCode to a safe failure
code (or non-null sentinel) so checkout cannot be re-enabled on malformed
success responses; locate and modify the parsing logic around available,
reasonCode and methods to add these guards.

In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 421-427: The enqueue eligibility is currently decided using
pre-read booleans (e.g., shouldAttemptEnqueue /
args.paymentBecamePaidInThisApply and
args.shippingRequired/shippingProvider/shippingMethodCode) which can be stale;
instead compute eligible_for_enqueue from the DB-returned updated_order state
(e.g., use updated_order.payment_status / updated_order.shipping_required /
updated_order.shipping_provider / updated_order.shipping_method_code and call
isInventoryCommittedForShipping(updated_order.inventory_status)) at the point
just before generating the SQL/queue action so the decision reflects the
persisted state; apply the same change to the second occurrence referenced
around the block at 454-465 so both enqueue checks derive eligibility from
updated_order rather than args.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts`:
- Around line 534-550: markSucceeded() can return null when the lease was lost;
update the try block to check the result of markSucceeded (the call to
markSucceeded with shipmentId: args.claim.id, runId: args.runId, providerRef:
created.providerRef, trackingNumber: created.trackingNumber) and if it returns
null treat this as a lease-loss outcome: do not call recordShippingMetric(...)
with name:'succeeded' nor return 'succeeded'—instead record/return an
appropriate lease-loss status (or throw) so the run is not misreported; place
this conditional immediately after the await markSucceeded(...) and before the
recordShippingMetric and return statements.

---

Outside diff comments:
In `@frontend/lib/services/orders/monobank-webhook.ts`:
- Around line 489-540: The update duplicates the 6-condition gate in the CASE
and in eligible_for_enqueue; make eligible_for_enqueue the single source of
truth and derive shipping_status from it by changing the CASE in the orders
update (the CTE producing updated_order) to set shipping_status = 'queued' only
when the row exists in eligible_for_enqueue (e.g. replace the six-condition
expression that uses args.enqueueShipment and
inventoryCommittedForShippingSql(sql`orders.inventory_status`) with an existence
test against the eligible_for_enqueue CTE for that order id), leaving
eligible_for_enqueue unchanged (it still uses args.enqueueShipment and
inventoryCommittedForShippingSql(sql`updated_order.inventory_status`) so the
logic is centralized).

---

Duplicate comments:
In `@frontend/.env.example`:
- Around line 80-90: Update the example environment variables to match the
config validation: replace the incorrect NP_SENDER_ADDRESS_REF with
NP_SENDER_WAREHOUSE_REF and add the missing required NP_SENDER_NAME entry;
ensure NP_API_KEY, NP_API_BASE, NP_SENDER_WAREHOUSE_REF, NP_SENDER_CITY_REF,
NP_SENDER_CONTACT_REF, NP_SENDER_PHONE, NP_SENDER_REF and the new NP_SENDER_NAME
are present in the example so NovaPoshtaConfigError isn’t triggered when
shipping is enabled.

In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx:
- Around line 392-408: Replace the hardcoded visible prefixes "Qty:", "Unit:",
and "Line:" inside the dd elements that render quantities and prices with
localized keys (e.g. use t('qtyShort'), t('unitShort'), t('lineShort') where the
current dd nodes use "Qty:", "Unit:", "Line:"); update the corresponding
translation namespace (shop.orders.detail) to include these short-form keys;
ensure the dt elements remain using t('quantity'), t('unitPrice'),
t('lineTotal') and that safeFormatMoneyMajor is still used to format amounts.

In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 375-388: Define the previously-missing function
clearAppliedEventClaim to update monobank_events and clear claimed_at,
claim_expires_at, and claimed_by only for the given eventId/runId when
applied_at is not null; ensure the SQL uses the arguments eventId and runId (as
in the function signature) and preserves the defensive guard "and applied_at is
not null" plus the ownership check "and claimed_by = ${args.runId}" so claims
are only cleared for events that were actually applied and only by the owning
run.
- Around line 912-936: The change correctly isolates clearAppliedEventClaim from
the main applied/noop counting path; ensure the call to clearAppliedEventClaim
remains outside the logic that increments applied and noop (see
isAppliedResult(result.appliedResult), applied, noop) so that any errors from
clearAppliedEventClaim do not affect counts, and keep the catch logging via
logWarn('internal_monobank_janitor_job3_clear_claim_failed', { ...args.baseMeta,
code: 'JANITOR_JOB3_CLEAR_CLAIM_FAILED', runId: args.runId, eventId:
eventRow.id, invoiceId: eventRow.invoice_id, attemptId: eventRow.attempt_id,
error: clearError instanceof Error ? clearError.message : String(clearError) })
unchanged.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts`:
- Around line 279-309: The deactivated count logic in the block using db.execute
(inside the function that builds refs from keepRefs and the earlier
single-update branch) relies on a driver-specific res.rowCount shape; instead
modify both UPDATE statements (the single-branch and the NOT IN branch that uses
sql.join(refs, sql`, `)) to append a RETURNING clause (e.g. RETURNING id or ref)
and then derive deactivated from the returned rows length (inspect res.rows or
the adapter's returned array shape) rather than referencing (res as
any).rowCount; update references to args.runId, args.settlementRef, keepRefs,
db.execute and sql.join accordingly so both paths use the RETURNING-based count.

---

Nitpick comments:
In `@frontend/app/`[locale]/admin/shop/orders/[id]/page.tsx:
- Line 5: Remove the redundant unstable_noStore import and noStore() call since
the module already sets dynamic = 'force-dynamic'; specifically delete the
import statement "import { unstable_noStore as noStore } from 'next/cache';" and
remove the noStore() invocation inside the page component (the default export in
page.tsx), leaving dynamic = 'force-dynamic' as the single mechanism opting out
of caching.
- Around line 305-349: The three JSX blocks are wrapped in redundant {isAdmin &&
...} checks because notFound() already prevents non-admins from reaching this
render; remove the conditional guards and render the dl blocks unconditionally.
Specifically, update the sections using isAdmin around order.paymentProvider,
paymentIntentId/idempotencyKey, and stockRestored/restockedFormatted so they
render directly (keep the dt/dd contents and classNames intact) and delete the
{isAdmin && ( ... )} wrappers; no other logic changes required.

In `@frontend/drizzle/0019_p2_shop_invariants.sql`:
- Around line 33-40: Multiple VALIDATE CONSTRAINT commands on "orders" cause
repeated full-table scans; combine the three validations into a single ALTER
TABLE "orders" statement that lists the constraints
orders_shipping_amount_minor_non_negative_chk,
orders_shipping_payer_null_when_not_required_chk, and
orders_shipping_payer_present_when_required_chk as multiple sub-commands in one
ALTER to perform a single scan (or leave as-is if longer runtime is acceptable)
— update the migration to use one ALTER TABLE "orders" ... VALIDATE CONSTRAINT
... VALIDATE CONSTRAINT ... sequence instead of three separate ALTERs.

In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 937-947: Job3 currently leaves the claim locked on error, causing
retries to wait until claim_expires_at; mirror Job1/Job2 behavior by calling
releaseAttemptLease(...) when handling failures in the catch block for Job3 (the
same attempt identifiers used for logging: eventRow.id, eventRow.attempt_id,
args.runId, etc.), or move the releaseAttemptLease call into a finally block
around the try/catch in monobank-janitor.ts so that failed rows are released
immediately instead of waiting for MONO_JANITOR_LEASE_SECONDS.

In `@frontend/lib/services/shop/shipping/nova-poshta-catalog.ts`:
- Around line 214-269: upsertWarehouses currently builds one giant INSERT ...
VALUES for all rows which can exceed DB parameter limits; modify
upsertWarehouses to chunk rows (e.g., 500–1000 items per batch) and run the
existing INSERT ... ON CONFLICT logic for each chunk in a loop: split the
incoming rows array into batches, map each batch to its values array (same
mapping logic currently using sql`(...)`), call db.execute with sql.join(values,
sql`, `) per batch, and await each execution; keep the early return when
rows.length is zero and reuse the same column list and ON CONFLICT update block
for each chunk to preserve behavior.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts`:
- Around line 518-530: The code only checks that details.shipping_method_code
exists but does not ensure it matches the parsed snapshot, so
parseSnapshot(details.shipping_address) may contain a different methodCode;
update the guard to parse the snapshot first (parseSnapshot), compare
parsedSnapshot.methodCode (or equivalent property) to
details.shipping_method_code, and if they differ throw a failure (e.g.,
SHIPPING_METHOD_MISMATCH) before calling toNpPayload(order: details, snapshot:
parsedSnapshot) so the NP payload is built only from consistent data.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 047f705 and 313ff9b.

📒 Files selected for processing (19)
  • frontend/.env.example
  • frontend/app/[locale]/admin/shop/orders/[id]/page.tsx
  • frontend/app/[locale]/shop/cart/CartPageClient.tsx
  • frontend/app/[locale]/shop/orders/[id]/page.tsx
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/db/schema/shop.ts
  • frontend/drizzle/0019_p2_shop_invariants.sql
  • frontend/drizzle/meta/0019_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/services/orders/_shared.ts
  • frontend/lib/services/orders/monobank-janitor.ts
  • frontend/lib/services/orders/monobank-webhook.ts
  • frontend/lib/services/shop/shipping/checkout-payload.ts
  • frontend/lib/services/shop/shipping/nova-poshta-catalog.ts
  • frontend/lib/services/shop/shipping/nova-poshta-client.ts
  • frontend/lib/services/shop/shipping/shipments-worker.ts
  • frontend/messages/en.json
  • frontend/messages/pl.json
  • frontend/messages/uk.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/db/schema/shop.ts
  • frontend/lib/services/shop/shipping/checkout-payload.ts

Comment on lines 82 to 84
NP_API_KEY=
# Optional if code has a default; set explicitly in production for clarity
NP_API_BASE=
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Resolve dotenv key ordering warning in the NP section.

dotenv-linter warns that NP_API_BASE should be declared before NP_API_KEY (Line 84 vs Line 82). Reordering will clear the warning.

🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 84-84: [UnorderedKey] The NP_API_BASE key should go before the NP_API_KEY key

(UnorderedKey)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/.env.example` around lines 82 - 84, The dotenv-linter warning is due
to ordering of the NP keys; move the NP_API_BASE declaration so it appears
before NP_API_KEY in frontend/.env.example (ensure the comment about
optional/defaults remains with NP_API_BASE if needed) so the two entries read
NP_API_BASE then NP_API_KEY; no other changes to values or comments are
required.

Comment on lines 293 to 299
const available = data?.available === true;
const reasonCode = (data?.reasonCode ??
null) as ShippingAvailabilityReasonCode | null;
const methods = Array.isArray(data?.methods)
? (data.methods as ShippingMethod[])
: [];

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fail closed on invalid /shipping/methods success payloads.

If the endpoint returns HTTP 200 but malformed/partial JSON, available becomes false and reasonCode stays null. That leaves shippingUnavailableHardBlock false and can re-enable checkout without a valid shipping decision path.

🛠️ Suggested fix
-        const available = data?.available === true;
-        const reasonCode = (data?.reasonCode ??
-          null) as ShippingAvailabilityReasonCode | null;
-        const methods = Array.isArray(data?.methods)
-          ? (data.methods as ShippingMethod[])
-          : [];
+        if (!data || typeof data !== 'object' || typeof data.available !== 'boolean') {
+          if (!cancelled) {
+            setShippingAvailable(false);
+            setShippingReasonCode('INTERNAL_ERROR');
+            setShippingMethods([]);
+          }
+          return;
+        }
+
+        const available = data.available;
+        const reasonCode =
+          typeof data.reasonCode === 'string'
+            ? (data.reasonCode as ShippingAvailabilityReasonCode)
+            : null;
+        const methods = Array.isArray(data.methods)
+          ? (data.methods as ShippingMethod[])
+          : [];
+
+        if (!available && !reasonCode) {
+          if (!cancelled) {
+            setShippingAvailable(false);
+            setShippingReasonCode('INTERNAL_ERROR');
+            setShippingMethods([]);
+          }
+          return;
+        }

Also applies to: 803-808

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx around lines 293 - 299,
The current parsing of the /shipping/methods response may silently treat
malformed HTTP 200 payloads as available=false but leave
shippingUnavailableHardBlock false; update the validation in CartPageClient
(variables available, reasonCode, methods) to fail closed: explicitly verify
types for data.available (boolean), data.reasonCode (matches
ShippingAvailabilityReasonCode) and data.methods (Array of ShippingMethod), and
if any check fails set available = false and set a hard block flag
(shippingUnavailableHardBlock = true) and populate reasonCode to a safe failure
code (or non-null sentinel) so checkout cannot be re-enabled on malformed
success responses; locate and modify the parsing logic around available,
reasonCode and methods to add these guards.

Comment on lines 421 to 427
const shouldAttemptEnqueue =
args.paymentBecamePaidInThisApply &&
args.shippingRequired === true &&
args.shippingProvider === 'nova_poshta' &&
Boolean(args.shippingMethodCode) &&
isInventoryCommittedForShipping(args.inventoryStatus);

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Derive enqueue eligibility from DB state, not pre-read fields.

eligible_for_enqueue is hard-gated by an app-computed boolean built from pre-read order fields. If those fields become eligible between read and SQL execution, enqueue is skipped even though updated_order reflects eligible state. This can leave a paid order without a queued shipment.

Suggested fix
-  const shouldAttemptEnqueue =
-    args.paymentBecamePaidInThisApply &&
-    args.shippingRequired === true &&
-    args.shippingProvider === 'nova_poshta' &&
-    Boolean(args.shippingMethodCode) &&
-    isInventoryCommittedForShipping(args.inventoryStatus);
+  const shouldAttemptEnqueue = args.paymentBecamePaidInThisApply;

@@
     eligible_for_enqueue as (
       select id
       from updated_order
       where ${shouldAttemptEnqueue}
         and payment_status = 'paid'
         and shipping_required = true
         and shipping_provider = 'nova_poshta'
         and shipping_method_code is not null
         and ${inventoryCommittedForShippingSql(
           sql`updated_order.inventory_status`
         )}

Also applies to: 454-465

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 421 - 427, The
enqueue eligibility is currently decided using pre-read booleans (e.g.,
shouldAttemptEnqueue / args.paymentBecamePaidInThisApply and
args.shippingRequired/shippingProvider/shippingMethodCode) which can be stale;
instead compute eligible_for_enqueue from the DB-returned updated_order state
(e.g., use updated_order.payment_status / updated_order.shipping_required /
updated_order.shipping_provider / updated_order.shipping_method_code and call
isInventoryCommittedForShipping(updated_order.inventory_status)) at the point
just before generating the SQL/queue action so the decision reflects the
persisted state; apply the same change to the second occurrence referenced
around the block at 454-465 so both enqueue checks derive eligibility from
updated_order rather than args.

Comment on lines 534 to 550
await markSucceeded({
shipmentId: args.claim.id,
runId: args.runId,
providerRef: created.providerRef,
trackingNumber: created.trackingNumber,
});

recordShippingMetric({
name: 'succeeded',
source: 'shipments_worker',
runId: args.runId,
orderId: args.claim.order_id,
shipmentId: args.claim.id,
});

return 'succeeded';
} catch (error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle lease-loss CAS misses before reporting success.

markSucceeded() can return null when the lease was lost (lease_owner mismatch), but the code still records "succeeded" and returns success. That hides contention/race outcomes and can misreport run stats.

🐛 Proposed fix
-    await markSucceeded({
+    const marked = await markSucceeded({
       shipmentId: args.claim.id,
       runId: args.runId,
       providerRef: created.providerRef,
       trackingNumber: created.trackingNumber,
     });
+
+    if (!marked) {
+      logWarn('shipping_shipments_worker_lease_lost', {
+        runId: args.runId,
+        shipmentId: args.claim.id,
+        orderId: args.claim.order_id,
+        code: 'SHIPMENT_LEASE_LOST',
+      });
+      return 'retried';
+    }
 
     recordShippingMetric({
       name: 'succeeded',
📝 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
await markSucceeded({
shipmentId: args.claim.id,
runId: args.runId,
providerRef: created.providerRef,
trackingNumber: created.trackingNumber,
});
recordShippingMetric({
name: 'succeeded',
source: 'shipments_worker',
runId: args.runId,
orderId: args.claim.order_id,
shipmentId: args.claim.id,
});
return 'succeeded';
} catch (error) {
const marked = await markSucceeded({
shipmentId: args.claim.id,
runId: args.runId,
providerRef: created.providerRef,
trackingNumber: created.trackingNumber,
});
if (!marked) {
logWarn('shipping_shipments_worker_lease_lost', {
runId: args.runId,
shipmentId: args.claim.id,
orderId: args.claim.order_id,
code: 'SHIPMENT_LEASE_LOST',
});
return 'retried';
}
recordShippingMetric({
name: 'succeeded',
source: 'shipments_worker',
runId: args.runId,
orderId: args.claim.order_id,
shipmentId: args.claim.id,
});
return 'succeeded';
} catch (error) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts` around lines 534 -
550, markSucceeded() can return null when the lease was lost; update the try
block to check the result of markSucceeded (the call to markSucceeded with
shipmentId: args.claim.id, runId: args.runId, providerRef: created.providerRef,
trackingNumber: created.trackingNumber) and if it returns null treat this as a
lease-loss outcome: do not call recordShippingMetric(...) with name:'succeeded'
nor return 'succeeded'—instead record/return an appropriate lease-loss status
(or throw) so the run is not misreported; place this conditional immediately
after the await markSucceeded(...) and before the recordShippingMetric and
return statements.

@ViktorSvertoka ViktorSvertoka merged commit 2c77e2a into develop Feb 25, 2026
11 checks passed
@ViktorSvertoka ViktorSvertoka deleted the lso/feat/shop branch February 25, 2026 06:41
@coderabbitai coderabbitai bot mentioned this pull request Feb 26, 2026
ViktorSvertoka added a commit that referenced this pull request Feb 26, 2026
* (SP: 3) [Backend] add Nova Poshta shipping foundation + checkout persistence + async label workflow (#364)

* (SP: 2) [Frontend] Reduce Vercel variable costs via caching and analytics cleanup (#367)

* perf(vercel): cut runtime costs via notification, blog cache, and analytics changes

* perf(blog): remove server searchParams usage to preserve ISR

* fix(build): align Netlify Node version and remove SpeedInsights import

* chore(release): bump version to 1.0.4

---------

Co-authored-by: Liudmyla Sovetovs <milkaegik@gmail.com>
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