Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 62 additions & 26 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
# --- Core / Environment
APP_ADDITIONAL_ORIGINS=https://admin.example.test
# Main runtime environment label (for example: local, develop, production).
APP_ENV=

# Canonical application origin / base URL.
APP_ORIGIN=https://example.test
APP_URL=
NEXT_PUBLIC_SITE_URL=

# Additional trusted browser origins, comma-separated if multiple.
APP_ADDITIONAL_ORIGINS=https://admin.example.test

# --- Database
# Primary database URL used by the app runtime.
DATABASE_URL=

# Local-only database URL used for strict local development/testing flows.
DATABASE_URL_LOCAL=

# --- Upstash Redis (REST)
Expand Down Expand Up @@ -46,40 +54,63 @@ CLOUDINARY_UPLOAD_FOLDER=
CLOUDINARY_URL=

# --- Payments (Stripe)
# Public Stripe key for browser checkout flows.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# Options: test, live (defaults to test in development, live in production)

# Allowed values: test, live.
# In local/development, test mode is expected.
# In production-like runtime, invalid or placeholder Stripe config must fail closed.
STRIPE_MODE=

# Toggle Stripe payments for Shop checkout flows.
STRIPE_PAYMENTS_ENABLED=

# Required when PAYMENTS_ENABLED enables Shop payments and Stripe is used as an active payment rail.
# In production-like runtime, invalid or placeholder config must fail closed.
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

# --- Payments (Monobank)
# Optional; set explicitly in production for clarity
# Optional API base override. Leave empty to use code defaults unless a custom base is required.
MONO_API_BASE=

# Optional invoice timeout override in milliseconds.
# Default fallback: 8000 in production, 12000 outside production.
MONO_INVOICE_TIMEOUT_MS=

# Required for Monobank checkout/webhooks
# Required when PAYMENTS_ENABLED enables Shop payments and Monobank is used as an active payment rail.
# In production-like runtime, invalid or placeholder config must fail closed.
MONO_MERCHANT_TOKEN=
MONO_PUBLIC_KEY=

# Optional webhook/runtime tuning (defaults in code if omitted)
# Optional Monobank webhook/runtime tuning.
MONO_REFUND_ENABLED=0
MONO_WEBHOOK_CLAIM_TTL_MS=
MONO_WEBHOOK_MODE=

# Global payments toggle for Shop checkout flows.
PAYMENTS_ENABLED=

# --- Shipping (Nova Poshta)
# Toggles (optional; defaults are handled in code)
# Shipping feature toggles.
SHOP_SHIPPING_ENABLED=0
SHOP_SHIPPING_NP_ENABLED=0

# Retention (optional; days, used for cleanup/retention policies)
# Optional retention in days for cleanup / retention jobs.
SHOP_SHIPPING_RETENTION_DAYS=

# 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.
# Optional if code has a default; set explicitly in production for clarity
# Authoritative flat shipping prices used by Shop checkout and shipping-method resolution.
# Values are stored in minor units for UAH.
# Example: 500 = 5.00 UAH
# Required whenever Nova Poshta shipping methods are enabled.
# Missing or invalid values must fail closed.
SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR=
SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR=
SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR=

# Required Nova Poshta provider config when shipping is enabled
# (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1).
# In production-like runtime, invalid or placeholder config must fail closed.
NP_API_BASE=
NP_API_KEY=
NP_SENDER_WAREHOUSE_REF=
Expand All @@ -89,7 +120,7 @@ NP_SENDER_NAME=
NP_SENDER_PHONE=
NP_SENDER_REF=

# Optional tuning (override only if needed; otherwise code defaults apply)
# Optional Nova Poshta runtime tuning.
NP_MAX_RETRIES=
NP_RETRY_DELAY_MS=
NP_TIMEOUT_MS=
Expand All @@ -100,18 +131,18 @@ INTERNAL_JANITOR_MIN_INTERVAL_SECONDS=
INTERNAL_JANITOR_SECRET=
JANITOR_URL=

# Optional internal/admin runtime secrets & tuning (used by internal endpoints/jobs)
# Optional internal/admin runtime secrets & tuning.
INTERNAL_SECRET=
JANITOR_TIMEOUT_MS=

# Optional instance IDs for webhook multi-instance diagnostics/claiming
# Optional instance IDs for webhook multi-instance diagnostics / claiming.
STRIPE_WEBHOOK_INSTANCE_ID=
WEBHOOK_INSTANCE_ID=

# --- Quiz
QUIZ_ENCRYPTION_KEY=

# --- Web3Forms (feedback form)
# --- Web3Forms / Sponsors / Feedback
GITHUB_SPONSORS_TOKEN=
NEXT_PUBLIC_WEB3FORMS_KEY=

Expand All @@ -125,44 +156,49 @@ GMAIL_APP_PASSWORD=
GMAIL_USER=

# --- Shop / Internal
# Optional public/base URL used by shop services/links
# Optional absolute base URL used by Shop links/services.
# Set explicitly in production to avoid incorrect absolute URLs.
SHOP_BASE_URL=

# Policy/consent version labels used by Shop flows.
SHOP_PRIVACY_VERSION=privacy-v1
SHOP_TERMS_VERSION=terms-v1

# Required for signed shop status tokens (if status endpoint/token flow is enabled)
# Required for signed Shop status-token flows.
SHOP_STATUS_TOKEN_SECRET=

# --- Security
CSRF_SECRET=

# Checkout route rate limiting.
CHECKOUT_RATE_LIMIT_MAX=10
CHECKOUT_RATE_LIMIT_WINDOW_SECONDS=300

# Stripe webhook rate limit envs (applied per reason; reason-specific overrides generic).
# Stripe webhook rate-limit envs.
# Missing signature has its own envs with fallback to generic, then legacy invalid_sig.
STRIPE_WEBHOOK_MISSING_SIG_RL_MAX=30
STRIPE_WEBHOOK_MISSING_SIG_RL_WINDOW_SECONDS=60

# Generic Stripe webhook rate limit fallback (applies to missing_sig and invalid_sig).
# Generic Stripe webhook rate-limit fallback.
STRIPE_WEBHOOK_RL_MAX=30
STRIPE_WEBHOOK_RL_WINDOW_SECONDS=60

# Invalid signature envs (canonical for invalid_sig, legacy fallback for missing_sig).
# Invalid-signature envs.
STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30
STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60

# SECURITY: If true, trust Cloudflare's cf-connecting-ip header for rate limiting.
# Enable ONLY when traffic is fronted by Cloudflare (header is set by Cloudflare at the edge).
# Default: false (0). Keep 0 in untrusted environments to avoid IP spoofing.
# SECURITY:
# Trust Cloudflare's cf-connecting-ip header for rate limiting only when traffic is fronted by Cloudflare.
# Default: 0
TRUST_CF_CONNECTING_IP=0

# SECURITY: If true, trust x-real-ip / x-forwarded-for headers for rate limiting.
# Enable ONLY behind Cloudflare or a trusted reverse proxy that overwrites these headers.
# Default: false (empty/0/false).
# SECURITY:
# Trust x-real-ip / x-forwarded-for only behind Cloudflare or another trusted reverse proxy.
# Default: 0
TRUST_FORWARDED_HEADERS=0

# emergency switch
# Emergency switch for rate limiting.
RATE_LIMIT_DISABLED=0

# --- AI / External
GROQ_API_KEY=
44 changes: 43 additions & 1 deletion frontend/app/[locale]/admin/shop/orders/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { orderItems, orders } from '@/db/schema';
import { Link } from '@/i18n/routing';
import { getCurrentUser } from '@/lib/auth';
import { logError } from '@/lib/logging';
import {
type CanonicalFulfillmentStage,
deriveCanonicalFulfillmentStage,
latestReturnStatusSql,
latestShipmentStatusSql,
} from '@/lib/services/shop/fulfillment-stage';
import { type CurrencyCode, formatMoney } from '@/lib/shop/currency';
import { fromDbMoney } from '@/lib/shop/money';
import {
Expand Down Expand Up @@ -39,6 +45,7 @@ type OrderDetail = {
paymentStatus: OrderPaymentStatus;
paymentProvider: OrderPaymentProvider;
paymentIntentId: string | null;
fulfillmentStage: CanonicalFulfillmentStage;
shippingStatus: string | null;
trackingNumber: string | null;
stockRestored: boolean;
Expand Down Expand Up @@ -76,6 +83,10 @@ function safeFormatDateTime(iso: string, dtf: Intl.DateTimeFormat): string {
return dtf.format(d);
}

function fulfillmentStageLabelKey(stage: CanonicalFulfillmentStage): string {
return `fulfillmentStages.${stage}`;
}

function toOrderItem(
item: {
id: string | null;
Expand Down Expand Up @@ -150,7 +161,10 @@ export default async function OrderDetailPage({
paymentStatus: orders.paymentStatus,
paymentProvider: orders.paymentProvider,
paymentIntentId: orders.paymentIntentId,
orderStatus: orders.status,
shippingStatus: orders.shippingStatus,
shipmentStatus: latestShipmentStatusSql(orders.id),
returnStatus: latestReturnStatusSql(orders.id),
trackingNumber: orders.trackingNumber,
stockRestored: orders.stockRestored,
restockedAt: orders.restockedAt,
Expand Down Expand Up @@ -183,13 +197,32 @@ export default async function OrderDetailPage({

try {
const base = rows[0]!.order;
const fulfillmentStage = deriveCanonicalFulfillmentStage({
orderStatus: base.orderStatus,
shippingStatus: base.shippingStatus,
shipmentStatus:
typeof base.shipmentStatus === 'string' ? base.shipmentStatus : null,
returnStatus:
typeof base.returnStatus === 'string' ? base.returnStatus : null,
});

const items = rows
.map(r => toOrderItem(r.item))
.filter((i): i is NonNullable<typeof i> => i !== null);

order = {
...base,
id: base.id,
userId: base.userId,
totalAmount: base.totalAmount,
currency: base.currency,
paymentStatus: base.paymentStatus,
paymentProvider: base.paymentProvider,
paymentIntentId: base.paymentIntentId,
fulfillmentStage,
shippingStatus: base.shippingStatus,
trackingNumber: base.trackingNumber,
stockRestored: base.stockRestored,
idempotencyKey: base.idempotencyKey,
createdAt: base.createdAt.toISOString(),
updatedAt: base.updatedAt.toISOString(),
restockedAt: base.restockedAt ? base.restockedAt.toISOString() : null,
Expand Down Expand Up @@ -276,6 +309,15 @@ export default async function OrderDetailPage({
</dd>
</div>

<div>
<dt className="text-muted-foreground text-xs">
{t('fulfillmentStage')}
</dt>
<dd className="text-sm font-medium">
{t(fulfillmentStageLabelKey(order.fulfillmentStage))}
</dd>
</div>

<div>
<dt className="text-muted-foreground text-xs">
{t('shippingStatus')}
Expand Down
44 changes: 43 additions & 1 deletion frontend/app/[locale]/shop/orders/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import { orderItems, orders } from '@/db/schema';
import { Link } from '@/i18n/routing';
import { getCurrentUser } from '@/lib/auth';
import { logError } from '@/lib/logging';
import {
type CanonicalFulfillmentStage,
deriveCanonicalFulfillmentStage,
latestReturnStatusSql,
latestShipmentStatusSql,
} from '@/lib/services/shop/fulfillment-stage';
import { type CurrencyCode, formatMoney } from '@/lib/shop/currency';
import { fromDbMoney } from '@/lib/shop/money';
import {
Expand Down Expand Up @@ -40,6 +46,7 @@ type OrderDetail = {
paymentStatus: OrderPaymentStatus;
paymentProvider: OrderPaymentProvider;
paymentIntentId: string | null;
fulfillmentStage: CanonicalFulfillmentStage;
shippingStatus: string | null;
trackingNumber: string | null;
stockRestored: boolean;
Expand Down Expand Up @@ -77,6 +84,10 @@ function safeFormatDateTime(iso: string, dtf: Intl.DateTimeFormat): string {
return dtf.format(d);
}

function fulfillmentStageLabelKey(stage: CanonicalFulfillmentStage): string {
return `fulfillmentStages.${stage}`;
}

function toOrderItem(
item: {
id: string | null;
Expand Down Expand Up @@ -153,7 +164,10 @@ export default async function OrderDetailPage({
paymentStatus: orders.paymentStatus,
paymentProvider: orders.paymentProvider,
paymentIntentId: orders.paymentIntentId,
orderStatus: orders.status,
shippingStatus: orders.shippingStatus,
shipmentStatus: latestShipmentStatusSql(orders.id),
returnStatus: latestReturnStatusSql(orders.id),
trackingNumber: orders.trackingNumber,
stockRestored: orders.stockRestored,
restockedAt: orders.restockedAt,
Expand Down Expand Up @@ -189,13 +203,32 @@ export default async function OrderDetailPage({

try {
const base = rows[0]!.order;
const fulfillmentStage = deriveCanonicalFulfillmentStage({
orderStatus: base.orderStatus,
shippingStatus: base.shippingStatus,
shipmentStatus:
typeof base.shipmentStatus === 'string' ? base.shipmentStatus : null,
returnStatus:
typeof base.returnStatus === 'string' ? base.returnStatus : null,
});

const items = rows
.map(r => toOrderItem(r.item))
.filter((i): i is NonNullable<typeof i> => i !== null);

order = {
...base,
id: base.id,
userId: base.userId,
totalAmount: base.totalAmount,
currency: base.currency,
paymentStatus: base.paymentStatus,
paymentProvider: base.paymentProvider,
paymentIntentId: base.paymentIntentId,
fulfillmentStage,
shippingStatus: base.shippingStatus,
trackingNumber: base.trackingNumber,
stockRestored: base.stockRestored,
idempotencyKey: base.idempotencyKey,
createdAt: base.createdAt.toISOString(),
updatedAt: base.updatedAt.toISOString(),
restockedAt: base.restockedAt ? base.restockedAt.toISOString() : null,
Expand Down Expand Up @@ -282,6 +315,15 @@ export default async function OrderDetailPage({
</dd>
</div>

<div>
<dt className="text-muted-foreground text-xs">
{t('fulfillmentStage')}
</dt>
<dd className="text-sm font-medium">
{t(fulfillmentStageLabelKey(order.fulfillmentStage))}
</dd>
</div>

<div>
<dt className="text-muted-foreground text-xs">
{t('shippingStatus')}
Expand Down
7 changes: 6 additions & 1 deletion frontend/app/api/shop/admin/orders/[id]/shipping/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ export const runtime = 'nodejs';

const payloadSchema = z
.object({
action: z.enum(['retry_label_creation', 'mark_shipped', 'mark_delivered']),
action: z.enum([
'recover_initial_shipment',
'retry_label_creation',
'mark_shipped',
'mark_delivered',
]),
})
.strict();

Expand Down
Loading
Loading