diff --git a/.github/workflows/shop-janitor-restock-stale.yml b/.github/workflows/shop-janitor-restock-stale.yml index c304db38..b15a321e 100644 --- a/.github/workflows/shop-janitor-restock-stale.yml +++ b/.github/workflows/shop-janitor-restock-stale.yml @@ -2,7 +2,7 @@ name: Shop janitor - restock stale orders on: schedule: - - cron: "*/5 * * * *" + - cron: "*/30 * * * *" workflow_dispatch: {} concurrency: diff --git a/frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx b/frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx index 6eba5a37..2d67d20d 100644 --- a/frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx +++ b/frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx @@ -69,9 +69,9 @@ const UI_STATE_TO_PAYMENT_STATUS_KEY = { const STATUS_TOKEN_KEY_PREFIX = 'shop:order-status-token:'; const POLL_MAX_ATTEMPTS = 10; const POLL_MAX_DURATION_MS = 2 * 60 * 1000; -const POLL_BASE_DELAY_MS = 1_500; -const POLL_MAX_DELAY_MS = 12_000; -const POLL_BUSY_RETRY_DELAY_MS = 250; +const POLL_BASE_DELAY_MS = 3_000; +const POLL_MAX_DELAY_MS = 15_000; +const POLL_BUSY_RETRY_DELAY_MS = 1_000; const POLL_STOP_ERROR_CODES = new Set([ 'STATUS_TOKEN_REQUIRED', 'STATUS_TOKEN_INVALID', @@ -132,6 +132,27 @@ function normalizeToken(value: string | null | undefined): string | null { function parseOrderStatusPayload(payload: unknown): OrderStatusModel | null { if (!payload || typeof payload !== 'object') return null; const root = payload as Record; + + if ( + typeof root.id === 'string' && + root.id.trim() && + root.currency === 'UAH' && + typeof root.totalAmountMinor === 'number' && + Number.isFinite(root.totalAmountMinor) && + typeof root.paymentStatus === 'string' && + root.paymentStatus.trim() && + typeof root.itemsCount === 'number' && + Number.isFinite(root.itemsCount) + ) { + return { + id: root.id, + currency: root.currency, + totalAmountMinor: root.totalAmountMinor, + paymentStatus: root.paymentStatus, + itemsCount: root.itemsCount, + }; + } + if (root.success !== true) return null; const orderRaw = root.order; @@ -181,6 +202,7 @@ async function fetchOrderStatus(args: { }): Promise { try { const qp = new URLSearchParams(); + qp.set('view', 'lite'); if (args.statusToken) { qp.set('statusToken', args.statusToken); } diff --git a/frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx b/frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx index 70d4c806..ba68ef08 100644 --- a/frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx +++ b/frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx @@ -5,38 +5,184 @@ import { useEffect, useRef } from 'react'; type Props = { paymentStatus: string; - maxMs?: number; - intervalMs?: number; }; -function isTerminal(status: string) { - return status === 'paid' || status === 'failed' || status === 'refunded'; +const MAX_ATTEMPTS = 8; +const MAX_DURATION_MS = 2 * 60 * 1000; +const BASE_DELAY_MS = 2_000; +const MAX_DELAY_MS = 15_000; +const JITTER_RATIO = 0.2; +const TERMINAL_STATUSES = new Set([ + 'paid', + 'failed', + 'refunded', + 'needs_review', +]); + +type StatusFetchResult = + | { ok: true; paymentStatus: string } + | { ok: false; status: number; code: string }; + +function normalizeQueryValue(value: string | null): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function isTerminal(status: string): boolean { + return TERMINAL_STATUSES.has(status); +} + +function shouldStopOnError(status: number, code: string): boolean { + if (status === 401 || status === 403) return true; + if (status !== 400) return false; + const normalized = code.trim().toUpperCase(); + return ( + normalized === 'STATUS_TOKEN_INVALID' || + normalized === 'INVALID_STATUS_TOKEN' || + normalized.endsWith('TOKEN_INVALID') + ); +} + +function getBackoffDelayMs(attempt: number): number { + return Math.min(BASE_DELAY_MS * 2 ** Math.max(attempt - 1, 0), MAX_DELAY_MS); +} + +function withJitter(delayMs: number): number { + const jitterMultiplier = 1 + (Math.random() * 2 - 1) * JITTER_RATIO; + return Math.max(0, Math.floor(delayMs * jitterMultiplier)); +} + +function getErrorCode(payload: unknown): string { + if (!payload || typeof payload !== 'object') return 'INTERNAL_ERROR'; + const code = (payload as Record).code; + if (typeof code !== 'string') return 'INTERNAL_ERROR'; + const trimmed = code.trim(); + return trimmed.length ? trimmed : 'INTERNAL_ERROR'; +} + +function parseLitePaymentStatus(payload: unknown): string | null { + if (!payload || typeof payload !== 'object') return null; + const root = payload as Record; + const paymentStatus = root.paymentStatus; + if (typeof paymentStatus !== 'string') return null; + const trimmed = paymentStatus.trim(); + return trimmed.length ? trimmed : null; +} + +async function fetchLiteOrderStatus(args: { + orderId: string; + tokenKey: string | null; + tokenValue: string | null; + signal: AbortSignal; +}): Promise { + const qp = new URLSearchParams(); + qp.set('view', 'lite'); + if (args.tokenKey && args.tokenValue) qp.set(args.tokenKey, args.tokenValue); + + const endpoint = `/api/shop/orders/${encodeURIComponent(args.orderId)}/status?${qp.toString()}`; + + const res = await fetch(endpoint, { + method: 'GET', + cache: 'no-store', + headers: { 'Cache-Control': 'no-store' }, + credentials: 'same-origin', + signal: args.signal, + }); + + const body = await res.json().catch(() => ({})); + if (!res.ok) { + return { ok: false, status: res.status, code: getErrorCode(body) }; + } + + const paymentStatus = parseLitePaymentStatus(body); + if (!paymentStatus) { + return { ok: false, status: 500, code: 'INVALID_STATUS_RESPONSE' }; + } + + return { ok: true, paymentStatus }; } -export default function OrderStatusAutoRefresh({ - paymentStatus, - maxMs = 30_000, - intervalMs = 1_500, -}: Props) { +export default function OrderStatusAutoRefresh({ paymentStatus }: Props) { const router = useRouter(); - const startedAtRef = useRef(null); + const didTerminalRefreshRef = useRef(false); useEffect(() => { if (isTerminal(paymentStatus)) return; - if (startedAtRef.current == null) startedAtRef.current = Date.now(); + let cancelled = false; + let timeoutId: number | null = null; + let activeController: AbortController | null = null; + const startedAtMs = Date.now(); + let attempts = 0; + + const params = new URLSearchParams(window.location.search); + const orderId = normalizeQueryValue(params.get('orderId')); + if (!orderId) return; + + const tokenKey = params.has('statusToken') ? 'statusToken' : null; + const tokenValue = + tokenKey === null ? null : normalizeQueryValue(params.get(tokenKey)); - const id = window.setInterval(() => { - const startedAt = startedAtRef.current ?? Date.now(); - if (Date.now() - startedAt > maxMs) { - window.clearInterval(id); - return; + const wait = async (delayMs: number) => + new Promise(resolve => { + timeoutId = window.setTimeout(resolve, delayMs); + }); + + const run = async () => { + while (!cancelled) { + if (attempts >= MAX_ATTEMPTS) return; + if (Date.now() - startedAtMs >= MAX_DURATION_MS) return; + + attempts += 1; + const controller = new AbortController(); + activeController = controller; + const result = await fetchLiteOrderStatus({ + orderId, + tokenKey, + tokenValue, + signal: controller.signal, + }).catch( + (): StatusFetchResult => ({ + ok: false, + status: 500, + code: 'INTERNAL_ERROR', + }) + ); + + if (cancelled) { + return; + } + activeController = null; + + if (result.ok) { + if (isTerminal(result.paymentStatus)) { + if (!didTerminalRefreshRef.current) { + didTerminalRefreshRef.current = true; + router.refresh(); + } + return; + } + } else if (shouldStopOnError(result.status, result.code)) { + return; + } + + if (attempts >= MAX_ATTEMPTS) return; + if (Date.now() - startedAtMs >= MAX_DURATION_MS) return; + + const delayMs = withJitter(getBackoffDelayMs(attempts)); + await wait(delayMs); } - router.refresh(); - }, intervalMs); + }; + + void run(); - return () => window.clearInterval(id); - }, [paymentStatus, router, maxMs, intervalMs]); + return () => { + cancelled = true; + activeController?.abort(); + if (timeoutId !== null) window.clearTimeout(timeoutId); + }; + }, [paymentStatus, router]); return ; } diff --git a/frontend/app/api/sessions/activity/route.ts b/frontend/app/api/sessions/activity/route.ts index ee2077ae..354204e2 100644 --- a/frontend/app/api/sessions/activity/route.ts +++ b/frontend/app/api/sessions/activity/route.ts @@ -8,6 +8,15 @@ import { activeSessions } from '@/db/schema/sessions'; const SESSION_TIMEOUT_MINUTES = 15; +function getHeartbeatThrottleMs(): number { + const raw = process.env.HEARTBEAT_THROTTLE_MS; + const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; + const fallback = 60_000; + const floor = 1_000; + if (!Number.isFinite(parsed)) return fallback; + return Math.max(floor, parsed); +} + export async function POST() { try { const cookieStore = await cookies(); @@ -17,15 +26,21 @@ export async function POST() { sessionId = randomUUID(); } + const now = new Date(); + const heartbeatThreshold = new Date( + now.getTime() - getHeartbeatThrottleMs() + ); + await db .insert(activeSessions) .values({ sessionId, - lastActivity: new Date(), + lastActivity: now, }) .onConflictDoUpdate({ target: activeSessions.sessionId, - set: { lastActivity: new Date() }, + set: { lastActivity: now }, + setWhere: lt(activeSessions.lastActivity, heartbeatThreshold), }); if (Math.random() < 0.05) { @@ -44,7 +59,7 @@ export async function POST() { const result = await db .select({ - total: sql`count(distinct session_id)`, + total: sql`count(*)`, }) .from(activeSessions) .where(gte(activeSessions.lastActivity, countThreshold)); diff --git a/frontend/app/api/shop/internal/orders/restock-stale/route.ts b/frontend/app/api/shop/internal/orders/restock-stale/route.ts index 4f3220c5..d27106e1 100644 --- a/frontend/app/api/shop/internal/orders/restock-stale/route.ts +++ b/frontend/app/api/shop/internal/orders/restock-stale/route.ts @@ -194,14 +194,14 @@ function parseRequestedMinIntervalSeconds( function getEnvMinIntervalSeconds(): number { if (process.env.NODE_ENV === 'test') return 0; - const fallback = process.env.NODE_ENV === 'production' ? 300 : 60; + const fallback = process.env.NODE_ENV === 'production' ? 900 : 60; const n = toFiniteNumber(process.env.INTERNAL_JANITOR_MIN_INTERVAL_SECONDS); const v = n === null ? fallback : n; return clampInt(v, 0, MIN_INTERVAL_SECONDS_MAX); } -type GateRow = { next_allowed_at: unknown }; +type GateRow = { next_allowed_at: unknown; updated_at: unknown }; function normalizeDate(x: unknown): Date | null { if (!x) return null; @@ -230,14 +230,19 @@ async function acquireJobSlot(params: { last_run_id = ${runId}::uuid, updated_at = now() WHERE internal_job_state.next_allowed_at <= now() - RETURNING next_allowed_at + RETURNING next_allowed_at, updated_at `); const rows = (res as any).rows ?? []; - if (rows.length > 0) return { ok: true as const }; + if (rows.length > 0) { + return { + ok: true as const, + lastRunTs: normalizeDate(rows[0]?.updated_at), + }; + } const res2 = await db.execute(sql` - SELECT next_allowed_at + SELECT next_allowed_at, updated_at FROM internal_job_state WHERE job_name = ${jobName} LIMIT 1 @@ -245,8 +250,9 @@ async function acquireJobSlot(params: { const rows2 = (res2 as any).rows ?? []; const nextAllowedAt = normalizeDate(rows2[0]?.next_allowed_at); + const lastRunTs = normalizeDate(rows2[0]?.updated_at); - return { ok: false as const, nextAllowedAt }; + return { ok: false as const, nextAllowedAt, lastRunTs }; } export async function POST(request: NextRequest) { @@ -388,10 +394,12 @@ export async function POST(request: NextRequest) { const envMinIntervalSeconds = getEnvMinIntervalSeconds(); const requestedMinIntervalSeconds = requestedMinIntervalParsed; - const minIntervalSeconds = Math.max( + const effectiveIntervalSeconds = Math.max( envMinIntervalSeconds, requestedMinIntervalSeconds ); + // Alias kept for backward compatibility in API responses and logs. + const minIntervalSeconds = effectiveIntervalSeconds; const runId = crypto.randomUUID(); const jobName = baseMeta.jobName; @@ -399,9 +407,11 @@ export async function POST(request: NextRequest) { const gate = await acquireJobSlot({ jobName, - effectiveMinIntervalSeconds: minIntervalSeconds, + effectiveMinIntervalSeconds: effectiveIntervalSeconds, runId, }); + const nowTs = new Date().toISOString(); + const lastRunTs = gate.lastRunTs ? gate.lastRunTs.toISOString() : null; if (!gate.ok) { const retryAfterSeconds = gate.nextAllowedAt @@ -416,6 +426,10 @@ export async function POST(request: NextRequest) { runId, workerId, retryAfterSeconds, + effectiveIntervalSeconds, + gateDecision: 'skipped', + nowTs, + lastRunTs, minIntervalSeconds, }); @@ -479,6 +493,10 @@ export async function POST(request: NextRequest) { batchSize: policy.batchSize, appliedPolicy: policy, maxRuntimeMs, + effectiveIntervalSeconds, + gateDecision: 'ran', + nowTs, + lastRunTs, minIntervalSeconds, runtimeMs: Date.now() - startedAtMs, }); diff --git a/frontend/app/api/shop/internal/shipping/np/sync/route.ts b/frontend/app/api/shop/internal/shipping/np/sync/route.ts index ce55c65e..2623309e 100644 --- a/frontend/app/api/shop/internal/shipping/np/sync/route.ts +++ b/frontend/app/api/shop/internal/shipping/np/sync/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { requireInternalJanitorAuth } from '@/lib/auth/internal-janitor'; import { getShopShippingFlags } from '@/lib/env/nova-poshta'; -import { logError, logWarn } from '@/lib/logging'; +import { logError, logInfo, logWarn } from '@/lib/logging'; import { guardNonBrowserFailClosed } from '@/lib/security/origin'; import { sanitizeShippingErrorForLog, @@ -16,7 +16,10 @@ import { cacheSettlementsByQuery, cacheWarehousesBySettlement, } from '@/lib/services/shop/shipping/nova-poshta-catalog'; -import { internalNpSyncPayloadSchema } from '@/lib/validation/shop-shipping'; +import { + getInternalShippingMinIntervalFloorSeconds, + internalNpSyncPayloadSchema, +} from '@/lib/validation/shop-shipping'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -81,7 +84,8 @@ function retryAfterSeconds(nextAllowedAt: Date | null): number { } export async function POST(request: NextRequest) { - const requestId = request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); const runId = crypto.randomUUID(); const baseMeta = { requestId, @@ -158,9 +162,24 @@ export async function POST(request: NextRequest) { ); } + const requestedMinIntervalSeconds = parsed.data.minIntervalSeconds; + const effectiveIntervalSeconds = Math.max( + getInternalShippingMinIntervalFloorSeconds(), + requestedMinIntervalSeconds + ); + const wasClamped = effectiveIntervalSeconds !== requestedMinIntervalSeconds; + + logInfo('shop_shipping_job_interval_applied', { + ...baseMeta, + jobName: JOB_NAME, + requestedMinIntervalSeconds, + effectiveIntervalSeconds, + wasClamped, + }); + const gate = await acquireJobSlot({ runId, - minIntervalSeconds: parsed.data.minIntervalSeconds, + minIntervalSeconds: effectiveIntervalSeconds, }); if (!gate.ok) { diff --git a/frontend/app/api/shop/internal/shipping/retention/run/route.ts b/frontend/app/api/shop/internal/shipping/retention/run/route.ts index 8aefbceb..ec1031ba 100644 --- a/frontend/app/api/shop/internal/shipping/retention/run/route.ts +++ b/frontend/app/api/shop/internal/shipping/retention/run/route.ts @@ -6,14 +6,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { requireInternalJanitorAuth } from '@/lib/auth/internal-janitor'; import { getShopShippingFlags } from '@/lib/env/nova-poshta'; -import { logError, logWarn } from '@/lib/logging'; +import { logError, logInfo, logWarn } from '@/lib/logging'; import { guardNonBrowserFailClosed } from '@/lib/security/origin'; import { sanitizeShippingErrorForLog, sanitizeShippingLogMeta, } from '@/lib/services/shop/shipping/log-sanitizer'; import { anonymizeRetainedOrderShippingSnapshots } from '@/lib/services/shop/shipping/retention'; -import { internalShippingRetentionRunPayloadSchema } from '@/lib/validation/shop-shipping'; +import { + getInternalShippingMinIntervalFloorSeconds, + internalShippingRetentionRunPayloadSchema, +} from '@/lib/validation/shop-shipping'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -175,13 +178,29 @@ export async function POST(request: NextRequest) { ); } + const requestedMinIntervalSeconds = parsed.data.minIntervalSeconds; + const floorSeconds = getInternalShippingMinIntervalFloorSeconds(); + const effectiveIntervalSeconds = Math.max( + floorSeconds, + requestedMinIntervalSeconds + ); + const wasClamped = effectiveIntervalSeconds !== requestedMinIntervalSeconds; + + logInfo('shop_shipping_job_interval_applied', { + ...safeBaseMeta, + jobName: JOB_NAME, + requestedMinIntervalSeconds, + effectiveIntervalSeconds, + wasClamped, + }); + const bypass = isLocalBypassEnabled(); const gate = bypass ? ({ ok: true as const } as const) : await acquireJobSlot({ runId, - minIntervalSeconds: parsed.data.minIntervalSeconds, + minIntervalSeconds: effectiveIntervalSeconds, }); if (!gate.ok) { diff --git a/frontend/app/api/shop/internal/shipping/shipments/run/route.ts b/frontend/app/api/shop/internal/shipping/shipments/run/route.ts index 46a60b1a..417be95f 100644 --- a/frontend/app/api/shop/internal/shipping/shipments/run/route.ts +++ b/frontend/app/api/shop/internal/shipping/shipments/run/route.ts @@ -6,14 +6,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { requireInternalJanitorAuth } from '@/lib/auth/internal-janitor'; import { getNovaPoshtaConfig, getShopShippingFlags, NovaPoshtaConfigError } from '@/lib/env/nova-poshta'; -import { logError, logWarn } from '@/lib/logging'; +import { logError, logInfo, logWarn } from '@/lib/logging'; import { guardNonBrowserFailClosed } from '@/lib/security/origin'; import { sanitizeShippingErrorForLog, sanitizeShippingLogMeta, } from '@/lib/services/shop/shipping/log-sanitizer'; import { runShippingShipmentsWorker } from '@/lib/services/shop/shipping/shipments-worker'; -import { internalShippingShipmentsRunPayloadSchema } from '@/lib/validation/shop-shipping'; +import { + getInternalShippingMinIntervalFloorSeconds, + internalShippingShipmentsRunPayloadSchema, +} from '@/lib/validation/shop-shipping'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -161,9 +164,25 @@ export async function POST(request: NextRequest) { ); } + const requestedMinIntervalSeconds = parsed.data.minIntervalSeconds; + const floorSeconds = getInternalShippingMinIntervalFloorSeconds(); + const effectiveIntervalSeconds = Math.max( + floorSeconds, + requestedMinIntervalSeconds + ); + const wasClamped = effectiveIntervalSeconds !== requestedMinIntervalSeconds; + + logInfo('shop_shipping_job_interval_applied', { + ...baseMeta, + jobName: JOB_NAME, + requestedMinIntervalSeconds, + effectiveIntervalSeconds, + wasClamped, + }); + const gate = await acquireJobSlot({ runId, - minIntervalSeconds: parsed.data.minIntervalSeconds, + minIntervalSeconds: effectiveIntervalSeconds, }); if (!gate.ok) { const wait = retryAfterSeconds(gate.nextAllowedAt); diff --git a/frontend/app/api/shop/orders/[id]/status/route.ts b/frontend/app/api/shop/orders/[id]/status/route.ts index 606ee0e6..c8ea0f5e 100644 --- a/frontend/app/api/shop/orders/[id]/status/route.ts +++ b/frontend/app/api/shop/orders/[id]/status/route.ts @@ -8,13 +8,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { orders } from '@/db/schema'; import { getCurrentUser } from '@/lib/auth'; -import { logError, logWarn } from '@/lib/logging'; +import { logError, logInfo, logWarn } from '@/lib/logging'; import { OrderNotFoundError, OrderStateInvalidError, } from '@/lib/services/errors'; import { getOrderAttemptSummary, + getOrderStatusLiteSummary, getOrderSummary, } from '@/lib/services/orders/summary'; import { verifyStatusToken } from '@/lib/shop/status-token'; @@ -33,6 +34,8 @@ export async function GET( context: { params: Promise<{ id: string }> } ) { const startedAtMs = Date.now(); + const responseMode = + request.nextUrl.searchParams.get('view') === 'lite' ? 'lite' : 'full'; const requestId = request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); @@ -43,6 +46,7 @@ export async function GET( requestId, code: 'INVALID_ORDER_ID', orderId: null, + responseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code: 'INVALID_ORDER_ID' }, { status: 400 }); @@ -77,6 +81,7 @@ export async function GET( requestId, orderId, code, + responseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code }, { status }); @@ -95,6 +100,7 @@ export async function GET( requestId, orderId, code: 'STATUS_TOKEN_MISCONFIGURED', + responseMode, durationMs: Date.now() - startedAtMs, } ); @@ -108,14 +114,32 @@ export async function GET( requestId, orderId, code: 'STATUS_TOKEN_INVALID', + responseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code: 'STATUS_TOKEN_INVALID' }, { status: 403 }); } } + if (responseMode === 'lite') { + const liteOrder = await getOrderStatusLiteSummary(orderId); + logInfo('order_status_responded', { + requestId, + orderId, + responseMode, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson(liteOrder, { status: 200 }); + } + const order = await getOrderSummary(orderId); const attempt = await getOrderAttemptSummary(orderId); + logInfo('order_status_responded', { + requestId, + orderId, + responseMode, + durationMs: Date.now() - startedAtMs, + }); return noStoreJson({ success: true, order, attempt }, { status: 200 }); } catch (error) { if (error instanceof OrderNotFoundError) { @@ -123,6 +147,7 @@ export async function GET( requestId, code: 'ORDER_NOT_FOUND', orderId, + responseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code: 'ORDER_NOT_FOUND' }, { status: 404 }); @@ -133,6 +158,7 @@ export async function GET( requestId, code: 'INTERNAL_ERROR', orderId, + responseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code: 'INTERNAL_ERROR' }, { status: 500 }); @@ -142,6 +168,7 @@ export async function GET( requestId, code: 'ORDER_STATUS_FAILED', orderId, + responseMode, durationMs: Date.now() - startedAtMs, }); diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 9873b757..2ba7ae87 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -411,8 +411,6 @@ type StripePaidApplyArgs = { async function applyStripePaidAndQueueShipmentAtomic( args: StripePaidApplyArgs ): Promise<{ applied: boolean; shipmentQueued: boolean }> { - const shouldAttemptEnqueue = args.paymentBecamePaidInThisApply; - const res = await db.execute(sql` with updated_order as ( update orders @@ -439,17 +437,16 @@ async function applyStripePaidAndQueueShipmentAtomic( shipping_provider, shipping_method_code ), - 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` - )} + eligible_for_enqueue as ( + select o.id + from orders o + where o.id = ${args.orderId}::uuid + and o.payment_provider = 'stripe' + and o.payment_status = 'paid' + and o.shipping_required = true + and o.shipping_provider = 'nova_poshta' + and o.shipping_method_code is not null + and ${inventoryCommittedForShippingSql(sql`o.inventory_status`)} ), inserted_shipment as ( insert into shipping_shipments ( @@ -468,31 +465,45 @@ async function applyStripePaidAndQueueShipmentAtomic( ${args.now}, ${args.now} from eligible_for_enqueue - on conflict (order_id) do nothing + on conflict (order_id) do update + set status = 'queued', + updated_at = ${args.now} + where shipping_shipments.provider = 'nova_poshta' + and shipping_shipments.status is distinct from 'queued' returning order_id ), + queued_order_ids as ( + select order_id from inserted_shipment + union + select s.order_id + from shipping_shipments s + where s.order_id in (select id from eligible_for_enqueue) + and s.status = 'queued' + ), mark_queued as ( update orders set shipping_status = 'queued'::shipping_status, updated_at = ${args.now} - from inserted_shipment - where orders.id = inserted_shipment.order_id - returning orders.id + where id in (select order_id from queued_order_ids) + and shipping_status is distinct from 'queued'::shipping_status + returning id ) select (select count(*)::int from updated_order) as updated_count, (select count(*)::int from inserted_shipment) as inserted_shipment_count, + (select count(*)::int from queued_order_ids) as queued_shipment_count, (select count(*)::int from mark_queued) as mark_queued_count `); const row = readDbRows<{ updated_count?: number; inserted_shipment_count?: number; + queued_shipment_count?: number; }>(res)[0]; return { applied: Number(row?.updated_count ?? 0) > 0, - shipmentQueued: Number(row?.inserted_shipment_count ?? 0) > 0, + shipmentQueued: Number(row?.queued_shipment_count ?? 0) > 0, }; } @@ -1032,6 +1043,56 @@ export async function POST(request: NextRequest) { }); } + if ( + !applyResult.shipmentQueued && + order.shippingRequired === true && + order.shippingProvider === 'nova_poshta' && + Boolean(order.shippingMethodCode) && + order.inventoryStatus === 'reserved' + ) { + await db.execute(sql` + with ensured_shipment as ( + insert into shipping_shipments ( + order_id, + provider, + status, + attempt_count, + created_at, + updated_at + ) values ( + ${order.id}::uuid, + 'nova_poshta', + 'queued', + 0, + ${now}, + ${now} + ) + on conflict (order_id) do update + set status = 'queued', + updated_at = ${now} + where shipping_shipments.provider = 'nova_poshta' + and shipping_shipments.status is distinct from 'queued' + returning order_id + ), + existing_shipment as ( + select order_id + from shipping_shipments + where order_id = ${order.id}::uuid + and status = 'queued' + ), + shipment_order_ids as ( + select order_id from ensured_shipment + union + select order_id from existing_shipment + ) + update orders + set shipping_status = 'queued'::shipping_status, + updated_at = ${now} + where id in (select order_id from shipment_order_ids) + and shipping_status is distinct from 'queued'::shipping_status + `); + } + await markStripeAttemptFinal({ paymentIntentId, status: 'succeeded', diff --git a/frontend/components/shared/OnlineCounterPopup.tsx b/frontend/components/shared/OnlineCounterPopup.tsx index 253f9f03..ef0e2ca3 100644 --- a/frontend/components/shared/OnlineCounterPopup.tsx +++ b/frontend/components/shared/OnlineCounterPopup.tsx @@ -12,6 +12,9 @@ import { const SHOW_DURATION_MS = 10_000; const SESSION_KEY = 'onlineCounterShown'; +const ACTIVITY_THROTTLE_MS = 60_000; +const ACTIVITY_LAST_SENT_AT_KEY = 'onlineCounterActivityLastSentAt'; +const ACTIVITY_LAST_ONLINE_KEY = 'onlineCounterActivityLastOnline'; type OnlineCounterPopupProps = { ctaRef: React.RefObject; @@ -30,19 +33,67 @@ export function OnlineCounterPopup({ ctaRef }: OnlineCounterPopupProps) { return r.json(); }) .then(data => { - if (typeof data.online === 'number') setOnline(data.online); + if (typeof data.online === 'number') { + setOnline(data.online); + try { + sessionStorage.setItem( + ACTIVITY_LAST_ONLINE_KEY, + String(data.online) + ); + } catch { + // Best effort only. + } + } }) .catch(() => {}); }, []); useEffect(() => { - const alreadyShown = sessionStorage.getItem(SESSION_KEY); + let alreadyShown: string | null = null; + let shouldSendActivity = true; + const now = Date.now(); + + try { + const rawOnline = sessionStorage.getItem(ACTIVITY_LAST_ONLINE_KEY); + if (rawOnline !== null) { + const n = Number(rawOnline); + if (Number.isFinite(n)) { + const cachedOnline = n; + setTimeout(() => setOnline(cachedOnline), 0); + } + } + } catch { + // Best effort only. + } + + try { + alreadyShown = sessionStorage.getItem(SESSION_KEY); + + const lastSentAtRaw = sessionStorage.getItem(ACTIVITY_LAST_SENT_AT_KEY); + const lastSentAt = + lastSentAtRaw === null ? NaN : Number.parseInt(lastSentAtRaw, 10); + + if ( + Number.isFinite(lastSentAt) && + now - lastSentAt < ACTIVITY_THROTTLE_MS + ) { + shouldSendActivity = false; + } else { + sessionStorage.setItem(ACTIVITY_LAST_SENT_AT_KEY, String(now)); + } + } catch { + shouldSendActivity = true; + } - fetchActivity(); + if (shouldSendActivity) fetchActivity(); if (!alreadyShown) { const showTimer = setTimeout(() => setShow(true), 500); - sessionStorage.setItem(SESSION_KEY, '1'); + try { + sessionStorage.setItem(SESSION_KEY, '1'); + } catch { + // Best effort only. + } hideTimerRef.current = setTimeout( () => setShow(false), diff --git a/frontend/drizzle/0020_shop_orders_sweeps_partial_indexes.sql b/frontend/drizzle/0020_shop_orders_sweeps_partial_indexes.sql new file mode 100644 index 00000000..335cc11e --- /dev/null +++ b/frontend/drizzle/0020_shop_orders_sweeps_partial_indexes.sql @@ -0,0 +1,29 @@ +-- Minimal partial indexes for orders sweep selectors (plain CREATE INDEX). +-- NOTE: intentionally not using CONCURRENTLY in migration. +-- DEPLOYMENT PLAN: run during low-traffic / maintenance window because plain CREATE INDEX takes locks. +-- FAIL-FAST: set short lock_timeout so we don't block writes if the lock can't be acquired quickly. +-- If zero-downtime is required, CREATE INDEX CONCURRENTLY must be executed outside a transaction (separate ops/runbook). +SET LOCAL lock_timeout = '2s'; +SET LOCAL statement_timeout = '5min'; + +CREATE INDEX "orders_sweep_stripe_created_claim_id_idx" +ON "orders" ("created_at", "sweep_claim_expires_at", "id") +WHERE + "payment_provider" = 'stripe' + AND "payment_status" IN ('pending', 'requires_payment') + AND "stock_restored" = false + AND "restocked_at" IS NULL + AND "inventory_status" <> 'released'; +--> statement-breakpoint + +SET LOCAL lock_timeout = '2s'; +SET LOCAL statement_timeout = '5min'; + +CREATE INDEX "orders_sweep_none_created_claim_id_idx" +ON "orders" ("created_at", "sweep_claim_expires_at", "id") +WHERE + "payment_provider" = 'none' + AND "stock_restored" = false + AND "restocked_at" IS NULL + AND "inventory_status" IN ('none', 'reserving', 'release_pending'); +--> statement-breakpoint diff --git a/frontend/drizzle/meta/0020_snapshot.json b/frontend/drizzle/meta/0020_snapshot.json new file mode 100644 index 00000000..7b241cf0 --- /dev/null +++ b/frontend/drizzle/meta/0020_snapshot.json @@ -0,0 +1,4360 @@ +{ + "id": "ca7a31cd-6fd8-4d8b-b9b1-69eda1703b55", + "prevId": "24962298-d424-45c6-9188-a4a096ebb416", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_translations": { + "name": "category_translations", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "category_translations_category_id_categories_id_fk": { + "name": "category_translations_category_id_categories_id_fk", + "tableFrom": "category_translations", + "columnsFrom": [ + "category_id" + ], + "tableTo": "categories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "category_translations_category_id_locale_pk": { + "name": "category_translations_category_id_locale_pk", + "columns": [ + "category_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_tokens": { + "name": "email_verification_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_tokens_user_id_idx": { + "name": "email_verification_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.point_transactions": { + "name": "point_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'quiz'" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "point_transactions_user_id_idx": { + "name": "point_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_translations": { + "name": "question_translations", + "schema": "", + "columns": { + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer_blocks": { + "name": "answer_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "question_translations_question_id_questions_id_fk": { + "name": "question_translations_question_id_questions_id_fk", + "tableFrom": "question_translations", + "columnsFrom": [ + "question_id" + ], + "tableTo": "questions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "question_translations_question_id_locale_pk": { + "name": "question_translations_question_id_locale_pk", + "columns": [ + "question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "questions_category_sort_order_idx": { + "name": "questions_category_sort_order_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "columnsFrom": [ + "category_id" + ], + "tableTo": "categories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answer_translations": { + "name": "quiz_answer_translations", + "schema": "", + "columns": { + "quiz_answer_id": { + "name": "quiz_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "answer_text": { + "name": "answer_text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk": { + "name": "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_answer_translations", + "columnsFrom": [ + "quiz_answer_id" + ], + "tableTo": "quiz_answers", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "quiz_answer_translations_quiz_answer_id_locale_pk": { + "name": "quiz_answer_translations_quiz_answer_id_locale_pk", + "columns": [ + "quiz_answer_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answers": { + "name": "quiz_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "quiz_answers_question_display_order_idx": { + "name": "quiz_answers_question_display_order_idx", + "columns": [ + { + "expression": "quiz_question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "columnsFrom": [ + "quiz_question_id" + ], + "tableTo": "quiz_questions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempt_answers": { + "name": "quiz_attempt_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_answer_id": { + "name": "selected_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempt_answers_attempt_idx": { + "name": "quiz_attempt_answers_attempt_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "columnsFrom": [ + "attempt_id" + ], + "tableTo": "quiz_attempts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_attempt_answers", + "columnsFrom": [ + "quiz_question_id" + ], + "tableTo": "quiz_questions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk": { + "name": "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_attempt_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "tableTo": "quiz_answers", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempts": { + "name": "quiz_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_questions": { + "name": "total_questions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "time_spent_seconds": { + "name": "time_spent_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "integrity_score": { + "name": "integrity_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 100 + }, + "points_earned": { + "name": "points_earned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempts_user_id_idx": { + "name": "quiz_attempts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "quiz_attempts_user_completed_at_idx": { + "name": "quiz_attempts_user_completed_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "quiz_attempts_quiz_percentage_completed_at_idx": { + "name": "quiz_attempts_quiz_percentage_completed_at_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "percentage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "quiz_attempts_quiz_integrity_score_idx": { + "name": "quiz_attempts_quiz_integrity_score_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integrity_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "quiz_id" + ], + "tableTo": "quizzes", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_question_content": { + "name": "quiz_question_content", + "schema": "", + "columns": { + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question_text": { + "name": "question_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_question_content_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_question_content_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_question_content", + "columnsFrom": [ + "quiz_question_id" + ], + "tableTo": "quiz_questions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "quiz_question_content_quiz_question_id_locale_pk": { + "name": "quiz_question_content_quiz_question_id_locale_pk", + "columns": [ + "quiz_question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_questions": { + "name": "quiz_questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_question_id": { + "name": "source_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_questions_quiz_display_order_idx": { + "name": "quiz_questions_quiz_display_order_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "columnsFrom": [ + "quiz_id" + ], + "tableTo": "quizzes", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_translations": { + "name": "quiz_translations", + "schema": "", + "columns": { + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_translations_quiz_id_quizzes_id_fk": { + "name": "quiz_translations_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_translations", + "columnsFrom": [ + "quiz_id" + ], + "tableTo": "quizzes", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "quiz_translations_quiz_id_locale_pk": { + "name": "quiz_translations_quiz_id_locale_pk", + "columns": [ + "quiz_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quizzes": { + "name": "quizzes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "questions_count": { + "name": "questions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "time_limit_seconds": { + "name": "time_limit_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quizzes_slug_idx": { + "name": "quizzes_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "columnsFrom": [ + "category_id" + ], + "tableTo": "categories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "columns": [ + "category_id", + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.active_sessions": { + "name": "active_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "active_sessions_last_activity_idx": { + "name": "active_sessions_last_activity_idx", + "columns": [ + { + "expression": "last_activity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_rate_limits": { + "name": "api_rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_started_at": { + "name": "window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_rate_limits_updated_at_idx": { + "name": "api_rate_limits_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_rate_limits_count_non_negative": { + "name": "api_rate_limits_count_non_negative", + "value": "\"api_rate_limits\".\"count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.internal_job_state": { + "name": "internal_job_state", + "schema": "", + "columns": { + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "next_allowed_at": { + "name": "next_allowed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_moves": { + "name": "inventory_moves", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "move_key": { + "name": "move_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "inventory_move_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_moves_move_key_uq": { + "name": "inventory_moves_move_key_uq", + "columns": [ + { + "expression": "move_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "columnsFrom": [ + "order_id" + ], + "tableTo": "orders", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "columnsFrom": [ + "product_id" + ], + "tableTo": "products", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inventory_moves_quantity_gt_0": { + "name": "inventory_moves_quantity_gt_0", + "value": "\"inventory_moves\".\"quantity\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.monobank_events": { + "name": "monobank_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monobank'" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ccy": { + "name": "ccy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "normalized_payload": { + "name": "normalized_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_result": { + "name": "applied_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_error_code": { + "name": "applied_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_error_message": { + "name": "applied_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_sha256": { + "name": "raw_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_events_event_key_unique": { + "name": "monobank_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "columnsFrom": [ + "attempt_id" + ], + "tableTo": "payment_attempts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "columnsFrom": [ + "order_id" + ], + "tableTo": "orders", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_events_provider_check": { + "name": "monobank_events_provider_check", + "value": "\"monobank_events\".\"provider\" in ('monobank')" + } + }, + "isRLSEnabled": false + }, + "public.monobank_payment_cancels": { + "name": "monobank_payment_cancels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ext_ref": { + "name": "ext_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_response": { + "name": "psp_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_payment_cancels_ext_ref_unique": { + "name": "monobank_payment_cancels_ext_ref_unique", + "columns": [ + { + "expression": "ext_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "monobank_payment_cancels_order_id_idx": { + "name": "monobank_payment_cancels_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "monobank_payment_cancels_attempt_id_idx": { + "name": "monobank_payment_cancels_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "columnsFrom": [ + "order_id" + ], + "tableTo": "orders", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "columnsFrom": [ + "attempt_id" + ], + "tableTo": "payment_attempts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_payment_cancels_status_check": { + "name": "monobank_payment_cancels_status_check", + "value": "\"monobank_payment_cancels\".\"status\" in ('requested','processing','success','failure')" + } + }, + "isRLSEnabled": false + }, + "public.monobank_refunds": { + "name": "monobank_refunds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monobank'" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ext_ref": { + "name": "ext_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'UAH'" + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_refunds_ext_ref_unique": { + "name": "monobank_refunds_ext_ref_unique", + "columns": [ + { + "expression": "ext_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "columnsFrom": [ + "order_id" + ], + "tableTo": "orders", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "columnsFrom": [ + "attempt_id" + ], + "tableTo": "payment_attempts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_refunds_provider_check": { + "name": "monobank_refunds_provider_check", + "value": "\"monobank_refunds\".\"provider\" in ('monobank')" + }, + "monobank_refunds_status_check": { + "name": "monobank_refunds_status_check", + "value": "\"monobank_refunds\".\"status\" in ('requested','processing','success','failure','needs_review')" + }, + "monobank_refunds_amount_minor_non_negative": { + "name": "monobank_refunds_amount_minor_non_negative", + "value": "\"monobank_refunds\".\"amount_minor\" >= 0" + }, + "monobank_refunds_currency_uah": { + "name": "monobank_refunds_currency_uah", + "value": "\"monobank_refunds\".\"currency\" = 'UAH'" + } + }, + "isRLSEnabled": false + }, + "public.np_cities": { + "name": "np_cities", + "schema": "", + "columns": { + "ref": { + "name": "ref", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name_ua": { + "name": "name_ua", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_ru": { + "name": "name_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settlement_type": { + "name": "settlement_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_sync_run_id": { + "name": "last_sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "np_cities_active_name_idx": { + "name": "np_cities_active_name_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_ua", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "np_cities_last_sync_run_idx": { + "name": "np_cities_last_sync_run_idx", + "columns": [ + { + "expression": "last_sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "np_cities_active_name_prefix_idx": { + "name": "np_cities_active_name_prefix_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_ua", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.np_warehouses": { + "name": "np_warehouses", + "schema": "", + "columns": { + "ref": { + "name": "ref", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "city_ref": { + "name": "city_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settlement_ref": { + "name": "settlement_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_ru": { + "name": "name_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_ru": { + "name": "address_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_post_machine": { + "name": "is_post_machine", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_sync_run_id": { + "name": "last_sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "np_warehouses_settlement_active_idx": { + "name": "np_warehouses_settlement_active_idx", + "columns": [ + { + "expression": "settlement_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "np_warehouses_city_active_idx": { + "name": "np_warehouses_city_active_idx", + "columns": [ + { + "expression": "city_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "np_warehouses_active_name_idx": { + "name": "np_warehouses_active_name_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "np_warehouses_last_sync_run_idx": { + "name": "np_warehouses_last_sync_run_idx", + "columns": [ + { + "expression": "last_sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "np_warehouses_settlement_ref_np_cities_ref_fk": { + "name": "np_warehouses_settlement_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "columnsFrom": [ + "settlement_ref" + ], + "tableTo": "np_cities", + "columnsTo": [ + "ref" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_items": { + "name": "order_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_size": { + "name": "selected_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "selected_color": { + "name": "selected_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_total_minor": { + "name": "line_total_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price": { + "name": "unit_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "line_total": { + "name": "line_total", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "product_title": { + "name": "product_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_slug": { + "name": "product_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "order_items_order_id_idx": { + "name": "order_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "order_items_order_variant_uq": { + "name": "order_items_order_variant_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_size", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_color", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "columnsFrom": [ + "order_id" + ], + "tableTo": "orders", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "columnsFrom": [ + "product_id" + ], + "tableTo": "products", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_items_quantity_positive": { + "name": "order_items_quantity_positive", + "value": "\"order_items\".\"quantity\" > 0" + }, + "order_items_unit_price_minor_non_negative": { + "name": "order_items_unit_price_minor_non_negative", + "value": "\"order_items\".\"unit_price_minor\" >= 0" + }, + "order_items_line_total_minor_non_negative": { + "name": "order_items_line_total_minor_non_negative", + "value": "\"order_items\".\"line_total_minor\" >= 0" + }, + "order_items_line_total_consistent": { + "name": "order_items_line_total_consistent", + "value": "\"order_items\".\"line_total_minor\" = \"order_items\".\"unit_price_minor\" * \"order_items\".\"quantity\"" + }, + "order_items_unit_price_mirror_consistent": { + "name": "order_items_unit_price_mirror_consistent", + "value": "\"order_items\".\"unit_price\" = (\"order_items\".\"unit_price_minor\"::numeric / 100)" + }, + "order_items_line_total_mirror_consistent": { + "name": "order_items_line_total_mirror_consistent", + "value": "\"order_items\".\"line_total\" = (\"order_items\".\"line_total_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.order_shipping": { + "name": "order_shipping", + "schema": "", + "columns": { + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "shipping_address": { + "name": "shipping_address", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "order_shipping_updated_idx": { + "name": "order_shipping_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "columnsFrom": [ + "order_id" + ], + "tableTo": "orders", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_amount_minor": { + "name": "total_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "shipping_required": { + "name": "shipping_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "shipping_payer": { + "name": "shipping_payer", + "type": "shipping_payer", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_provider": { + "name": "shipping_provider", + "type": "shipping_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_method_code": { + "name": "shipping_method_code", + "type": "shipping_method_code", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_amount_minor": { + "name": "shipping_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_status": { + "name": "shipping_status", + "type": "shipping_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shipping_provider_ref": { + "name": "shipping_provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_charge_id": { + "name": "psp_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_payment_method": { + "name": "psp_payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_status_reason": { + "name": "psp_status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_metadata": { + "name": "psp_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "order_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CREATED'" + }, + "inventory_status": { + "name": "inventory_status", + "type": "inventory_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_request_hash": { + "name": "idempotency_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stock_restored": { + "name": "stock_restored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "restocked_at": { + "name": "restocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sweep_claimed_at": { + "name": "sweep_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_claim_expires_at": { + "name": "sweep_claim_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_run_id": { + "name": "sweep_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sweep_claimed_by": { + "name": "sweep_claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "orders_sweep_claim_expires_idx": { + "name": "orders_sweep_claim_expires_idx", + "columns": [ + { + "expression": "sweep_claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_orders_user_id_created_at": { + "name": "idx_orders_user_id_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "orders_shipping_status_idx": { + "name": "orders_shipping_status_idx", + "columns": [ + { + "expression": "shipping_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "columns": [ + "idempotency_key" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "orders_payment_provider_valid": { + "name": "orders_payment_provider_valid", + "value": "\"orders\".\"payment_provider\" in ('stripe', 'monobank', 'none')" + }, + "orders_total_amount_minor_non_negative": { + "name": "orders_total_amount_minor_non_negative", + "value": "\"orders\".\"total_amount_minor\" >= 0" + }, + "orders_payment_intent_id_null_when_none": { + "name": "orders_payment_intent_id_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_intent_id\" IS NULL" + }, + "orders_psp_fields_null_when_none": { + "name": "orders_psp_fields_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR (\n \"orders\".\"psp_charge_id\" IS NULL AND\n \"orders\".\"psp_payment_method\" IS NULL AND\n \"orders\".\"psp_status_reason\" IS NULL\n )" + }, + "orders_total_amount_mirror_consistent": { + "name": "orders_total_amount_mirror_consistent", + "value": "\"orders\".\"total_amount\" = (\"orders\".\"total_amount_minor\"::numeric / 100)" + }, + "orders_payment_status_valid_when_none": { + "name": "orders_payment_status_valid_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_status\" in ('paid','failed')" + }, + "orders_shipping_null_when_not_required_chk": { + "name": "orders_shipping_null_when_not_required_chk", + "value": "\n \"orders\".\"shipping_required\" IS TRUE\n OR (\n \"orders\".\"shipping_provider\" IS NULL\n AND \"orders\".\"shipping_method_code\" IS NULL\n AND \"orders\".\"shipping_status\" IS NULL\n )\n " + }, + "orders_shipping_present_when_required_chk": { + "name": "orders_shipping_present_when_required_chk", + "value": "\n \"orders\".\"shipping_required\" IS DISTINCT FROM TRUE\n OR (\n \"orders\".\"shipping_provider\" IS NOT NULL\n AND \"orders\".\"shipping_method_code\" IS NOT NULL\n AND \"orders\".\"shipping_status\" IS NOT NULL\n )\n " + }, + "orders_shipping_amount_minor_non_negative_chk": { + "name": "orders_shipping_amount_minor_non_negative_chk", + "value": "\"orders\".\"shipping_amount_minor\" IS NULL OR \"orders\".\"shipping_amount_minor\" >= 0" + }, + "orders_shipping_payer_null_when_not_required_chk": { + "name": "orders_shipping_payer_null_when_not_required_chk", + "value": "\"orders\".\"shipping_required\" IS TRUE OR \"orders\".\"shipping_payer\" IS NULL" + }, + "orders_shipping_payer_present_when_required_chk": { + "name": "orders_shipping_payer_present_when_required_chk", + "value": "\"orders\".\"shipping_required\" IS DISTINCT FROM TRUE OR \"orders\".\"shipping_payer\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.payment_attempts": { + "name": "payment_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "expected_amount_minor": { + "name": "expected_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_url": { + "name": "checkout_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "janitor_claimed_until": { + "name": "janitor_claimed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "janitor_claimed_by": { + "name": "janitor_claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payment_attempts_order_provider_attempt_unique": { + "name": "payment_attempts_order_provider_attempt_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "payment_attempts_provider_pi_unique": { + "name": "payment_attempts_provider_pi_unique", + "columns": [ + { + "expression": "provider_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "payment_attempts_order_provider_status_idx": { + "name": "payment_attempts_order_provider_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "payment_attempts_order_provider_active_unique": { + "name": "payment_attempts_order_provider_active_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false + }, + "payment_attempts_provider_status_updated_idx": { + "name": "payment_attempts_provider_status_updated_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "payment_attempts_janitor_claim_idx": { + "name": "payment_attempts_janitor_claim_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "janitor_claimed_until", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "columnsFrom": [ + "order_id" + ], + "tableTo": "orders", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "payment_attempts_provider_check": { + "name": "payment_attempts_provider_check", + "value": "\"payment_attempts\".\"provider\" in ('stripe','monobank')" + }, + "payment_attempts_status_check": { + "name": "payment_attempts_status_check", + "value": "\"payment_attempts\".\"status\" in ('creating','active','succeeded','failed','canceled')" + }, + "payment_attempts_attempt_number_check": { + "name": "payment_attempts_attempt_number_check", + "value": "\"payment_attempts\".\"attempt_number\" >= 1" + }, + "payment_attempts_expected_amount_minor_non_negative": { + "name": "payment_attempts_expected_amount_minor_non_negative", + "value": "\"payment_attempts\".\"expected_amount_minor\" is null or \"payment_attempts\".\"expected_amount_minor\" >= 0" + }, + "payment_attempts_mono_currency_uah": { + "name": "payment_attempts_mono_currency_uah", + "value": "\"payment_attempts\".\"provider\" <> 'monobank' OR \"payment_attempts\".\"currency\" = 'UAH'" + } + }, + "isRLSEnabled": false + }, + "public.product_prices": { + "name": "product_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price_minor": { + "name": "price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "original_price_minor": { + "name": "original_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_prices_product_id_idx": { + "name": "product_prices_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "product_prices_product_currency_uq": { + "name": "product_prices_product_currency_uq", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "columnsFrom": [ + "product_id" + ], + "tableTo": "products", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "product_prices_price_positive": { + "name": "product_prices_price_positive", + "value": "\"product_prices\".\"price_minor\" > 0" + }, + "product_prices_original_price_valid": { + "name": "product_prices_original_price_valid", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price_minor\" > \"product_prices\".\"price_minor\"" + }, + "product_prices_price_mirror_consistent": { + "name": "product_prices_price_mirror_consistent", + "value": "\"product_prices\".\"price\" = (\"product_prices\".\"price_minor\"::numeric / 100)" + }, + "product_prices_original_price_null_coupled": { + "name": "product_prices_original_price_null_coupled", + "value": "(\"product_prices\".\"original_price_minor\" is null) = (\"product_prices\".\"original_price\" is null)" + }, + "product_prices_original_price_mirror_consistent": { + "name": "product_prices_original_price_mirror_consistent", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price\" = (\"product_prices\".\"original_price_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_public_id": { + "name": "image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "colors": { + "name": "colors", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "sizes": { + "name": "sizes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "badge": { + "name": "badge", + "type": "product_badge", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NONE'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stock": { + "name": "stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "products_slug_unique": { + "name": "products_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "products_stock_non_negative": { + "name": "products_stock_non_negative", + "value": "\"products\".\"stock\" >= 0" + }, + "products_currency_usd_only": { + "name": "products_currency_usd_only", + "value": "\"products\".\"currency\" = 'USD'" + }, + "products_price_positive": { + "name": "products_price_positive", + "value": "\"products\".\"price\" > 0" + }, + "products_original_price_valid": { + "name": "products_original_price_valid", + "value": "\"products\".\"original_price\" is null or \"products\".\"original_price\" > \"products\".\"price\"" + } + }, + "isRLSEnabled": false + }, + "public.shipping_shipments": { + "name": "shipping_shipments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "shipping_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nova_poshta'" + }, + "status": { + "name": "status", + "type": "shipping_shipment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_shipments_order_id_uq": { + "name": "shipping_shipments_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "shipping_shipments_queue_idx": { + "name": "shipping_shipments_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "columnsFrom": [ + "order_id" + ], + "tableTo": "orders", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_shipments_attempt_count_non_negative_chk": { + "name": "shipping_shipments_attempt_count_non_negative_chk", + "value": "\"shipping_shipments\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_events": { + "name": "stripe_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_events_event_id_idx": { + "name": "stripe_events_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "columnsFrom": [ + "order_id" + ], + "tableTo": "orders", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_provider_provider_id_unique": { + "name": "users_provider_provider_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.inventory_move_type": { + "name": "inventory_move_type", + "schema": "public", + "values": [ + "reserve", + "release" + ] + }, + "public.inventory_status": { + "name": "inventory_status", + "schema": "public", + "values": [ + "none", + "reserving", + "reserved", + "release_pending", + "released", + "failed" + ] + }, + "public.order_status": { + "name": "order_status", + "schema": "public", + "values": [ + "CREATED", + "INVENTORY_RESERVED", + "INVENTORY_FAILED", + "PAID", + "CANCELED" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "requires_payment", + "paid", + "failed", + "refunded", + "needs_review" + ] + }, + "public.product_badge": { + "name": "product_badge", + "schema": "public", + "values": [ + "NEW", + "SALE", + "NONE" + ] + }, + "public.shipping_method_code": { + "name": "shipping_method_code", + "schema": "public", + "values": [ + "NP_WAREHOUSE", + "NP_LOCKER", + "NP_COURIER" + ] + }, + "public.shipping_payer": { + "name": "shipping_payer", + "schema": "public", + "values": [ + "customer", + "merchant" + ] + }, + "public.shipping_provider": { + "name": "shipping_provider", + "schema": "public", + "values": [ + "nova_poshta", + "ukrposhta" + ] + }, + "public.shipping_shipment_status": { + "name": "shipping_shipment_status", + "schema": "public", + "values": [ + "queued", + "processing", + "succeeded", + "failed", + "needs_attention" + ] + }, + "public.shipping_status": { + "name": "shipping_status", + "schema": "public", + "values": [ + "pending", + "queued", + "creating_label", + "label_created", + "shipped", + "delivered", + "cancelled", + "needs_attention" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json index e2141abe..7ebde737 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1771984384747, "tag": "0019_p2_shop_invariants", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1772135863883, + "tag": "0020_shop_orders_sweeps_partial_indexes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index d3674eed..3c8cfca1 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -879,53 +879,66 @@ export async function createOrderWithItems({ let orderId: string; try { - orderId = await db.transaction(async tx => { - const [created] = await tx - .insert(orders) - .values({ - totalAmountMinor: orderTotalCents, - totalAmount: toDbMoney(orderTotalCents), - - currency, - paymentStatus: initialPaymentStatus, - paymentProvider, - paymentIntentId: null, - shippingRequired: preparedShipping.orderSummary.shippingRequired, - shippingPayer: preparedShipping.orderSummary.shippingPayer, - shippingProvider: preparedShipping.orderSummary.shippingProvider, - shippingMethodCode: preparedShipping.orderSummary.shippingMethodCode, - shippingAmountMinor: - preparedShipping.orderSummary.shippingAmountMinor, - shippingStatus: preparedShipping.orderSummary.shippingStatus, - trackingNumber: null, - shippingProviderRef: null, - - status: 'CREATED', - - inventoryStatus: paymentsEnabled ? 'none' : 'reserving', - failureCode: null, - failureMessage: null, - idempotencyRequestHash: requestHash, - - stockRestored: false, - restockedAt: null, - idempotencyKey, - userId: userId ?? null, - }) - .returning({ id: orders.id }); - - if (!created) throw new Error('Failed to create order'); - - if (preparedShipping.required && preparedShipping.snapshot) { + const [created] = await db + .insert(orders) + .values({ + totalAmountMinor: orderTotalCents, + totalAmount: toDbMoney(orderTotalCents), + + currency, + paymentStatus: initialPaymentStatus, + paymentProvider, + paymentIntentId: null, + shippingRequired: preparedShipping.orderSummary.shippingRequired, + shippingPayer: preparedShipping.orderSummary.shippingPayer, + shippingProvider: preparedShipping.orderSummary.shippingProvider, + shippingMethodCode: preparedShipping.orderSummary.shippingMethodCode, + shippingAmountMinor: preparedShipping.orderSummary.shippingAmountMinor, + shippingStatus: preparedShipping.orderSummary.shippingStatus, + trackingNumber: null, + shippingProviderRef: null, + + status: 'CREATED', + + inventoryStatus: paymentsEnabled ? 'none' : 'reserving', + failureCode: null, + failureMessage: null, + idempotencyRequestHash: requestHash, + + stockRestored: false, + restockedAt: null, + idempotencyKey, + userId: userId ?? null, + }) + .returning({ id: orders.id }); + + if (!created) throw new Error('Failed to create order'); + + if (preparedShipping.required && preparedShipping.snapshot) { + try { await ensureOrderShippingSnapshot({ orderId: created.id, snapshot: preparedShipping.snapshot, - dbClient: tx, }); + } catch (e) { + // Neon HTTP: no interactive transactions. Do compensating cleanup. + logError( + `[createOrderWithItems] orderShipping snapshot insert failed orderId=${created.id}`, + e + ); + try { + await db.delete(orders).where(eq(orders.id, created.id)); + } catch (cleanupErr) { + logError( + `[createOrderWithItems] cleanup delete failed orderId=${created.id}`, + cleanupErr + ); + } + throw e; } + } - return created.id; - }); + orderId = created.id; } catch (error) { if ((error as { code?: string }).code === '23505') { const existingOrder = await getOrderByIdempotencyKey(db, idempotencyKey); diff --git a/frontend/lib/services/orders/monobank-webhook.ts b/frontend/lib/services/orders/monobank-webhook.ts index cfcf481f..ccf2b2ef 100644 --- a/frontend/lib/services/orders/monobank-webhook.ts +++ b/frontend/lib/services/orders/monobank-webhook.ts @@ -483,50 +483,126 @@ async function atomicMarkPaidOrderAndSucceedAttempt(args: { enqueueShipment: boolean; }): Promise<{ ok: boolean; shipmentQueued: boolean }> { const res = await db.execute(sql` - with updated_order as ( - update orders - set status = 'PAID', - 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 + with updated_order as ( + update orders + set status = 'PAID', + payment_status = 'paid', + 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 + and order_id = ${args.orderId}::uuid + ) + returning + id, + payment_status, + inventory_status, + shipping_required, + shipping_provider, + shipping_method_code + ), + updated_attempt as ( + update payment_attempts + set status = 'succeeded', + finalized_at = ${args.now}, + updated_at = ${args.now}, + last_error_code = null, + last_error_message = null, + provider_modified_at = ${args.nextProviderModifiedAt ?? null} + where id = ${args.attemptId}::uuid + and exists (select 1 from updated_order) + returning id + ), + eligible_for_enqueue as ( + select uo.id + from updated_order uo + where ${args.enqueueShipment} = true + and uo.payment_status = 'paid' + and uo.shipping_required = true + and uo.shipping_provider = 'nova_poshta' + and uo.shipping_method_code is not null + and ${inventoryCommittedForShippingSql(sql`uo.inventory_status`)} + ), + inserted_shipment as ( + insert into shipping_shipments ( + order_id, + provider, + status, + attempt_count, + created_at, + updated_at ) - returning - id, - payment_status, - inventory_status, - shipping_required, - shipping_provider, - shipping_method_code - ), - updated_attempt as ( - update payment_attempts - set status = 'succeeded', - finalized_at = ${args.now}, - updated_at = ${args.now}, - last_error_code = null, - last_error_message = null, - provider_modified_at = ${args.nextProviderModifiedAt ?? null} - where id = ${args.attemptId}::uuid - and exists (select 1 from updated_order) - returning id - ), - 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 ( + select + id, + 'nova_poshta', + 'queued', + 0, + ${args.now}, + ${args.now} + from eligible_for_enqueue + on conflict (order_id) do nothing + returning order_id + ), + queued_order_ids as ( + select order_id from inserted_shipment + union + select s.order_id + from shipping_shipments s + where s.order_id in (select id from eligible_for_enqueue) + and s.status = 'queued' + ), + shipping_status_update as ( + update orders + set shipping_status = 'queued'::shipping_status, + updated_at = ${args.now} + where id in (select order_id from queued_order_ids) + and shipping_status is distinct from 'queued'::shipping_status + 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, + (select count(*)::int from queued_order_ids) as queued_order_ids_count, + (select count(*)::int from shipping_status_update) as shipping_status_update_count, + (select exists( + select 1 + from shipping_shipments s + where s.order_id = ${args.orderId}::uuid + and s.status = 'queued' + )) as shipment_is_queued, + (select (o.shipping_status = 'queued'::shipping_status) + from orders o + where o.id = ${args.orderId}::uuid + ) as order_shipping_is_queued + `); + + const row = readDbRows<{ + order_id?: string; + attempt_id?: string; + inserted_shipment_count?: number; + queued_order_ids_count?: number; + shipment_is_queued?: boolean; + order_shipping_is_queued?: boolean | null; + }>(res)[0]; + + return { + ok: Boolean(row?.order_id && row?.attempt_id), + shipmentQueued: + Boolean(row?.shipment_is_queued) && Boolean(row?.order_shipping_is_queued), + }; +} + +async function ensureQueuedShipmentAndOrderShippingStatus(args: { + now: Date; + orderId: string; +}): Promise<{ insertedShipment: boolean; updatedOrder: boolean }> { + const insertRes = await db.execute(sql` insert into shipping_shipments ( order_id, provider, @@ -536,44 +612,49 @@ async function atomicMarkPaidOrderAndSucceedAttempt(args: { updated_at ) select - id, + o.id, 'nova_poshta', 'queued', 0, ${args.now}, ${args.now} - from eligible_for_enqueue - on conflict (order_id) do nothing - returning order_id - ), - queued_shipment as ( - select s.order_id - from shipping_shipments s - where s.order_id in (select id from eligible_for_enqueue) - and s.status = 'queued' - ), - shipping_status_update as ( + from orders o + where o.id = ${args.orderId}::uuid + and o.payment_provider = 'monobank' + and o.payment_status = 'paid' + and o.shipping_required = true + and o.shipping_provider = 'nova_poshta' + and o.shipping_method_code is not null + and ${inventoryCommittedForShippingSql(sql`o.inventory_status`)} + on conflict (order_id) do update + set status = 'queued', + updated_at = ${args.now} + where shipping_shipments.provider = 'nova_poshta' + and shipping_shipments.status is distinct from 'queued' +returning order_id + `); + + const insertedShipment = + readDbRows<{ order_id?: string }>(insertRes).length > 0; + + const updateRes = await db.execute(sql` update orders set shipping_status = 'queued'::shipping_status, updated_at = ${args.now} - where id in (select order_id from queued_shipment) + where id = ${args.orderId}::uuid + and shipping_status is distinct from 'queued'::shipping_status + and exists ( + select 1 + from shipping_shipments s + where s.order_id = ${args.orderId}::uuid + and s.status = 'queued' + ) 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 -`); + `); - const row = readDbRows<{ - order_id?: string; - attempt_id?: string; - inserted_shipment_count?: number; - }>(res)[0]; - return { - ok: Boolean(row?.order_id && row?.attempt_id), - shipmentQueued: Number(row?.inserted_shipment_count ?? 0) > 0, - }; + const updatedOrder = readDbRows<{ id?: string }>(updateRes).length > 0; + + return { insertedShipment, updatedOrder }; } async function atomicFinalizeOrderAndAttempt(args: { @@ -865,7 +946,6 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { } const enqueueShipment = - tr.applied && orderRow.shippingRequired === true && orderRow.shippingProvider === 'nova_poshta' && Boolean(orderRow.shippingMethodCode) && @@ -912,7 +992,19 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { }); } - if (atomicResult.shipmentQueued) { + const ensured = atomicResult.shipmentQueued + ? { insertedShipment: false, updatedOrder: false } + : await ensureQueuedShipmentAndOrderShippingStatus({ + now, + orderId: orderRow.id, + }); + + const shipmentQueued = + atomicResult.shipmentQueued || + ensured.insertedShipment || + ensured.updatedOrder; + + if (shipmentQueued) { recordShippingMetric({ name: 'queued', source: 'monobank_webhook', diff --git a/frontend/lib/services/orders/summary.ts b/frontend/lib/services/orders/summary.ts index d41b2785..18b0259c 100644 --- a/frontend/lib/services/orders/summary.ts +++ b/frontend/lib/services/orders/summary.ts @@ -164,6 +164,62 @@ export async function getOrderSummary( return getOrderById(id); } +export type OrderStatusLiteSummary = { + id: string; + paymentStatus: string; + totalAmountMinor: number; + currency: string; + itemsCount: number; + updatedAt: Date; +}; + +export async function getOrderStatusLiteSummary( + orderId: string +): Promise { + const rows = await db + .select({ + id: orders.id, + paymentStatus: orders.paymentStatus, + totalAmountMinor: orders.totalAmountMinor, + totalAmount: orders.totalAmount, + currency: orders.currency, + updatedAt: orders.updatedAt, + itemsCount: sql`count(${orderItems.id})::int`, + }) + .from(orders) + .leftJoin(orderItems, eq(orderItems.orderId, orders.id)) + .where(eq(orders.id, orderId)) + .groupBy( + orders.id, + orders.paymentStatus, + orders.totalAmountMinor, + orders.totalAmount, + orders.currency, + orders.updatedAt + ) + .limit(1); + + const row = rows[0]; + if (!row) throw new OrderNotFoundError('Order not found'); + + const totalAmountMinor = + row.totalAmountMinor == null + ? fromDbMoney(row.totalAmount) + : requireMinor(row.totalAmountMinor, { + orderId: row.id, + field: 'orders.totalAmountMinor', + }); + + return { + id: row.id, + paymentStatus: row.paymentStatus, + totalAmountMinor, + currency: row.currency, + itemsCount: Number.isFinite(row.itemsCount) ? row.itemsCount : 0, + updatedAt: row.updatedAt, + }; +} + type OrderAttemptSummary = { status: string; providerRef: string | null; diff --git a/frontend/lib/services/orders/sweeps.ts b/frontend/lib/services/orders/sweeps.ts index 0d4732f2..5286d0ef 100644 --- a/frontend/lib/services/orders/sweeps.ts +++ b/frontend/lib/services/orders/sweeps.ts @@ -1,12 +1,70 @@ import crypto from 'crypto'; -import { and, eq, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm'; +import { + and, + eq, + inArray, + isNull, + lt, + ne, + or, + sql, + type SQLWrapper, +} from 'drizzle-orm'; import { db } from '@/db'; import { orders } from '@/db/schema/shop'; +import { logDebug } from '@/lib/logging'; import { type PaymentStatus } from '@/lib/shop/payments'; import { restockOrder } from './restock'; +function compactConditions(conds: Array): SQLWrapper[] { + return conds.filter((c): c is SQLWrapper => Boolean(c)); +} + +type ClaimOrdersForSweepBatchArgs = { + now: Date; + claimExpiresAt: Date; + runId: string; + workerId: string; + batchSize: number; + baseConditions: SQLWrapper[]; + extraSet?: Record; +}; + +async function claimOrdersForSweepBatch( + args: ClaimOrdersForSweepBatchArgs +): Promise> { + const claimable = db + .select({ id: orders.id }) + .from(orders) + .where(and(...args.baseConditions)) + .orderBy(orders.createdAt) + .limit(args.batchSize) + .for('update', { skipLocked: true }); + + return db + .update(orders) + .set({ + sweepClaimedAt: args.now, + sweepClaimExpiresAt: args.claimExpiresAt, + sweepRunId: args.runId, + sweepClaimedBy: args.workerId, + updatedAt: args.now, + ...(args.extraSet ?? {}), + }) + .where( + and( + inArray(orders.id, claimable), + or( + isNull(orders.sweepClaimExpiresAt), + lt(orders.sweepClaimExpiresAt, args.now) + ) + ) + ) + .returning({ id: orders.id }); +} + export async function restockStalePendingOrders(options?: { olderThanMinutes?: number; batchSize?: number; @@ -63,15 +121,19 @@ export async function restockStalePendingOrders(options?: { const cutoff = new Date(Date.now() - olderThanMinutes * 60 * 1000); let processed = 0; + let loopCount = 0; const runId = crypto.randomUUID(); while (true) { if (Date.now() >= deadlineMs) break; + loopCount += 1; const now = new Date(); - const claimExpiresAt = new Date(Date.now() + claimTtlMinutes * 60 * 1000); + const claimExpiresAt = new Date( + now.getTime() + claimTtlMinutes * 60 * 1000 + ); - const baseConditions = [ + const baseConditions: SQLWrapper[] = compactConditions([ eq(orders.paymentProvider, 'stripe'), inArray(orders.paymentStatus, [ 'pending', @@ -84,41 +146,32 @@ export async function restockStalePendingOrders(options?: { isNull(orders.sweepClaimExpiresAt), lt(orders.sweepClaimExpiresAt, now) ), - ]; + ]); + if (!hasExplicitIds) { baseConditions.push(lt(orders.createdAt, cutoff)); } if (hasExplicitIds && options?.orderIds?.length) { - baseConditions.push(inArray(orders.id, options.orderIds)); + const idCond = inArray(orders.id, options.orderIds); + if (idCond) baseConditions.push(idCond); } - const claimable = db - .select({ id: orders.id }) - .from(orders) - .where(and(...baseConditions)) - .orderBy(orders.createdAt) - .limit(batchSize); - - const claimed = await db - .update(orders) - .set({ - sweepClaimedAt: now, - sweepClaimExpiresAt: claimExpiresAt, - sweepRunId: runId, - sweepClaimedBy: workerId, - updatedAt: now, - }) - .where( - and( - inArray(orders.id, claimable), - or( - isNull(orders.sweepClaimExpiresAt), - lt(orders.sweepClaimExpiresAt, now) - ) - ) - ) - .returning({ id: orders.id }); + const claimed = await claimOrdersForSweepBatch({ + now, + claimExpiresAt, + runId, + workerId, + batchSize, + baseConditions, + }); + + logDebug('orders_sweep_claim_batch', { + sweep: 'stale_pending', + runId, + loopCount, + claimedCount: claimed.length, + }); if (!claimed.length) break; @@ -185,15 +238,19 @@ export async function restockStuckReservingOrders(options?: { const cutoff = new Date(Date.now() - olderThanMinutes * 60 * 1000); let processed = 0; + let loopCount = 0; const runId = crypto.randomUUID(); while (true) { if (Date.now() >= deadlineMs) break; + loopCount += 1; const now = new Date(); - const claimExpiresAt = new Date(Date.now() + claimTtlMinutes * 60 * 1000); + const claimExpiresAt = new Date( + now.getTime() + claimTtlMinutes * 60 * 1000 + ); - const baseConditions = [ + const baseConditions: SQLWrapper[] = compactConditions([ eq(orders.paymentProvider, 'stripe'), inArray(orders.paymentStatus, [ @@ -215,36 +272,27 @@ export async function restockStuckReservingOrders(options?: { isNull(orders.sweepClaimExpiresAt), lt(orders.sweepClaimExpiresAt, now) ), - ]; - - const claimable = db - .select({ id: orders.id }) - .from(orders) - .where(and(...baseConditions)) - .orderBy(orders.createdAt) - .limit(batchSize); - - const claimed = await db - .update(orders) - .set({ - sweepClaimedAt: now, - sweepClaimExpiresAt: claimExpiresAt, - sweepRunId: runId, - sweepClaimedBy: workerId, + ]); + + const claimed = await claimOrdersForSweepBatch({ + now, + claimExpiresAt, + runId, + workerId, + batchSize, + baseConditions, + extraSet: { failureCode: sql`coalesce(${orders.failureCode}, 'STUCK_RESERVING_TIMEOUT')`, failureMessage: sql`coalesce(${orders.failureMessage}, 'Order timed out while reserving inventory.')`, - updatedAt: now, - }) - .where( - and( - inArray(orders.id, claimable), - or( - isNull(orders.sweepClaimExpiresAt), - lt(orders.sweepClaimExpiresAt, now) - ) - ) - ) - .returning({ id: orders.id }); + }, + }); + + logDebug('orders_sweep_claim_batch', { + sweep: 'stuck_reserving', + runId, + loopCount, + claimedCount: claimed.length, + }); if (!claimed.length) break; @@ -313,15 +361,19 @@ export async function restockStaleNoPaymentOrders(options?: { const cutoff = new Date(Date.now() - olderThanMinutes * 60 * 1000); let processed = 0; + let loopCount = 0; const runId = crypto.randomUUID(); while (true) { if (Date.now() >= deadlineMs) break; + loopCount += 1; const now = new Date(); - const claimExpiresAt = new Date(Date.now() + claimTtlMinutes * 60 * 1000); + const claimExpiresAt = new Date( + now.getTime() + claimTtlMinutes * 60 * 1000 + ); - const baseConditions = [ + const baseConditions: SQLWrapper[] = compactConditions([ eq(orders.paymentProvider, 'none'), eq(orders.stockRestored, false), isNull(orders.restockedAt), @@ -337,34 +389,23 @@ export async function restockStaleNoPaymentOrders(options?: { isNull(orders.sweepClaimExpiresAt), lt(orders.sweepClaimExpiresAt, now) ), - ]; - - const claimable = db - .select({ id: orders.id }) - .from(orders) - .where(and(...baseConditions)) - .orderBy(orders.createdAt) - .limit(batchSize); - - const claimed = await db - .update(orders) - .set({ - sweepClaimedAt: now, - sweepClaimExpiresAt: claimExpiresAt, - sweepRunId: runId, - sweepClaimedBy: workerId, - updatedAt: now, - }) - .where( - and( - inArray(orders.id, claimable), - or( - isNull(orders.sweepClaimExpiresAt), - lt(orders.sweepClaimExpiresAt, now) - ) - ) - ) - .returning({ id: orders.id }); + ]); + + const claimed = await claimOrdersForSweepBatch({ + now, + claimExpiresAt, + runId, + workerId, + batchSize, + baseConditions, + }); + + logDebug('orders_sweep_claim_batch', { + sweep: 'stale_no_payment', + runId, + loopCount, + claimedCount: claimed.length, + }); if (!claimed.length) break; diff --git a/frontend/lib/services/shop/shipping/inventory-eligibility.ts b/frontend/lib/services/shop/shipping/inventory-eligibility.ts index b1c71444..108542b2 100644 --- a/frontend/lib/services/shop/shipping/inventory-eligibility.ts +++ b/frontend/lib/services/shop/shipping/inventory-eligibility.ts @@ -1,4 +1,4 @@ -import { type SQL, sql, type SQLWrapper } from 'drizzle-orm'; +import { inArray, type SQL, sql, type SQLWrapper } from 'drizzle-orm'; import { inventoryStatusEnum } from '@/db/schema/shop'; @@ -25,8 +25,5 @@ export function isInventoryCommittedForShipping( export function inventoryCommittedForShippingSql( columnReference: SQLWrapper ): SQL { - return sql`${columnReference} in (${sql.join( - INVENTORY_COMMITTED_FOR_SHIPPING.map(value => sql`${value}`), - sql`, ` - )})`; + return inArray(columnReference, INVENTORY_COMMITTED_FOR_SHIPPING); } diff --git a/frontend/lib/tests/helpers/seed-product.ts b/frontend/lib/tests/helpers/seed-product.ts new file mode 100644 index 00000000..2aa2fcf5 --- /dev/null +++ b/frontend/lib/tests/helpers/seed-product.ts @@ -0,0 +1,228 @@ +import crypto from 'crypto'; +import { eq, sql } from 'drizzle-orm'; + +import { db } from '@/db'; +import { productPrices, products } from '@/db/schema'; +import { toDbMoney } from '@/lib/shop/money'; + +let __seedTemplateProductId: string | null = null; + +function readRows(res: any): T[] { + if (Array.isArray(res)) return res as T[]; + if (Array.isArray(res?.rows)) return res.rows as T[]; + return []; +} + +type ColumnInfo = { + column_name: string; + is_nullable: 'YES' | 'NO'; + column_default: string | null; + data_type: string; + udt_name: string; + is_identity?: 'YES' | 'NO'; + is_generated?: 'ALWAYS' | 'NEVER'; +}; + +function qIdent(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +async function getFirstEnumLabel(typeName: string): Promise { + const res = await db.execute(sql` + select e.enumlabel as label + from pg_type t + join pg_enum e on e.enumtypid = t.oid + where t.typname = ${typeName} + order by e.enumsortorder asc + limit 1 + `); + const rows = readRows<{ label?: unknown }>(res); + const label = rows[0]?.label; + if (typeof label !== 'string' || !label.trim()) { + throw new Error(`Unable to resolve enum label for type "${typeName}".`); + } + return label; +} + +export async function getOrSeedActiveTemplateProduct(): Promise { + const [existing] = await db + .select() + .from(products) + .where(eq(products.isActive as any, true)) + .limit(1); + + if (existing) return existing; + + const productId = crypto.randomUUID(); + const slug = `t-seed-${crypto.randomUUID()}`; + const sku = `t-seed-${crypto.randomUUID()}`; + const now = new Date(); + + __seedTemplateProductId = productId; + + const infoRes = await db.execute(sql` + select + column_name, + is_nullable, + column_default, + data_type, + udt_name, + is_identity, + is_generated + from information_schema.columns + where table_schema = 'public' and table_name = 'products' + order by ordinal_position asc + `); + + const cols = readRows(infoRes); + if (!cols.length) throw new Error('Unable to introspect products columns.'); + + const preferred: Record = { + id: productId, + slug, + sku, + title: `Seed ${slug}`, + stock: 9999, + is_active: true, + created_at: now, + updated_at: now, + }; + + const insertCols: string[] = []; + const insertVals: any[] = []; + + for (const c of cols) { + const col = c.column_name; + const hasPreferred = Object.prototype.hasOwnProperty.call(preferred, col); + const isGenerated = c.is_generated === 'ALWAYS'; + const isIdentity = c.is_identity === 'YES'; + const requiredNoDefault = + c.is_nullable === 'NO' && + (c.column_default === null || c.column_default === undefined); + + if (isGenerated || isIdentity) continue; + if (!hasPreferred && !requiredNoDefault) continue; + + insertCols.push(col); + + if (hasPreferred) { + insertVals.push(sql`${preferred[col]}`); + continue; + } + + if (c.data_type === 'USER-DEFINED') { + const enumLabel = await getFirstEnumLabel(c.udt_name); + insertVals.push(sql`${enumLabel}::${sql.raw(qIdent(c.udt_name))}`); + continue; + } + + switch (c.data_type) { + case 'boolean': + insertVals.push(sql`false`); + break; + case 'smallint': + case 'integer': + case 'bigint': + insertVals.push(sql`0`); + break; + + case 'numeric': + case 'real': + case 'double precision': + if (/price/i.test(col)) insertVals.push(sql`1`); + else insertVals.push(sql`0`); + break; + + case 'uuid': + insertVals.push(sql`${crypto.randomUUID()}::uuid`); + break; + + case 'jsonb': + insertVals.push(sql`${JSON.stringify({})}::jsonb`); + break; + + case 'json': + insertVals.push(sql`${JSON.stringify({})}::json`); + break; + + case 'date': + insertVals.push(sql`${now.toISOString().slice(0, 10)}`); + break; + + case 'timestamp with time zone': + case 'timestamp without time zone': + case 'timestamp': + insertVals.push(sql`${now}`); + break; + + default: + insertVals.push(sql`${`seed_${col}_${crypto.randomUUID()}`}`); + break; + } + } + + const colSql = insertCols.map(c => sql.raw(qIdent(c))); + + await db.execute(sql` + insert into "products" (${sql.join(colSql, sql`, `)}) + values (${sql.join(insertVals, sql`, `)}) + `); + + await db.insert(productPrices).values([ + { + productId, + currency: 'UAH', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + createdAt: now, + updatedAt: now, + } as any, + { + productId, + currency: 'USD', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + createdAt: now, + updatedAt: now, + } as any, + ]); + + const [seeded] = await db + .select() + .from(products) + .where(eq(products.id as any, productId)) + .limit(1); + + if (!seeded) throw new Error('Failed to seed template product.'); + return seeded; +} + +export async function cleanupSeededTemplateProduct(): Promise { + if (!__seedTemplateProductId) return; + const productId = __seedTemplateProductId; + __seedTemplateProductId = null; + + try { + await db.execute( + sql`delete from inventory_moves where product_id = ${productId}::uuid` + ); + } catch {} + + try { + await db.execute( + sql`delete from order_items where product_id = ${productId}::uuid` + ); + } catch {} + + try { + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + } catch {} + + try { + await db.delete(products).where(eq(products.id, productId)); + } catch {} +} \ No newline at end of file diff --git a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts index 7ff188e6..6ca57110 100644 --- a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts @@ -16,6 +16,10 @@ import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; import { toDbMoney } from '@/lib/shop/money'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { + cleanupSeededTemplateProduct, + getOrSeedActiveTemplateProduct, +} from '@/lib/tests/helpers/seed-product'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -49,8 +53,6 @@ vi.mock('@/lib/psp/monobank', () => ({ cancelMonobankInvoice: vi.fn(async () => {}), })); -let __seedTemplateProductId: string | null = null; - const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; @@ -105,193 +107,11 @@ beforeEach(() => { vi.clearAllMocks(); }); -function readRows(res: any): T[] { - if (Array.isArray(res)) return res as T[]; - if (Array.isArray(res?.rows)) return res.rows as T[]; - return []; -} - -type ColumnInfo = { - column_name: string; - is_nullable: 'YES' | 'NO'; - column_default: string | null; - data_type: string; - udt_name: string; - is_identity?: 'YES' | 'NO'; - is_generated?: 'ALWAYS' | 'NEVER'; -}; - -function qIdent(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - -async function getFirstEnumLabel(typeName: string): Promise { - const res = await db.execute(sql` - select e.enumlabel as label - from pg_type t - join pg_enum e on e.enumtypid = t.oid - where t.typname = ${typeName} - order by e.enumsortorder asc - limit 1 - `); - const rows = readRows<{ label?: unknown }>(res); - const label = rows[0]?.label; - if (typeof label !== 'string' || !label.trim()) { - throw new Error(`Unable to resolve enum label for type "${typeName}".`); - } - return label; -} - -async function seedTemplateProductIfMissing(): Promise { - const [existing] = await db - .select() - .from(products) - .where(eq(products.isActive as any, true)) - .limit(1); - - if (existing) return existing; - - const productId = crypto.randomUUID(); - const slug = `t-seed-${crypto.randomUUID()}`; - const sku = `t-seed-${crypto.randomUUID()}`; - const now = new Date(); - - __seedTemplateProductId = productId; - - const infoRes = await db.execute(sql` - select - column_name, - is_nullable, - column_default, - data_type, - udt_name, - is_identity, - is_generated - from information_schema.columns - where table_schema = 'public' and table_name = 'products' - order by ordinal_position asc - `); - const cols = readRows(infoRes); - if (!cols.length) throw new Error('Unable to introspect products columns.'); - - const preferred: Record = { - id: productId, - slug, - sku, - title: `Seed ${slug}`, - stock: 9999, - is_active: true, - created_at: now, - updated_at: now, - }; - - const insertCols: string[] = []; - const insertVals: any[] = []; - - for (const c of cols) { - const col = c.column_name; - const hasPreferred = Object.prototype.hasOwnProperty.call(preferred, col); - const isGenerated = c.is_generated === 'ALWAYS'; - const isIdentity = c.is_identity === 'YES'; - const requiredNoDefault = - c.is_nullable === 'NO' && - (c.column_default === null || c.column_default === undefined); - - if (isGenerated || isIdentity) continue; - if (!hasPreferred && !requiredNoDefault) continue; - - insertCols.push(col); - - if (hasPreferred) { - insertVals.push(sql`${preferred[col]}`); - continue; - } - - if (c.data_type === 'USER-DEFINED') { - const enumLabel = await getFirstEnumLabel(c.udt_name); - insertVals.push(sql`${enumLabel}::${sql.raw(qIdent(c.udt_name))}`); - continue; - } - - switch (c.data_type) { - case 'boolean': - insertVals.push(sql`false`); - break; - case 'smallint': - case 'integer': - case 'bigint': - case 'numeric': - case 'real': - case 'double precision': - insertVals.push(sql`0`); - break; - case 'uuid': - insertVals.push(sql`${crypto.randomUUID()}::uuid`); - break; - case 'jsonb': - insertVals.push(sql`${JSON.stringify({})}::jsonb`); - break; - case 'json': - insertVals.push(sql`${JSON.stringify({})}::json`); - break; - case 'date': - insertVals.push(sql`${now.toISOString().slice(0, 10)}`); - break; - case 'timestamp with time zone': - case 'timestamp without time zone': - case 'timestamp': - insertVals.push(sql`${now}`); - break; - default: - insertVals.push(sql`${`seed_${col}_${crypto.randomUUID()}`}`); - break; - } - } - - const colSql = insertCols.map(c => sql.raw(qIdent(c))); - await db.execute(sql` - insert into "products" (${sql.join(colSql, sql`, `)}) - values (${sql.join(insertVals, sql`, `)}) - `); - - await db.insert(productPrices).values([ - { - productId, - currency: 'UAH', - priceMinor: 1000, - originalPriceMinor: null, - price: toDbMoney(1000), - originalPrice: null, - createdAt: now, - updatedAt: now, - } as any, - { - productId, - currency: 'USD', - priceMinor: 1000, - originalPriceMinor: null, - price: toDbMoney(1000), - originalPrice: null, - createdAt: now, - updatedAt: now, - } as any, - ]); - - const [seeded] = await db - .select() - .from(products) - .where(eq(products.id as any, productId)) - .limit(1); - if (!seeded) throw new Error('Failed to seed template product.'); - - return seeded; -} - async function createIsolatedProduct(args: { stock: number; prices: Array<{ currency: 'USD' | 'UAH'; priceMinor: number }>; }) { - const tpl = await seedTemplateProductIfMissing(); + const tpl = await getOrSeedActiveTemplateProduct(); const productId = crypto.randomUUID(); const slug = `t-mono-contract-${crypto.randomUUID()}`; @@ -348,21 +168,8 @@ async function cleanupProduct(productId: string) { await db.delete(products).where(eq(products.id, productId)); } -function warnCleanup(step: string, err: unknown) { - console.warn('[checkout-monobank-idempotency-contract.test] cleanup failed', { - step, - err: err instanceof Error ? { name: err.name, message: err.message } : err, - }); -} - afterAll(async () => { - if (!__seedTemplateProductId) return; - try { - await cleanupProduct(__seedTemplateProductId); - } catch (e) { - warnCleanup('cleanupSeedTemplateProduct', e); - } - __seedTemplateProductId = null; + await cleanupSeededTemplateProduct(); }); async function postCheckout(idemKey: string, productId: string) { diff --git a/frontend/lib/tests/shop/checkout-no-payments.test.ts b/frontend/lib/tests/shop/checkout-no-payments.test.ts index 51ab95a0..0d5a5c4e 100644 --- a/frontend/lib/tests/shop/checkout-no-payments.test.ts +++ b/frontend/lib/tests/shop/checkout-no-payments.test.ts @@ -7,6 +7,7 @@ import { db } from '@/db'; import { orders, productPrices, products } from '@/db/schema'; import { toDbMoney } from '@/lib/shop/money'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { getOrSeedActiveTemplateProduct } from '@/lib/tests/helpers/seed-product'; const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; @@ -58,17 +59,7 @@ async function createIsolatedProductForCurrency(opts: { }): Promise<{ productId: string }> { const now = new Date(); - const [tpl] = await db - .select() - .from(products) - .where(eq(products.isActive as any, true)) - .limit(1); - - if (!tpl) { - throw new Error( - 'No template product found to clone (need at least 1 active product).' - ); - } + const tpl = await getOrSeedActiveTemplateProduct(); const productId = crypto.randomUUID(); const slug = `t-iso-nopay-${crypto.randomUUID()}`; diff --git a/frontend/lib/tests/shop/monobank-events-claim.test.ts b/frontend/lib/tests/shop/monobank-events-claim.test.ts index 846e0a07..8663fd97 100644 --- a/frontend/lib/tests/shop/monobank-events-claim.test.ts +++ b/frontend/lib/tests/shop/monobank-events-claim.test.ts @@ -47,22 +47,28 @@ async function insertMinimalEvent(args: { describe.sequential('claimNextMonobankEvent', () => { beforeEach(async () => { await db.execute(sql` - delete from monobank_events - where event_key like ${`${EVENT_KEY_PREFIX}:%`} - or raw_sha256 like ${`${EVENT_KEY_PREFIX}:%`} - or event_key = 'test:event_key:1' - or raw_sha256 = 'test:raw_sha256:1' - `); + delete from monobank_events + where provider = 'monobank' + and ( + event_key like 'test:%' + or raw_sha256 like 'test:%' + or event_key = 'test:event_key:1' + or raw_sha256 = 'test:raw_sha256:1' + ) +`); }); afterEach(async () => { await db.execute(sql` - delete from monobank_events - where event_key like ${`${EVENT_KEY_PREFIX}:%`} - or raw_sha256 like ${`${EVENT_KEY_PREFIX}:%`} - or event_key = 'test:event_key:1' - or raw_sha256 = 'test:raw_sha256:1' - `); + delete from monobank_events + where provider = 'monobank' + and ( + event_key like 'test:%' + or raw_sha256 like 'test:%' + or event_key = 'test:event_key:1' + or raw_sha256 = 'test:raw_sha256:1' + ) +`); }); it('claims first eligible event and sets lease fields', async () => { diff --git a/frontend/lib/tests/shop/monobank-payments-disabled.test.ts b/frontend/lib/tests/shop/monobank-payments-disabled.test.ts index 389a99b7..c5c06c8c 100644 --- a/frontend/lib/tests/shop/monobank-payments-disabled.test.ts +++ b/frontend/lib/tests/shop/monobank-payments-disabled.test.ts @@ -8,6 +8,7 @@ import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; import { toDbMoney } from '@/lib/shop/money'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { getOrSeedActiveTemplateProduct } from '@/lib/tests/helpers/seed-product'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -68,15 +69,7 @@ afterAll(() => { }); async function createIsolatedProduct(stock: number) { - const [tpl] = await db - .select() - .from(products) - .where(eq(products.isActive as any, true)) - .limit(1); - - if (!tpl) { - throw new Error('No template product found to clone.'); - } + const tpl = await getOrSeedActiveTemplateProduct(); const productId = crypto.randomUUID(); const slug = `t-mono-${crypto.randomUUID()}`; diff --git a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts index 67417016..3f1e8b6b 100644 --- a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts +++ b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts @@ -4,7 +4,12 @@ import { and, eq } from 'drizzle-orm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; -import { monobankEvents, orders, paymentAttempts, shippingShipments } from '@/db/schema'; +import { + monobankEvents, + orders, + paymentAttempts, + shippingShipments, +} from '@/db/schema'; import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; import { applyMonoWebhookEvent } from '@/lib/services/orders/monobank-webhook'; import { toDbMoney } from '@/lib/shop/money'; diff --git a/frontend/lib/tests/shop/shipping-checkout-payload-phase6.test.ts b/frontend/lib/tests/shop/shipping-checkout-payload-phase6.test.ts index cab05907..80cf4947 100644 --- a/frontend/lib/tests/shop/shipping-checkout-payload-phase6.test.ts +++ b/frontend/lib/tests/shop/shipping-checkout-payload-phase6.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - buildCheckoutShippingPayload, - shippingUnavailableMessage, -} from '@/lib/services/shop/shipping/checkout-payload'; +import { buildCheckoutShippingPayload } from '@/lib/services/shop/shipping/checkout-payload'; describe('phase6 checkout shipping payload helper', () => { it('builds NP warehouse payload shape without client prices/totals', () => { @@ -74,7 +71,7 @@ describe('phase6 checkout shipping payload helper', () => { }); }); - it('returns shipping unavailable UX message for unsupported country', () => { + it('returns SHIPPING_UNAVAILABLE code for unsupported country', () => { const result = buildCheckoutShippingPayload({ shippingAvailable: false, reasonCode: 'COUNTRY_NOT_SUPPORTED', @@ -93,6 +90,5 @@ describe('phase6 checkout shipping payload helper', () => { expect(result.ok).toBe(false); if (result.ok) return; expect(result.code).toBe('SHIPPING_UNAVAILABLE'); - expect(result.message).toBe(shippingUnavailableMessage('COUNTRY_NOT_SUPPORTED')); }); }); diff --git a/frontend/lib/validation/shop-shipping.ts b/frontend/lib/validation/shop-shipping.ts index a2bcf99f..3a618dc8 100644 --- a/frontend/lib/validation/shop-shipping.ts +++ b/frontend/lib/validation/shop-shipping.ts @@ -2,6 +2,30 @@ import { z } from 'zod'; import { currencySchema } from '@/lib/validation/shop'; +const INTERNAL_SHIPPING_PROD_MIN_INTERVAL_SECONDS = 60; + +function isProductionRuntime(): boolean { + const appEnv = String(process.env.APP_ENV ?? '').toLowerCase(); + const nodeEnv = String(process.env.NODE_ENV ?? '').toLowerCase(); + return appEnv === 'production' || nodeEnv === 'production'; +} + +export function getInternalShippingMinIntervalFloorSeconds(): number { + return isProductionRuntime() + ? INTERNAL_SHIPPING_PROD_MIN_INTERVAL_SECONDS + : 1; +} + +export function applyInternalShippingMinIntervalFloor( + requestedMinIntervalSeconds: number +): number { + if (!isProductionRuntime()) return requestedMinIntervalSeconds; + return Math.max( + INTERNAL_SHIPPING_PROD_MIN_INTERVAL_SECONDS, + requestedMinIntervalSeconds + ); +} + const localeSchema = z .string() .trim() @@ -35,7 +59,6 @@ export const shippingCitiesQuerySchema = z }) .strict(); -// cityRef in API contract is SettlementRef from NP Address.searchSettlements. const settlementRefSchema = z .string() .trim() @@ -65,7 +88,8 @@ export const internalNpSyncPayloadSchema = z .min(1) .max(3600) .optional() - .default(60), + .default(60) + .transform(applyInternalShippingMinIntervalFloor), }) .strict() .refine(value => !!value.cityRef || !!value.q, { @@ -97,7 +121,8 @@ export const internalShippingShipmentsRunPayloadSchema = z .min(1) .max(3600) .optional() - .default(1), + .default(1) + .transform(applyInternalShippingMinIntervalFloor), }) .strict(); @@ -111,6 +136,7 @@ export const internalShippingRetentionRunPayloadSchema = z .min(1) .max(86400) .optional() - .default(3600), + .default(3600) + .transform(applyInternalShippingMinIntervalFloor), }) .strict();