diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc90cd6..e66817dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -782,3 +782,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Reduced server load by moving auth and progress logic to client - Improved ISR caching efficiency for quizzes page - Faster navigation and more stable UI during locale and tab changes + +## [1.0.6] - 2026-03-01 + +### Added + +- New learning categories with visual identity: + - Django + - Docker + - Kubernetes + - AWS + - Azure + - DevOps +- Category-specific SVG icons with accent styling across tabs, pagination, and controls +- Improved AWS icon readability in dark mode + +### Shop (Production Readiness) + +- Audit-driven end-to-end purchase hardening: + - Canonical append-only event/audit system + - Async email notifications via outbox worker + - Persisted checkout legal consent (terms/privacy) + - Returns & exchanges lifecycle support + - Admin audit logs for product operations + - Token-scoped guest order access +- Improved Monobank webhook retry behavior +- Added Playwright E2E coverage for Shop flows + +### Performance & Infrastructure + +- Reduced Neon compute usage: + - Throttled background janitor jobs (every 30 min) + - Partial indexes for order sweeps + - SKIP LOCKED batching to reduce contention +- Optimized checkout and payment status polling with backoff strategy +- Lightweight order status view for faster client updates +- Reduced session activity write frequency diff --git a/frontend/.env.example b/frontend/.env.example index abb96456..fd5958df 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -127,6 +127,8 @@ GMAIL_USER= # --- Shop / Internal # Optional public/base URL used by shop services/links SHOP_BASE_URL= +SHOP_PRIVACY_VERSION=privacy-v1 +SHOP_TERMS_VERSION=terms-v1 # Required for signed shop status tokens (if status endpoint/token flow is enabled) SHOP_STATUS_TOKEN_SECRET= @@ -163,4 +165,4 @@ TRUST_FORWARDED_HEADERS=0 # emergency switch RATE_LIMIT_DISABLED=0 -GROQ_API_KEY= \ No newline at end of file +GROQ_API_KEY= diff --git a/frontend/.gitignore b/frontend/.gitignore index 735afbaf..fed11fe9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -13,6 +13,8 @@ # testing /coverage /coverage-quiz +/playwright-report +/test-results # next.js /.next/ diff --git a/frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx b/frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx index 2d67d20d..238503f1 100644 --- a/frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx +++ b/frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx @@ -75,6 +75,7 @@ const POLL_BUSY_RETRY_DELAY_MS = 1_000; const POLL_STOP_ERROR_CODES = new Set([ 'STATUS_TOKEN_REQUIRED', 'STATUS_TOKEN_INVALID', + 'STATUS_TOKEN_SCOPE_FORBIDDEN', 'UNAUTHORIZED', 'FORBIDDEN', ]); diff --git a/frontend/app/api/shop/admin/orders/[id]/quote/offer/route.ts b/frontend/app/api/shop/admin/orders/[id]/quote/offer/route.ts new file mode 100644 index 00000000..40f97d0f --- /dev/null +++ b/frontend/app/api/shop/admin/orders/[id]/quote/offer/route.ts @@ -0,0 +1,167 @@ +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; + +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError, logWarn } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + InvalidPayloadError, + OrderNotFoundError, +} from '@/lib/services/errors'; +import { offerIntlQuote } from '@/lib/services/shop/quotes'; +import { + intlQuoteOfferPayloadSchema, + orderIdParamSchema, +} from '@/lib/validation/shop'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function mapQuoteErrorStatus(code: string): number { + if ( + code === 'QUOTE_VERSION_CONFLICT' || + code === 'QUOTE_NOT_APPLICABLE' || + code === 'QUOTE_ALREADY_ACCEPTED' + ) { + return 409; + } + if (code === 'QUOTE_INVALID_EXPIRY') return 422; + return 400; +} + +export const runtime = 'nodejs'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + let orderIdForLog: string | null = null; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + const admin = await requireAdminApi(request); + const csrfRes = requireAdminCsrf(request, 'admin:orders:quote:offer'); + if (csrfRes) { + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson( + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, + { status: 400 } + ); + } + orderIdForLog = parsedParams.data.id; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid JSON body.' }, + { status: 400 } + ); + } + + const parsedBody = intlQuoteOfferPayloadSchema.safeParse(rawBody); + if (!parsedBody.success) { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid payload.' }, + { status: 400 } + ); + } + + const payload = parsedBody.data; + const result = await offerIntlQuote({ + orderId: orderIdForLog, + requestId, + actorUserId: typeof admin.id === 'string' ? admin.id : null, + version: payload.version, + currency: payload.currency, + shippingQuoteMinor: payload.shippingQuoteMinor, + expiresAt: payload.expiresAt ?? null, + payload: payload.payload, + }); + + return noStoreJson( + { + success: true, + orderId: result.orderId, + version: result.version, + quoteStatus: result.quoteStatus, + shippingQuoteMinor: result.shippingQuoteMinor, + currency: result.currency, + expiresAt: result.expiresAt.toISOString(), + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson( + { code: 'ADMIN_API_DISABLED', message: 'Admin API is disabled.' }, + { status: 403 } + ); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson( + { code: error.code, message: 'Unauthorized.' }, + { status: 401 } + ); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code, message: 'Forbidden.' }, { status: 403 }); + } + if (error instanceof OrderNotFoundError) { + return noStoreJson({ code: error.code }, { status: 404 }); + } + if (error instanceof InvalidPayloadError) { + logWarn('admin_quote_offer_rejected', { + ...baseMeta, + orderId: orderIdForLog, + code: error.code, + }); + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + { status: mapQuoteErrorStatus(error.code) } + ); + } + + logError('admin_quote_offer_failed', error, { + ...baseMeta, + orderId: orderIdForLog, + code: 'ADMIN_QUOTE_OFFER_FAILED', + }); + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to offer quote.' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/shop/admin/products/[id]/route.ts b/frontend/app/api/shop/admin/products/[id]/route.ts index bbec7e03..353d402e 100644 --- a/frontend/app/api/shop/admin/products/[id]/route.ts +++ b/frontend/app/api/shop/admin/products/[id]/route.ts @@ -25,6 +25,7 @@ import { getAdminProductByIdWithPrices, updateProduct, } from '@/lib/services/products'; +import { writeAdminAudit } from '@/lib/services/shop/events/write-admin-audit'; export const runtime = 'nodejs'; @@ -320,7 +321,9 @@ export async function PATCH( let productIdForLog: string | null = null; try { - await requireAdminApi(request); + const adminUser = await requireAdminApi(request); + const actorUserId = + adminUser && typeof adminUser.id === 'string' ? adminUser.id : null; const rawParams = await context.params; const parsedParams = productIdParamSchema.safeParse(rawParams); @@ -457,6 +460,51 @@ export async function PATCH( : undefined, }); + try { + await writeAdminAudit({ + actorUserId, + action: 'product_admin_action.update', + targetType: 'product', + targetId: updated.id, + requestId, + payload: { + productId: updated.id, + slug: updated.slug, + title: updated.title, + badge: updated.badge, + isActive: updated.isActive, + isFeatured: updated.isFeatured, + stock: updated.stock, + }, + dedupeSeed: { + domain: 'product_admin_action', + action: 'update', + requestId, + productId: updated.id, + slug: updated.slug, + toBadge: updated.badge, + toIsActive: updated.isActive, + toIsFeatured: updated.isFeatured, + toStock: updated.stock, + }, + }); + } catch (auditError) { + logWarn('admin_product_update_audit_failed', { + ...baseMeta, + code: 'AUDIT_WRITE_FAILED', + requestId, + actorUserId, + productId: updated.id, + action: 'product_admin_action.update', + message: + auditError instanceof Error + ? auditError.message + : String(auditError), + durationMs: Date.now() - startedAtMs, + }); + throw auditError; + } + return noStoreJson({ success: true, product: updated }, { status: 200 }); } catch (error) { if (error instanceof PriceConfigError) { @@ -643,7 +691,9 @@ export async function DELETE( let productIdForLog: string | null = null; try { - await requireAdminApi(request); + const adminUser = await requireAdminApi(request); + const actorUserId = + adminUser && typeof adminUser.id === 'string' ? adminUser.id : null; const csrfRes = requireAdminCsrf(request, 'admin:products:delete'); if (csrfRes) { @@ -704,6 +754,39 @@ export async function DELETE( await deleteProduct(productIdForLog); + try { + await writeAdminAudit({ + actorUserId, + action: 'product_admin_action.delete', + targetType: 'product', + targetId: productIdForLog, + requestId, + payload: { + productId: productIdForLog, + }, + dedupeSeed: { + domain: 'product_admin_action', + action: 'delete', + requestId, + productId: productIdForLog, + }, + }); + } catch (auditError) { + logWarn('admin_product_delete_audit_failed', { + ...baseMeta, + code: 'AUDIT_WRITE_FAILED', + requestId, + actorUserId, + productId: productIdForLog, + action: 'product_admin_action.delete', + message: + auditError instanceof Error ? auditError.message : String(auditError), + durationMs: Date.now() - startedAtMs, + }); + // Delete is irreversible; keep success response to avoid misleading retries. + // Audit failure is logged and should be monitored/alerted separately. + } + return noStoreJson({ success: true }, { status: 200 }); } catch (error) { if (error instanceof AdminApiDisabledError) { diff --git a/frontend/app/api/shop/admin/products/[id]/status/route.ts b/frontend/app/api/shop/admin/products/[id]/status/route.ts index a96ddd3d..1cc8a8cb 100644 --- a/frontend/app/api/shop/admin/products/[id]/status/route.ts +++ b/frontend/app/api/shop/admin/products/[id]/status/route.ts @@ -14,6 +14,7 @@ import { logError, logWarn } from '@/lib/logging'; import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; import { toggleProductStatus } from '@/lib/services/products'; +import { writeAdminAudit } from '@/lib/services/shop/events/write-admin-audit'; export const runtime = 'nodejs'; @@ -55,7 +56,9 @@ export async function PATCH( let productIdForLog: string | null = null; try { - await requireAdminApi(request); + const adminUser = await requireAdminApi(request); + const actorUserId = + adminUser && typeof adminUser.id === 'string' ? adminUser.id : null; const csrfRes = requireAdminCsrf(request, 'admin:products:status'); if (csrfRes) { logWarn('admin_product_status_csrf_rejected', { @@ -92,6 +95,41 @@ export async function PATCH( const updated = await toggleProductStatus(productIdForLog); + try { + await writeAdminAudit({ + actorUserId, + action: 'product_admin_action.toggle_status', + targetType: 'product', + targetId: updated.id, + requestId, + payload: { + productId: updated.id, + slug: updated.slug, + isActive: updated.isActive, + }, + dedupeSeed: { + domain: 'product_admin_action', + action: 'toggle_status', + requestId, + productId: updated.id, + toIsActive: updated.isActive, + }, + }); + } catch (auditError) { + logWarn('admin_product_status_audit_failed', { + ...baseMeta, + code: 'AUDIT_WRITE_FAILED', + requestId, + actorUserId, + productId: updated.id, + action: 'product_admin_action.toggle_status', + isActive: updated.isActive, + message: + auditError instanceof Error ? auditError.message : String(auditError), + durationMs: Date.now() - startedAtMs, + }); + } + return noStoreJson({ success: true, product: updated }, { status: 200 }); } catch (error) { if (error instanceof AdminApiDisabledError) { diff --git a/frontend/app/api/shop/admin/products/route.ts b/frontend/app/api/shop/admin/products/route.ts index 05c00f9b..80744775 100644 --- a/frontend/app/api/shop/admin/products/route.ts +++ b/frontend/app/api/shop/admin/products/route.ts @@ -9,11 +9,13 @@ import { AdminUnauthorizedError, requireAdminApi, } from '@/lib/auth/admin'; +import { destroyProductImage } from '@/lib/cloudinary'; import { logError, logWarn } from '@/lib/logging'; import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; import { InvalidPayloadError, SlugConflictError } from '@/lib/services/errors'; -import { createProduct } from '@/lib/services/products'; +import { createProduct, deleteProduct } from '@/lib/services/products'; +import { writeAdminAudit } from '@/lib/services/shop/events/write-admin-audit'; export const runtime = 'nodejs'; function noStoreJson(body: unknown, init?: { status?: number }) { @@ -115,7 +117,9 @@ export async function POST(request: NextRequest) { let slugForLog: string | null = null; try { - await requireAdminApi(request); + const adminUser = await requireAdminApi(request); + const actorUserId = + adminUser && typeof adminUser.id === 'string' ? adminUser.id : null; let formData: FormData; try { @@ -282,6 +286,84 @@ export async function POST(request: NextRequest) { ...parsed.data, image: imageFile, }); + + try { + await writeAdminAudit({ + actorUserId, + action: 'product_admin_action.create', + targetType: 'product', + targetId: inserted.id, + requestId, + payload: { + productId: inserted.id, + slug: inserted.slug, + title: inserted.title, + badge: inserted.badge, + isActive: inserted.isActive, + isFeatured: inserted.isFeatured, + stock: inserted.stock, + }, + dedupeSeed: { + domain: 'product_admin_action', + action: 'create', + requestId, + productId: inserted.id, + slug: inserted.slug, + }, + }); + } catch (auditError) { + logWarn('admin_product_create_audit_failed', { + ...baseMeta, + code: 'AUDIT_WRITE_FAILED', + productId: inserted.id, + slug: inserted.slug, + message: + auditError instanceof Error + ? auditError.message + : String(auditError), + durationMs: Date.now() - startedAtMs, + }); + + let rollbackDeleted = false; + try { + await deleteProduct(inserted.id); + rollbackDeleted = true; + } catch (rollbackError) { + logError( + 'admin_product_create_audit_rollback_failed', + rollbackError, + { + ...baseMeta, + code: 'AUDIT_ROLLBACK_FAILED', + productId: inserted.id, + slug: inserted.slug, + durationMs: Date.now() - startedAtMs, + } + ); + } + + try { + if (rollbackDeleted && inserted.imagePublicId) { + await destroyProductImage(inserted.imagePublicId); + } + } catch (imgError) { + logError( + 'admin_product_create_audit_rollback_image_failed', + imgError, + { + ...baseMeta, + code: 'AUDIT_ROLLBACK_IMAGE_FAILED', + productId: inserted.id, + slug: inserted.slug, + imagePublicId: inserted.imagePublicId ?? null, + durationMs: Date.now() - startedAtMs, + } + ); + } + + throw auditError; + } + return noStoreJson( { success: true, diff --git a/frontend/app/api/shop/admin/returns/[id]/approve/route.ts b/frontend/app/api/shop/admin/returns/[id]/approve/route.ts new file mode 100644 index 00000000..f9917265 --- /dev/null +++ b/frontend/app/api/shop/admin/returns/[id]/approve/route.ts @@ -0,0 +1,116 @@ +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; + +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError, logWarn } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { approveReturnRequest } from '@/lib/services/shop/returns'; +import { returnRequestIdParamSchema } from '@/lib/validation/shop-returns'; + +function noStoreJson(body: unknown, status = 200) { + const res = NextResponse.json(body, { status }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function mapInvalidPayloadStatus(code: string): number { + if (code === 'RETURN_NOT_FOUND') return 404; + if (code === 'RETURN_TRANSITION_INVALID') return 409; + return 400; +} + +export const runtime = 'nodejs'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + let returnRequestIdForLog: string | null = null; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + const admin = await requireAdminApi(request); + const csrfRes = requireAdminCsrf(request, 'admin:returns:approve'); + if (csrfRes) { + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } + + const parsed = returnRequestIdParamSchema.safeParse(await context.params); + if (!parsed.success) { + return noStoreJson({ code: 'INVALID_RETURN_ID' }, 400); + } + returnRequestIdForLog = parsed.data.id; + + const result = await approveReturnRequest({ + returnRequestId: returnRequestIdForLog, + actorUserId: typeof admin.id === 'string' ? admin.id : null, + requestId, + }); + + return noStoreJson({ + success: true, + changed: result.changed, + returnRequest: { + ...result.row, + approvedAt: result.row.approvedAt?.toISOString() ?? null, + rejectedAt: result.row.rejectedAt?.toISOString() ?? null, + receivedAt: result.row.receivedAt?.toISOString() ?? null, + refundedAt: result.row.refundedAt?.toISOString() ?? null, + createdAt: result.row.createdAt.toISOString(), + updatedAt: result.row.updatedAt.toISOString(), + }, + }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: 'ADMIN_API_DISABLED' }, 403); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, 401); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, 403); + } + if (error instanceof InvalidPayloadError) { + logWarn('admin_return_approve_rejected', { + ...baseMeta, + returnRequestId: returnRequestIdForLog, + code: error.code, + }); + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + mapInvalidPayloadStatus(error.code) + ); + } + logError('admin_return_approve_failed', error, { + ...baseMeta, + returnRequestId: returnRequestIdForLog, + code: 'ADMIN_RETURN_APPROVE_FAILED', + }); + return noStoreJson({ code: 'INTERNAL_ERROR' }, 500); + } +} diff --git a/frontend/app/api/shop/admin/returns/[id]/receive/route.ts b/frontend/app/api/shop/admin/returns/[id]/receive/route.ts new file mode 100644 index 00000000..01735bd6 --- /dev/null +++ b/frontend/app/api/shop/admin/returns/[id]/receive/route.ts @@ -0,0 +1,116 @@ +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; + +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError, logWarn } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { receiveReturnRequest } from '@/lib/services/shop/returns'; +import { returnRequestIdParamSchema } from '@/lib/validation/shop-returns'; + +function noStoreJson(body: unknown, status = 200) { + const res = NextResponse.json(body, { status }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function mapInvalidPayloadStatus(code: string): number { + if (code === 'RETURN_NOT_FOUND') return 404; + if (code === 'RETURN_TRANSITION_INVALID') return 409; + return 400; +} + +export const runtime = 'nodejs'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + let returnRequestIdForLog: string | null = null; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + const admin = await requireAdminApi(request); + const csrfRes = requireAdminCsrf(request, 'admin:returns:receive'); + if (csrfRes) { + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } + + const parsed = returnRequestIdParamSchema.safeParse(await context.params); + if (!parsed.success) { + return noStoreJson({ code: 'INVALID_RETURN_ID' }, 400); + } + returnRequestIdForLog = parsed.data.id; + + const result = await receiveReturnRequest({ + returnRequestId: returnRequestIdForLog, + actorUserId: typeof admin.id === 'string' ? admin.id : null, + requestId, + }); + + return noStoreJson({ + success: true, + changed: result.changed, + returnRequest: { + ...result.row, + approvedAt: result.row.approvedAt?.toISOString() ?? null, + rejectedAt: result.row.rejectedAt?.toISOString() ?? null, + receivedAt: result.row.receivedAt?.toISOString() ?? null, + refundedAt: result.row.refundedAt?.toISOString() ?? null, + createdAt: result.row.createdAt.toISOString(), + updatedAt: result.row.updatedAt.toISOString(), + }, + }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: 'ADMIN_API_DISABLED' }, 403); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, 401); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, 403); + } + if (error instanceof InvalidPayloadError) { + logWarn('admin_return_receive_rejected', { + ...baseMeta, + returnRequestId: returnRequestIdForLog, + code: error.code, + }); + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + mapInvalidPayloadStatus(error.code) + ); + } + logError('admin_return_receive_failed', error, { + ...baseMeta, + returnRequestId: returnRequestIdForLog, + code: 'ADMIN_RETURN_RECEIVE_FAILED', + }); + return noStoreJson({ code: 'INTERNAL_ERROR' }, 500); + } +} diff --git a/frontend/app/api/shop/admin/returns/[id]/refund/route.ts b/frontend/app/api/shop/admin/returns/[id]/refund/route.ts new file mode 100644 index 00000000..e5c35829 --- /dev/null +++ b/frontend/app/api/shop/admin/returns/[id]/refund/route.ts @@ -0,0 +1,124 @@ +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; + +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError, logWarn } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { refundReturnRequest } from '@/lib/services/shop/returns'; +import { returnRequestIdParamSchema } from '@/lib/validation/shop-returns'; + +function noStoreJson(body: unknown, status = 200) { + const res = NextResponse.json(body, { status }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function mapInvalidPayloadStatus(code: string): number { + if (code === 'RETURN_NOT_FOUND') return 404; + if ( + code === 'RETURN_TRANSITION_INVALID' || + code === 'RETURN_REFUND_STATE_INVALID' || + code === 'RETURN_REFUND_PROVIDER_UNSUPPORTED' || + code === 'RETURN_REFUND_PAYMENT_STATUS_INVALID' + ) { + return 409; + } + if (code === 'PSP_UNAVAILABLE') return 503; + return 400; +} + +export const runtime = 'nodejs'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + let returnRequestIdForLog: string | null = null; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + const admin = await requireAdminApi(request); + const csrfRes = requireAdminCsrf(request, 'admin:returns:refund'); + if (csrfRes) { + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } + + const parsed = returnRequestIdParamSchema.safeParse(await context.params); + if (!parsed.success) { + return noStoreJson({ code: 'INVALID_RETURN_ID' }, 400); + } + returnRequestIdForLog = parsed.data.id; + + const result = await refundReturnRequest({ + returnRequestId: returnRequestIdForLog, + actorUserId: typeof admin.id === 'string' ? admin.id : null, + requestId, + }); + + return noStoreJson({ + success: true, + changed: result.changed, + returnRequest: { + ...result.row, + approvedAt: result.row.approvedAt?.toISOString() ?? null, + rejectedAt: result.row.rejectedAt?.toISOString() ?? null, + receivedAt: result.row.receivedAt?.toISOString() ?? null, + refundedAt: result.row.refundedAt?.toISOString() ?? null, + createdAt: result.row.createdAt.toISOString(), + updatedAt: result.row.updatedAt.toISOString(), + }, + }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: 'ADMIN_API_DISABLED' }, 403); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, 401); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, 403); + } + if (error instanceof InvalidPayloadError) { + logWarn('admin_return_refund_rejected', { + ...baseMeta, + returnRequestId: returnRequestIdForLog, + code: error.code, + }); + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + mapInvalidPayloadStatus(error.code) + ); + } + logError('admin_return_refund_failed', error, { + ...baseMeta, + returnRequestId: returnRequestIdForLog, + code: 'ADMIN_RETURN_REFUND_FAILED', + }); + return noStoreJson({ code: 'INTERNAL_ERROR' }, 500); + } +} diff --git a/frontend/app/api/shop/admin/returns/[id]/reject/route.ts b/frontend/app/api/shop/admin/returns/[id]/reject/route.ts new file mode 100644 index 00000000..3423b8bd --- /dev/null +++ b/frontend/app/api/shop/admin/returns/[id]/reject/route.ts @@ -0,0 +1,116 @@ +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; + +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError, logWarn } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { rejectReturnRequest } from '@/lib/services/shop/returns'; +import { returnRequestIdParamSchema } from '@/lib/validation/shop-returns'; + +function noStoreJson(body: unknown, status = 200) { + const res = NextResponse.json(body, { status }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function mapInvalidPayloadStatus(code: string): number { + if (code === 'RETURN_NOT_FOUND') return 404; + if (code === 'RETURN_TRANSITION_INVALID') return 409; + return 400; +} + +export const runtime = 'nodejs'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + let returnRequestIdForLog: string | null = null; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + const admin = await requireAdminApi(request); + const csrfRes = requireAdminCsrf(request, 'admin:returns:reject'); + if (csrfRes) { + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } + + const parsed = returnRequestIdParamSchema.safeParse(await context.params); + if (!parsed.success) { + return noStoreJson({ code: 'INVALID_RETURN_ID' }, 400); + } + returnRequestIdForLog = parsed.data.id; + + const result = await rejectReturnRequest({ + returnRequestId: returnRequestIdForLog, + actorUserId: typeof admin.id === 'string' ? admin.id : null, + requestId, + }); + + return noStoreJson({ + success: true, + changed: result.changed, + returnRequest: { + ...result.row, + approvedAt: result.row.approvedAt?.toISOString() ?? null, + rejectedAt: result.row.rejectedAt?.toISOString() ?? null, + receivedAt: result.row.receivedAt?.toISOString() ?? null, + refundedAt: result.row.refundedAt?.toISOString() ?? null, + createdAt: result.row.createdAt.toISOString(), + updatedAt: result.row.updatedAt.toISOString(), + }, + }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return noStoreJson({ code: 'ADMIN_API_DISABLED' }, 403); + } + if (error instanceof AdminUnauthorizedError) { + return noStoreJson({ code: error.code }, 401); + } + if (error instanceof AdminForbiddenError) { + return noStoreJson({ code: error.code }, 403); + } + if (error instanceof InvalidPayloadError) { + logWarn('admin_return_reject_rejected', { + ...baseMeta, + returnRequestId: returnRequestIdForLog, + code: error.code, + }); + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + mapInvalidPayloadStatus(error.code) + ); + } + logError('admin_return_reject_failed', error, { + ...baseMeta, + returnRequestId: returnRequestIdForLog, + code: 'ADMIN_RETURN_REJECT_FAILED', + }); + return noStoreJson({ code: 'INTERNAL_ERROR' }, 500); + } +} diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index 7cb8a1fe..5e24160d 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -49,6 +49,8 @@ const EXPECTED_BUSINESS_ERROR_CODES = new Set([ 'INVALID_SHIPPING_ADDRESS', 'SHIPPING_METHOD_UNAVAILABLE', 'SHIPPING_CURRENCY_UNSUPPORTED', + 'TERMS_NOT_ACCEPTED', + 'PRIVACY_NOT_ACCEPTED', ]); const DEFAULT_CHECKOUT_RATE_LIMIT_MAX = 10; @@ -681,7 +683,7 @@ export async function POST(request: NextRequest) { ); } - const { items, userId, shipping, country } = parsedPayload.data; + const { items, userId, shipping, country, legalConsent } = parsedPayload.data; const itemCount = items.reduce((total, item) => total + item.quantity, 0); const locale = resolveRequestLocale(request); @@ -785,6 +787,7 @@ export async function POST(request: NextRequest) { locale, country: country ?? null, shipping: shipping ?? null, + legalConsent: legalConsent ?? null, paymentProvider: selectedProvider === 'monobank' ? 'monobank' : undefined, }); diff --git a/frontend/app/api/shop/internal/notifications/run/route.ts b/frontend/app/api/shop/internal/notifications/run/route.ts new file mode 100644 index 00000000..cc06ae4a --- /dev/null +++ b/frontend/app/api/shop/internal/notifications/run/route.ts @@ -0,0 +1,162 @@ +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; + +import { requireInternalJanitorAuth } from '@/lib/auth/internal-janitor'; +import { logError, logWarn } from '@/lib/logging'; +import { guardNonBrowserFailClosed } from '@/lib/security/origin'; +import { + countRunnableNotificationOutboxRows, + runNotificationOutboxWorker, +} from '@/lib/services/shop/notifications/outbox-worker'; +import { runNotificationOutboxProjector } from '@/lib/services/shop/notifications/projector'; +import { internalNotificationsRunPayloadSchema } from '@/lib/validation/shop-notifications'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +const ROUTE_PATH = '/api/shop/internal/notifications/run'; + +function noStoreJson(body: unknown, requestId: string, status = 200) { + const res = NextResponse.json(body, { status }); + res.headers.set('Cache-Control', 'no-store'); + res.headers.set('X-Request-Id', requestId); + return res; +} + +async function readJsonBodyOrDefault(request: NextRequest): Promise { + const raw = await request.text(); + if (!raw.trim()) return {}; + return JSON.parse(raw); +} + +export async function POST(request: NextRequest) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const runId = crypto.randomUUID(); + const baseMeta = { + requestId, + runId, + route: ROUTE_PATH, + method: request.method, + }; + + const blocked = guardNonBrowserFailClosed(request, { + surface: 'shop_notifications_worker', + }); + if (blocked) { + blocked.headers.set('X-Request-Id', requestId); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const authRes = requireInternalJanitorAuth(request); + if (authRes) { + authRes.headers.set('X-Request-Id', requestId); + authRes.headers.set('Cache-Control', 'no-store'); + return authRes; + } + + const contentType = (request.headers.get('content-type') ?? '').toLowerCase(); + if (!contentType.includes('application/json')) { + return noStoreJson( + { + success: false, + code: 'INVALID_PAYLOAD', + message: 'Content-Type must be application/json', + }, + requestId, + 400 + ); + } + + let rawBody: unknown; + try { + rawBody = await readJsonBodyOrDefault(request); + } catch { + return noStoreJson( + { + success: false, + code: 'INVALID_PAYLOAD', + message: 'Invalid JSON body', + }, + requestId, + 400 + ); + } + + const parsed = internalNotificationsRunPayloadSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + success: false, + code: 'INVALID_PAYLOAD', + message: 'Invalid payload', + }, + requestId, + 400 + ); + } + + const payload = parsed.data; + + try { + const projected = await runNotificationOutboxProjector({ + limit: payload.projectorLimit, + }); + + if (payload.dryRun) { + const runnable = await countRunnableNotificationOutboxRows(); + return noStoreJson( + { + success: true, + dryRun: true, + runId, + projector: projected, + runnable, + }, + requestId, + 200 + ); + } + + const workerResult = await runNotificationOutboxWorker({ + runId, + limit: payload.limit, + leaseSeconds: payload.leaseSeconds, + maxAttempts: payload.maxAttempts, + baseBackoffSeconds: payload.baseBackoffSeconds, + }); + + return noStoreJson( + { + success: true, + dryRun: false, + runId, + projector: projected, + worker: workerResult, + }, + requestId, + 200 + ); + } catch (error) { + logWarn('shop_notifications_worker_failed', { + ...baseMeta, + code: 'SHOP_NOTIFICATIONS_WORKER_FAILED', + }); + logError('shop_notifications_worker_failed_error', error, { + ...baseMeta, + code: 'SHOP_NOTIFICATIONS_WORKER_FAILED', + }); + + return noStoreJson( + { + success: false, + code: 'INTERNAL_ERROR', + }, + requestId, + 500 + ); + } +} 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 d27106e1..e0a8d792 100644 --- a/frontend/app/api/shop/internal/orders/restock-stale/route.ts +++ b/frontend/app/api/shop/internal/orders/restock-stale/route.ts @@ -12,6 +12,10 @@ import { restockStalePendingOrders, restockStuckReservingOrders, } from '@/lib/services/orders'; +import { + sweepAcceptedIntlQuotePaymentTimeouts, + sweepExpiredOfferedIntlQuotes, +} from '@/lib/services/shop/quotes'; export const runtime = 'nodejs'; @@ -473,10 +477,30 @@ export async function POST(request: NextRequest) { timeBudgetMs: remaining2, }); + const remaining3 = Math.max(0, deadlineMs - Date.now()); + const processedIntlQuoteExpired = + remaining3 > 0 + ? await sweepExpiredOfferedIntlQuotes({ + batchSize: policy.batchSize, + now: new Date(), + }) + : 0; + + const remaining4 = Math.max(0, deadlineMs - Date.now()); + const processedIntlQuotePaymentTimeouts = + remaining4 > 0 + ? await sweepAcceptedIntlQuotePaymentTimeouts({ + batchSize: policy.batchSize, + now: new Date(), + }) + : 0; + const processed = processedStuckReserving + processedStalePending + - processedOrphanNoPayment; + processedOrphanNoPayment + + processedIntlQuoteExpired + + processedIntlQuotePaymentTimeouts; logInfo('internal_janitor_run_completed', { ...baseMeta, @@ -489,6 +513,8 @@ export async function POST(request: NextRequest) { stuckReserving: processedStuckReserving, stalePending: processedStalePending, orphanNoPayment: processedOrphanNoPayment, + intlQuoteExpired: processedIntlQuoteExpired, + intlQuotePaymentTimeout: processedIntlQuotePaymentTimeouts, }, batchSize: policy.batchSize, appliedPolicy: policy, @@ -509,6 +535,8 @@ export async function POST(request: NextRequest) { stuckReserving: processedStuckReserving, stalePending: processedStalePending, orphanNoPayment: processedOrphanNoPayment, + intlQuoteExpired: processedIntlQuoteExpired, + intlQuotePaymentTimeout: processedIntlQuotePaymentTimeouts, }, batchSize: policy.batchSize, olderThanMinutes: policy.olderThanMinutes.stalePending, diff --git a/frontend/app/api/shop/orders/[id]/payment/init/route.ts b/frontend/app/api/shop/orders/[id]/payment/init/route.ts new file mode 100644 index 00000000..77b75c75 --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/payment/init/route.ts @@ -0,0 +1,178 @@ +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; + +import { logError, logWarn } from '@/lib/logging'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + InvalidPayloadError, + OrderNotFoundError, + OrderStateInvalidError, +} from '@/lib/services/errors'; +import { + ensureStripePaymentIntentForOrder, + PaymentAttemptsExhaustedError, +} from '@/lib/services/orders/payment-attempts'; +import { authorizeOrderMutationAccess } from '@/lib/services/shop/order-access'; +import { assertIntlPaymentInitAllowed } from '@/lib/services/shop/quotes'; +import { + orderIdParamSchema, + orderPaymentInitPayloadSchema, +} from '@/lib/validation/shop'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function mapInitErrorStatus(code: string): number { + if ( + code === 'QUOTE_NOT_ACCEPTED' || + code === 'QUOTE_INVENTORY_NOT_RESERVED' || + code === 'QUOTE_VERSION_CONFLICT' || + code === 'PAYMENT_PROVIDER_NOT_ALLOWED_FOR_INTL' + ) { + return 409; + } + if (code === 'QUOTE_PAYMENT_WINDOW_EXPIRED' || code === 'QUOTE_EXPIRED') { + return 410; + } + return 400; +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) return blocked; + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson( + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, + { status: 400 } + ); + } + const orderId = parsedParams.data.id; + + let rawBody: unknown = {}; + try { + const raw = await request.text(); + if (raw.trim()) rawBody = JSON.parse(raw); + } catch { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid JSON body.' }, + { status: 400 } + ); + } + + const parsedBody = orderPaymentInitPayloadSchema.safeParse(rawBody); + if (!parsedBody.success) { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid payload.' }, + { status: 400 } + ); + } + + const statusToken = request.nextUrl.searchParams.get('statusToken'); + const auth = await authorizeOrderMutationAccess({ + orderId, + statusToken, + requiredScope: 'order_payment_init', + }); + if (!auth.authorized) { + return noStoreJson({ code: auth.code }, { status: auth.status }); + } + + try { + const provider = parsedBody.data.provider; + await assertIntlPaymentInitAllowed({ + orderId, + provider, + }); + + const ensured = await ensureStripePaymentIntentForOrder({ + orderId, + }); + + return noStoreJson( + { + success: true, + orderId, + provider, + paymentIntentId: ensured.paymentIntentId, + clientSecret: ensured.clientSecret, + attemptId: ensured.attemptId, + attemptNumber: ensured.attemptNumber, + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof OrderNotFoundError) { + return noStoreJson({ code: error.code }, { status: 404 }); + } + if (error instanceof PaymentAttemptsExhaustedError) { + return noStoreJson( + { + code: error.code, + message: 'Payment attempts exhausted for this order.', + details: { + orderId: error.orderId, + provider: error.provider, + }, + }, + { status: 409 } + ); + } + if (error instanceof InvalidPayloadError) { + logWarn('order_payment_init_rejected', { + ...baseMeta, + orderId, + code: error.code, + }); + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + { status: mapInitErrorStatus(error.code) } + ); + } + if (error instanceof OrderStateInvalidError) { + logWarn('order_payment_init_state_invalid', { + ...baseMeta, + orderId, + code: error.code, + }); + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + { status: 409 } + ); + } + + logError('order_payment_init_failed', error, { + ...baseMeta, + orderId, + code: 'ORDER_PAYMENT_INIT_FAILED', + }); + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to initialize payment.' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/shop/orders/[id]/quote/accept/route.ts b/frontend/app/api/shop/orders/[id]/quote/accept/route.ts new file mode 100644 index 00000000..35ab666d --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/quote/accept/route.ts @@ -0,0 +1,129 @@ +import crypto from 'node:crypto'; + +import { NextRequest } from 'next/server'; + +import { logError, logWarn } from '@/lib/logging'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + InvalidPayloadError, + OrderNotFoundError, +} from '@/lib/services/errors'; +import { authorizeOrderMutationAccess } from '@/lib/services/shop/order-access'; +import { acceptIntlQuote } from '@/lib/services/shop/quotes'; +import { + intlQuoteAcceptPayloadSchema, + orderIdParamSchema, +} from '@/lib/validation/shop'; + +import { mapQuoteErrorStatus, noStoreJson } from '../quote-utils'; + +export const runtime = 'nodejs'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson( + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, + { status: 400 } + ); + } + const orderId = parsedParams.data.id; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid JSON body.' }, + { status: 400 } + ); + } + const parsedBody = intlQuoteAcceptPayloadSchema.safeParse(rawBody); + if (!parsedBody.success) { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid payload.' }, + { status: 400 } + ); + } + + const statusToken = request.nextUrl.searchParams.get('statusToken'); + const auth = await authorizeOrderMutationAccess({ + orderId, + statusToken, + requiredScope: 'order_quote_accept', + }); + if (!auth.authorized) { + return noStoreJson({ code: auth.code }, { status: auth.status }); + } + + try { + const result = await acceptIntlQuote({ + orderId, + requestId, + actorUserId: auth.actorUserId, + version: parsedBody.data.version, + }); + + return noStoreJson( + { + success: true, + orderId: result.orderId, + version: result.version, + quoteStatus: result.quoteStatus, + changed: result.changed, + paymentDeadlineAt: result.paymentDeadlineAt?.toISOString() ?? null, + totalAmountMinor: result.totalAmountMinor ?? null, + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof OrderNotFoundError) { + return noStoreJson({ code: error.code }, { status: 404 }); + } + + if (error instanceof InvalidPayloadError) { + logWarn('quote_accept_rejected', { + ...baseMeta, + orderId, + code: error.code, + }); + + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + { status: mapQuoteErrorStatus(error.code, 'accept') } + ); + } + + logError('quote_accept_failed', error, { + ...baseMeta, + orderId, + code: 'QUOTE_ACCEPT_FAILED', + }); + + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to accept quote.' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/shop/orders/[id]/quote/decline/route.ts b/frontend/app/api/shop/orders/[id]/quote/decline/route.ts new file mode 100644 index 00000000..b7b3a204 --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/quote/decline/route.ts @@ -0,0 +1,129 @@ +import crypto from 'node:crypto'; + +import { NextRequest } from 'next/server'; + +import { logError, logWarn } from '@/lib/logging'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + InvalidPayloadError, + OrderNotFoundError, +} from '@/lib/services/errors'; +import { authorizeOrderMutationAccess } from '@/lib/services/shop/order-access'; +import { declineIntlQuote } from '@/lib/services/shop/quotes'; +import { + intlQuoteDeclinePayloadSchema, + orderIdParamSchema, +} from '@/lib/validation/shop'; + +import { mapQuoteErrorStatus, noStoreJson } from '../quote-utils'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson( + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, + { status: 400 } + ); + } + + let rawBody: unknown = {}; + try { + const raw = await request.text(); + if (raw.trim()) { + rawBody = JSON.parse(raw); + } + } catch { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid JSON body.' }, + { status: 400 } + ); + } + + const parsedBody = intlQuoteDeclinePayloadSchema.safeParse(rawBody); + if (!parsedBody.success) { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid payload.' }, + { status: 400 } + ); + } + + const orderId = parsedParams.data.id; + const statusToken = request.nextUrl.searchParams.get('statusToken'); + const auth = await authorizeOrderMutationAccess({ + orderId, + statusToken, + requiredScope: 'order_quote_decline', + }); + if (!auth.authorized) { + return noStoreJson({ code: auth.code }, { status: auth.status }); + } + + try { + const result = await declineIntlQuote({ + orderId, + requestId, + actorUserId: auth.actorUserId, + version: parsedBody.data.version ?? null, + }); + + return noStoreJson( + { + success: true, + orderId: result.orderId, + version: result.version ?? null, + quoteStatus: result.quoteStatus, + changed: result.changed, + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof OrderNotFoundError) { + return noStoreJson({ code: error.code }, { status: 404 }); + } + + if (error instanceof InvalidPayloadError) { + logWarn('quote_decline_rejected', { + ...baseMeta, + orderId, + code: error.code, + }); + + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + { status: mapQuoteErrorStatus(error.code, 'decline') } + ); + } + + logError('quote_decline_failed', error, { + ...baseMeta, + orderId, + code: 'QUOTE_DECLINE_FAILED', + }); + + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to decline quote.' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/shop/orders/[id]/quote/quote-utils.ts b/frontend/app/api/shop/orders/[id]/quote/quote-utils.ts new file mode 100644 index 00000000..6b57f076 --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/quote/quote-utils.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; + +type QuoteErrorStatusMode = 'accept' | 'decline' | 'request'; + +const CONFLICT_CODES = new Set([ + 'QUOTE_NOT_APPLICABLE', + 'QUOTE_ALREADY_ACCEPTED', + 'QUOTE_NOT_OFFERED', +]); + +const VERSION_CONFLICT_MODES = new Set([ + 'accept', + 'decline', +]); + +export function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export function mapQuoteErrorStatus( + code: string, + mode: QuoteErrorStatusMode +): number { + if ( + CONFLICT_CODES.has(code) || + (code === 'QUOTE_VERSION_CONFLICT' && VERSION_CONFLICT_MODES.has(mode)) || + (code === 'QUOTE_STOCK_UNAVAILABLE' && mode === 'accept') + ) { + return 409; + } + if (code === 'QUOTE_EXPIRED' && mode !== 'request') return 410; + return 400; +} diff --git a/frontend/app/api/shop/orders/[id]/quote/request/route.ts b/frontend/app/api/shop/orders/[id]/quote/request/route.ts new file mode 100644 index 00000000..3d2c9efa --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/quote/request/route.ts @@ -0,0 +1,117 @@ +import crypto from 'node:crypto'; + +import { NextRequest } from 'next/server'; + +import { logError, logWarn } from '@/lib/logging'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + InvalidPayloadError, + OrderNotFoundError, +} from '@/lib/services/errors'; +import { authorizeOrderMutationAccess } from '@/lib/services/shop/order-access'; +import { requestIntlQuote } from '@/lib/services/shop/quotes'; +import { orderIdParamSchema } from '@/lib/validation/shop'; + +import { mapQuoteErrorStatus, noStoreJson } from '../quote-utils'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const raw = request.headers.get('x-request-id'); + const candidateRequestId = raw?.trim() ?? ''; + const requestId = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + candidateRequestId + ) + ? candidateRequestId + : crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson( + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, + { status: 400 } + ); + } + + const orderId = parsedParams.data.id; + const statusToken = request.nextUrl.searchParams.get('statusToken'); + + const auth = await authorizeOrderMutationAccess({ + orderId, + statusToken, + requiredScope: 'order_quote_request', + }); + if (!auth.authorized) { + return noStoreJson({ code: auth.code }, { status: auth.status }); + } + + try { + const result = await requestIntlQuote({ + orderId, + requestId, + actorUserId: auth.actorUserId, + }); + + return noStoreJson( + { + success: true, + orderId: result.orderId, + quoteStatus: result.quoteStatus, + changed: result.changed, + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof OrderNotFoundError) { + return noStoreJson({ code: error.code }, { status: 404 }); + } + + if (error instanceof InvalidPayloadError) { + const detailsKeys = + error.details && + typeof error.details === 'object' && + !Array.isArray(error.details) + ? Object.keys(error.details as Record).slice(0, 20) + : null; + + logWarn('quote_request_rejected', { + ...baseMeta, + orderId, + action: 'order_quote_request', + code: error.code, + ...(detailsKeys ? { detailsKeys } : {}), + }); + + return noStoreJson( + { + code: error.code, + message: error.message, + }, + { status: mapQuoteErrorStatus(error.code, 'request') } + ); + } + + logError('quote_request_failed', error, { + ...baseMeta, + orderId, + code: 'QUOTE_REQUEST_FAILED', + }); + + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to request quote.' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/shop/orders/[id]/returns/route.ts b/frontend/app/api/shop/orders/[id]/returns/route.ts new file mode 100644 index 00000000..c16f940d --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/returns/route.ts @@ -0,0 +1,247 @@ +import crypto from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; +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 { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { createReturnRequest, listOrderReturns } from '@/lib/services/shop/returns'; +import { orderIdParamSchema } from '@/lib/validation/shop'; +import { createReturnPayloadSchema } from '@/lib/validation/shop-returns'; + +function noStoreJson(body: unknown, status = 200) { + const res = NextResponse.json(body, { status }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +function mapInvalidPayloadStatus(code: string): number { + if (code === 'RETURN_NOT_FOUND') return 404; + if (code === 'RETURN_ALREADY_EXISTS') return 409; + if (code === 'PSP_UNAVAILABLE') return 503; + return 400; +} + +async function assertOwnerOrderAccess(args: { + orderId: string; + userId: string; +}): Promise { + const [owned] = await db + .select({ id: orders.id }) + .from(orders) + .where(and(eq(orders.id, args.orderId), eq(orders.userId, args.userId))) + .limit(1); + return !!owned; +} + +async function assertAdminOrOwnerAccess(args: { + orderId: string; + userId: string; + role: string | null | undefined; +}): Promise { + if (args.role === 'admin') { + const [exists] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.id, args.orderId)) + .limit(1); + return !!exists; + } + return assertOwnerOrderAccess({ orderId: args.orderId, userId: args.userId }); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const user = await getCurrentUser(); + if (!user) { + return noStoreJson({ code: 'UNAUTHORIZED' }, 401); + } + if (user.role === 'admin') { + return noStoreJson({ code: 'FORBIDDEN' }, 403); + } + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson({ code: 'INVALID_ORDER_ID' }, 400); + } + const orderId = parsedParams.data.id; + + const ownsOrder = await assertOwnerOrderAccess({ orderId, userId: user.id }); + if (!ownsOrder) { + return noStoreJson({ code: 'ORDER_NOT_FOUND' }, 404); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid JSON body.' }, + 400 + ); + } + + const parsedPayload = createReturnPayloadSchema.safeParse(body); + if (!parsedPayload.success) { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid payload.' }, + 400 + ); + } + + if (parsedPayload.data.resolution === 'exchange') { + logWarn('order_returns_exchange_not_supported', { + ...baseMeta, + orderId, + code: 'EXCHANGES_NOT_SUPPORTED', + }); + return noStoreJson( + { + code: 'EXCHANGES_NOT_SUPPORTED', + message: 'Exchanges are not supported. Please create a return refund request.', + }, + 422 + ); + } + + try { + const result = await createReturnRequest({ + orderId, + actorUserId: user.id, + idempotencyKey: parsedPayload.data.idempotencyKey, + reason: parsedPayload.data.reason ?? null, + policyRestock: parsedPayload.data.policyRestock, + requestId, + }); + + return noStoreJson( + { + success: true, + created: result.created, + returnRequest: { + ...result.request, + approvedAt: result.request.approvedAt?.toISOString() ?? null, + rejectedAt: result.request.rejectedAt?.toISOString() ?? null, + receivedAt: result.request.receivedAt?.toISOString() ?? null, + refundedAt: result.request.refundedAt?.toISOString() ?? null, + createdAt: result.request.createdAt.toISOString(), + updatedAt: result.request.updatedAt.toISOString(), + items: result.request.items.map(item => ({ + ...item, + createdAt: item.createdAt.toISOString(), + })), + }, + }, + result.created ? 201 : 200 + ); + } catch (error) { + if (error instanceof InvalidPayloadError) { + logWarn('order_returns_create_rejected', { + ...baseMeta, + orderId, + code: error.code, + }); + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + mapInvalidPayloadStatus(error.code) + ); + } + + logError('order_returns_create_failed', error, { + ...baseMeta, + orderId, + code: 'ORDER_RETURNS_CREATE_FAILED', + }); + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to create return request.' }, + 500 + ); + } +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const user = await getCurrentUser(); + if (!user) { + return noStoreJson({ code: 'UNAUTHORIZED' }, 401); + } + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson({ code: 'INVALID_ORDER_ID' }, 400); + } + const orderId = parsedParams.data.id; + + const allowed = await assertAdminOrOwnerAccess({ + orderId, + userId: user.id, + role: user.role, + }); + if (!allowed) { + return noStoreJson({ code: 'ORDER_NOT_FOUND' }, 404); + } + + try { + const rows = await listOrderReturns(orderId); + return noStoreJson({ + success: true, + returns: rows.map(row => ({ + ...row, + approvedAt: row.approvedAt?.toISOString() ?? null, + rejectedAt: row.rejectedAt?.toISOString() ?? null, + receivedAt: row.receivedAt?.toISOString() ?? null, + refundedAt: row.refundedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + items: row.items.map(item => ({ + ...item, + createdAt: item.createdAt.toISOString(), + })), + })), + }); + } catch (error) { + logError('order_returns_list_failed', error, { + ...baseMeta, + orderId, + code: 'ORDER_RETURNS_LIST_FAILED', + }); + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to load returns.' }, + 500 + ); + } +} diff --git a/frontend/app/api/shop/orders/[id]/status/route.ts b/frontend/app/api/shop/orders/[id]/status/route.ts index c8ea0f5e..51b4e701 100644 --- a/frontend/app/api/shop/orders/[id]/status/route.ts +++ b/frontend/app/api/shop/orders/[id]/status/route.ts @@ -13,12 +13,16 @@ import { OrderNotFoundError, OrderStateInvalidError, } from '@/lib/services/errors'; +import { writeAdminAudit } from '@/lib/services/shop/events/write-admin-audit'; import { getOrderAttemptSummary, getOrderStatusLiteSummary, getOrderSummary, } from '@/lib/services/orders/summary'; -import { verifyStatusToken } from '@/lib/shop/status-token'; +import { + hasStatusTokenScope, + verifyStatusToken, +} from '@/lib/shop/status-token'; import { orderIdParamSchema } from '@/lib/validation/shop'; export const dynamic = 'force-dynamic'; @@ -34,7 +38,7 @@ export async function GET( context: { params: Promise<{ id: string }> } ) { const startedAtMs = Date.now(); - const responseMode = + const requestedResponseMode = request.nextUrl.searchParams.get('view') === 'lite' ? 'lite' : 'full'; const requestId = request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); @@ -46,7 +50,7 @@ export async function GET( requestId, code: 'INVALID_ORDER_ID', orderId: null, - responseMode, + responseMode: requestedResponseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code: 'INVALID_ORDER_ID' }, { status: 400 }); @@ -58,6 +62,14 @@ export async function GET( try { const user = await getCurrentUser(); let authorized = false; + let accessByStatusToken = false; + let tokenAuditSeed: + | { + nonce: string; + iat: number; + exp: number; + } + | null = null; if (user) { const isAdmin = user.role === 'admin'; @@ -81,7 +93,7 @@ export async function GET( requestId, orderId, code, - responseMode, + responseMode: requestedResponseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code }, { status }); @@ -100,7 +112,7 @@ export async function GET( requestId, orderId, code: 'STATUS_TOKEN_MISCONFIGURED', - responseMode, + responseMode: requestedResponseMode, durationMs: Date.now() - startedAtMs, } ); @@ -114,11 +126,86 @@ export async function GET( requestId, orderId, code: 'STATUS_TOKEN_INVALID', - responseMode, + responseMode: requestedResponseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code: 'STATUS_TOKEN_INVALID' }, { status: 403 }); } + + accessByStatusToken = true; + if (!hasStatusTokenScope(tokenResult.payload, 'status_lite')) { + logWarn('order_status_token_scope_forbidden', { + requestId, + orderId, + code: 'STATUS_TOKEN_SCOPE_FORBIDDEN', + responseMode: requestedResponseMode, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( + { code: 'STATUS_TOKEN_SCOPE_FORBIDDEN' }, + { status: 403 } + ); + } + + tokenAuditSeed = { + nonce: tokenResult.payload.nonce, + iat: tokenResult.payload.iat, + exp: tokenResult.payload.exp, + }; + authorized = true; + } + + const effectiveResponseMode = accessByStatusToken + ? 'lite' + : requestedResponseMode; + + if (accessByStatusToken && tokenAuditSeed) { + try { + await writeAdminAudit({ + orderId, + actorUserId: null, + action: 'guest_status_token.used', + targetType: 'order_status', + targetId: orderId, + requestId, + payload: { + scope: 'status_lite', + tokenNonce: tokenAuditSeed.nonce, + tokenIat: tokenAuditSeed.iat, + tokenExp: tokenAuditSeed.exp, + }, + dedupeSeed: { + domain: 'guest_status_token_use', + orderId, + tokenNonce: tokenAuditSeed.nonce, + scope: 'status_lite', + }, + }); + } catch (auditError) { + logWarn('order_status_guest_token_audit_failed', { + requestId, + orderId, + code: 'AUDIT_WRITE_FAILED', + message: + auditError instanceof Error + ? auditError.message + : String(auditError), + responseMode: effectiveResponseMode, + durationMs: Date.now() - startedAtMs, + }); + } + } + + if (effectiveResponseMode === 'lite') { + const liteOrder = await getOrderStatusLiteSummary(orderId); + logInfo('order_status_responded', { + requestId, + orderId, + responseMode: effectiveResponseMode, + authMode: accessByStatusToken ? 'guest_token' : 'session', + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson(liteOrder, { status: 200 }); } if (responseMode === 'lite') { @@ -137,7 +224,8 @@ export async function GET( logInfo('order_status_responded', { requestId, orderId, - responseMode, + responseMode: effectiveResponseMode, + authMode: 'session', durationMs: Date.now() - startedAtMs, }); return noStoreJson({ success: true, order, attempt }, { status: 200 }); @@ -147,7 +235,7 @@ export async function GET( requestId, code: 'ORDER_NOT_FOUND', orderId, - responseMode, + responseMode: requestedResponseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code: 'ORDER_NOT_FOUND' }, { status: 404 }); @@ -158,7 +246,7 @@ export async function GET( requestId, code: 'INTERNAL_ERROR', orderId, - responseMode, + responseMode: requestedResponseMode, durationMs: Date.now() - startedAtMs, }); return noStoreJson({ code: 'INTERNAL_ERROR' }, { status: 500 }); @@ -168,7 +256,7 @@ export async function GET( requestId, code: 'ORDER_STATUS_FAILED', orderId, - responseMode, + responseMode: requestedResponseMode, durationMs: Date.now() - startedAtMs, }); diff --git a/frontend/app/api/shop/webhooks/monobank/route.ts b/frontend/app/api/shop/webhooks/monobank/route.ts index 88b00dc7..026c295a 100644 --- a/frontend/app/api/shop/webhooks/monobank/route.ts +++ b/frontend/app/api/shop/webhooks/monobank/route.ts @@ -22,6 +22,10 @@ import { getRateLimitSubject, rateLimitResponse, } from '@/lib/security/rate-limit'; +import { + getMonobankApplyErrorCode, + isRetryableApplyError, +} from '@/lib/services/orders/monobank-retry'; import { handleMonobankWebhook } from '@/lib/services/orders/monobank-webhook'; export const dynamic = 'force-dynamic'; @@ -32,6 +36,7 @@ const DEFAULT_MONO_WEBHOOK_MISSING_SIG_LIMIT = 30; const DEFAULT_MONO_WEBHOOK_MISSING_SIG_WINDOW_SECONDS = 60; const DEFAULT_MONO_WEBHOOK_INVALID_SIG_LIMIT = 30; const DEFAULT_MONO_WEBHOOK_INVALID_SIG_WINDOW_SECONDS = 60; +const MONO_WEBHOOK_RETRY_AFTER_SECONDS = 10; function parseWebhookMode(raw: unknown): WebhookMode { const v = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; @@ -39,8 +44,14 @@ function parseWebhookMode(raw: unknown): WebhookMode { return 'apply'; } -function noStoreJson(body: unknown, init?: { status?: number }) { - const res = NextResponse.json(body, { status: init?.status ?? 200 }); +function noStoreJson( + body: unknown, + init?: { status?: number; headers?: HeadersInit } +) { + const res = NextResponse.json(body, { + status: init?.status ?? 200, + headers: init?.headers, + }); res.headers.set('Cache-Control', 'no-store'); return res; } @@ -258,12 +269,30 @@ export async function POST(request: NextRequest) { reason: 'PROCESSED', }); } catch (error) { + const retryable = isRetryableApplyError(error); logError('monobank_webhook_apply_failed', error, { ...diagMeta, code: 'WEBHOOK_APPLY_FAILED', eventKey, + errorCode: getMonobankApplyErrorCode(error), + retryable, reason: 'WEBHOOK_APPLY_FAILED', }); + + if (retryable) { + return noStoreJson( + { + code: 'WEBHOOK_RETRYABLE', + retryAfterSeconds: MONO_WEBHOOK_RETRY_AFTER_SECONDS, + }, + { + status: 503, + headers: { + 'Retry-After': String(MONO_WEBHOOK_RETRY_AFTER_SECONDS), + }, + } + ); + } } return noStoreJson({ ok: true }, { status: 200 }); diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 2ba7ae87..e3a63a6f 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -6,6 +6,7 @@ import Stripe from 'stripe'; import { db } from '@/db'; import { orders, stripeEvents } from '@/db/schema'; +import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; import { logError, logInfo, logWarn } from '@/lib/logging'; import { retrieveCharge, verifyWebhookSignature } from '@/lib/psp/stripe'; import { guardNonBrowserOnly } from '@/lib/security/origin'; @@ -22,8 +23,10 @@ import { appendRefundToMeta, type RefundMetaRecord, } from '@/lib/services/orders/psp-metadata/refunds'; +import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; import { inventoryCommittedForShippingSql } from '@/lib/services/shop/shipping/inventory-eligibility'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; +import { shippingStatusTransitionWhereSql } from '@/lib/services/shop/transitions/shipping-state'; const REFUND_FULLNESS_UNDETERMINED = 'REFUND_FULLNESS_UNDETERMINED' as const; @@ -401,21 +404,271 @@ function readDbRows(res: unknown): T[] { type StripePaidApplyArgs = { now: Date; orderId: string; + paymentIntentId: string | null; + stripeEventId: string; pspChargeId: string | null; pspPaymentMethod: string | null; pspStatusReason: string; pspMetadata: Record; paymentBecamePaidInThisApply: boolean; + canonicalDualWriteEnabled: boolean; + canonicalEventDedupeKey: string; + canonicalEventPayload: Record; }; async function applyStripePaidAndQueueShipmentAtomic( args: StripePaidApplyArgs ): Promise<{ applied: boolean; shipmentQueued: boolean }> { + const res = args.canonicalDualWriteEnabled + ? await db.execute(sql` + with updated_order as ( + update orders + set payment_status = 'paid', + status = 'PAID', + updated_at = ${args.now}, + psp_charge_id = ${args.pspChargeId}, + psp_payment_method = ${args.pspPaymentMethod}, + psp_status_reason = ${args.pspStatusReason}, + psp_metadata = ${JSON.stringify(args.pspMetadata)}::jsonb + where id = ${args.orderId}::uuid + and payment_provider = 'stripe' + and payment_status in ('pending', 'requires_payment', 'paid') + and (stock_restored is null or stock_restored = false) + and (inventory_status is null or inventory_status <> 'released') + and (payment_status <> 'paid' or status <> 'PAID') + and payment_status <> 'failed' + and payment_status <> 'refunded' + returning + id, + total_amount_minor, + currency, + payment_status, + inventory_status, + shipping_required, + shipping_provider, + shipping_method_code + ), + inserted_payment_event as ( + insert into payment_events ( + order_id, + provider, + event_name, + event_source, + event_ref, + attempt_id, + provider_payment_intent_id, + provider_charge_id, + amount_minor, + currency, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + 'stripe', + 'paid_applied', + 'stripe_webhook', + ${args.stripeEventId}, + null, + ${args.paymentIntentId}, + ${args.pspChargeId}, + uo.total_amount_minor::bigint, + uo.currency, + ${JSON.stringify(args.canonicalEventPayload)}::jsonb, + ${args.canonicalEventDedupeKey}, + ${args.now}, + ${args.now} + from updated_order uo + on conflict (dedupe_key) do nothing + returning id + ), + 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 ( + order_id, + provider, + status, + attempt_count, + created_at, + updated_at + ) + select + id, + 'nova_poshta', + 'queued', + 0, + ${args.now}, + ${args.now} + from eligible_for_enqueue + 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} + where id in (select order_id from queued_order_ids) + and shipping_status is distinct from 'queued'::shipping_status + and ${shippingStatusTransitionWhereSql({ + column: sql`shipping_status`, + to: 'queued', + allowNullFrom: true, + })} + 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 + `) + : await db.execute(sql` + with updated_order as ( + update orders + set payment_status = 'paid', + status = 'PAID', + updated_at = ${args.now}, + psp_charge_id = ${args.pspChargeId}, + psp_payment_method = ${args.pspPaymentMethod}, + psp_status_reason = ${args.pspStatusReason}, + psp_metadata = ${JSON.stringify(args.pspMetadata)}::jsonb + where id = ${args.orderId}::uuid + and payment_provider = 'stripe' + and payment_status in ('pending', 'requires_payment', 'paid') + and (stock_restored is null or stock_restored = false) + and (inventory_status is null or inventory_status <> 'released') + and (payment_status <> 'paid' or status <> 'PAID') + and payment_status <> 'failed' + and payment_status <> 'refunded' + returning + id, + payment_status, + inventory_status, + shipping_required, + shipping_provider, + shipping_method_code + ), + 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 ( + order_id, + provider, + status, + attempt_count, + created_at, + updated_at + ) + select + id, + 'nova_poshta', + 'queued', + 0, + ${args.now}, + ${args.now} + from eligible_for_enqueue + 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} + where id in (select order_id from queued_order_ids) + and shipping_status is distinct from 'queued'::shipping_status + and ${shippingStatusTransitionWhereSql({ + column: sql`shipping_status`, + to: 'queued', + allowNullFrom: true, + })} + 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?.queued_shipment_count ?? 0) > 0, + }; +} + +type StripeRefundApplyArgs = { + now: Date; + orderId: string; + paymentIntentId: string | null; + stripeEventId: string; + pspChargeId: string | null; + pspPaymentMethod: string | null; + pspStatusReason: string; + pspMetadata: Record; + canonicalDualWriteEnabled: boolean; + canonicalEventDedupeKey: string; + canonicalEventPayload: Record; +}; + +async function applyStripeRefundedAtomic( + args: StripeRefundApplyArgs +): Promise<{ applied: boolean }> { const res = await db.execute(sql` with updated_order as ( update orders - set payment_status = 'paid', - status = 'PAID', + set payment_status = 'refunded', + status = 'CANCELED', updated_at = ${args.now}, psp_charge_id = ${args.pspChargeId}, psp_payment_method = ${args.pspPaymentMethod}, @@ -423,87 +676,55 @@ async function applyStripePaidAndQueueShipmentAtomic( psp_metadata = ${JSON.stringify(args.pspMetadata)}::jsonb where id = ${args.orderId}::uuid and payment_provider = 'stripe' - and payment_status in ('pending', 'requires_payment', 'paid') - and (stock_restored is null or stock_restored = false) - and (inventory_status is null or inventory_status <> 'released') - and (payment_status <> 'paid' or status <> 'PAID') - and payment_status <> 'failed' - and payment_status <> 'refunded' + and payment_status = 'paid' returning id, - payment_status, - inventory_status, - shipping_required, - shipping_provider, - shipping_method_code + total_amount_minor, + currency ), - 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 ( + inserted_payment_event as ( + insert into payment_events ( order_id, provider, - status, - attempt_count, - created_at, - updated_at + event_name, + event_source, + event_ref, + attempt_id, + provider_payment_intent_id, + provider_charge_id, + amount_minor, + currency, + payload, + dedupe_key, + occurred_at, + created_at ) select - id, - 'nova_poshta', - 'queued', - 0, + uo.id, + 'stripe', + 'refund_applied', + 'stripe_webhook', + ${args.stripeEventId}, + null, + ${args.paymentIntentId}, + ${args.pspChargeId}, + uo.total_amount_minor::bigint, + uo.currency, + ${JSON.stringify(args.canonicalEventPayload)}::jsonb, + ${args.canonicalEventDedupeKey}, ${args.now}, ${args.now} - from eligible_for_enqueue - 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} - where id in (select order_id from queued_order_ids) - and shipping_status is distinct from 'queued'::shipping_status + from updated_order uo + where ${args.canonicalDualWriteEnabled} = true + on conflict (dedupe_key) do nothing 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 + select (select count(*)::int from updated_order) as updated_count `); - const row = readDbRows<{ - updated_count?: number; - inserted_shipment_count?: number; - queued_shipment_count?: number; - }>(res)[0]; - + const row = readDbRows<{ updated_count?: number }>(res)[0]; return { applied: Number(row?.updated_count ?? 0) > 0, - shipmentQueued: Number(row?.queued_shipment_count ?? 0) > 0, }; } @@ -520,6 +741,7 @@ export async function POST(request: NextRequest) { provider: 'stripe', instanceId: STRIPE_WEBHOOK_INSTANCE_ID, }; + const canonicalDualWriteEnabled = isCanonicalEventsDualWriteEnabled(); const meta = (extra: Record = {}) => ({ ...baseMeta, @@ -1011,11 +1233,30 @@ export async function POST(request: NextRequest) { const appliedArgs = { now, orderId: order.id, + paymentIntentId, + stripeEventId: event.id, pspChargeId: latestChargeId ?? chargeForIntent?.id ?? null, pspPaymentMethod: resolvePaymentMethod(paymentIntent, chargeForIntent), pspStatusReason: paymentIntent?.status ?? 'succeeded', pspMetadata: nextMeta, paymentBecamePaidInThisApply: order.paymentStatus !== 'paid', + canonicalDualWriteEnabled, + canonicalEventDedupeKey: buildPaymentEventDedupeKey({ + provider: 'stripe', + orderId: order.id, + eventName: 'paid_applied', + eventSource: 'stripe_webhook', + stripeEventId: event.id, + paymentIntentId, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + }), + canonicalEventPayload: { + stripeEventId: event.id, + eventType, + paymentIntentId, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + paymentIntentStatus: paymentIntent?.status ?? null, + }, }; const applyResult = await applyStripePaidAndQueueShipmentAtomic(appliedArgs); @@ -1090,6 +1331,11 @@ export async function POST(request: NextRequest) { updated_at = ${now} where id in (select order_id from shipment_order_ids) and shipping_status is distinct from 'queued'::shipping_status + and ${shippingStatusTransitionWhereSql({ + column: sql`shipping_status`, + to: 'queued', + allowNullFrom: true, + })} `); } @@ -1620,26 +1866,59 @@ export async function POST(request: NextRequest) { createdAtIso, }); - const refundRes = await guardedPaymentStatusUpdate({ - orderId: order.id, - paymentProvider: 'stripe', - to: 'refunded', - source: 'stripe_webhook', - eventId: event.id, - note: eventType, - set: { - updatedAt: now, - status: 'CANCELED', + let canRestock = false; + if (canonicalDualWriteEnabled) { + const refundApply = await applyStripeRefundedAtomic({ + now, + orderId: order.id, + paymentIntentId, + stripeEventId: event.id, pspChargeId: charge?.id ?? refundChargeId ?? null, pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), pspStatusReason: refund?.reason ?? refund?.status ?? 'refunded', pspMetadata: nextMeta, - }, - }); + canonicalDualWriteEnabled, + canonicalEventDedupeKey: buildPaymentEventDedupeKey({ + provider: 'stripe', + orderId: order.id, + eventName: 'refund_applied', + eventSource: 'stripe_webhook', + stripeEventId: event.id, + paymentIntentId, + chargeId: charge?.id ?? refundChargeId ?? null, + refundId: refund?.id ?? null, + }), + canonicalEventPayload: { + stripeEventId: event.id, + eventType, + paymentIntentId, + chargeId: charge?.id ?? refundChargeId ?? null, + refundId: refund?.id ?? null, + }, + }); + canRestock = refundApply.applied || order.paymentStatus === 'refunded'; + } else { + const refundRes = await guardedPaymentStatusUpdate({ + orderId: order.id, + paymentProvider: 'stripe', + to: 'refunded', + source: 'stripe_webhook', + eventId: event.id, + note: eventType, + set: { + updatedAt: now, + status: 'CANCELED', + pspChargeId: charge?.id ?? refundChargeId ?? null, + pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), + pspStatusReason: refund?.reason ?? refund?.status ?? 'refunded', + pspMetadata: nextMeta, + }, + }); - const canRestock = - refundRes.applied || - (!refundRes.applied && refundRes.reason === 'ALREADY_IN_STATE'); + canRestock = + refundRes.applied || + (!refundRes.applied && refundRes.reason === 'ALREADY_IN_STATE'); + } if (canRestock && shouldRestockFromWebhook(order)) { await restockOrder(order.id, { reason: 'refunded' }); diff --git a/frontend/components/about/HeroSection.tsx b/frontend/components/about/HeroSection.tsx index 283e58a1..f48163ea 100644 --- a/frontend/components/about/HeroSection.tsx +++ b/frontend/components/about/HeroSection.tsx @@ -16,7 +16,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) { questionsSolved: '850+', githubStars: '120+', activeUsers: '200+', - linkedinFollowers: '1.6k+', + linkedinFollowers: '1.7k+', }; return ( diff --git a/frontend/data/category.ts b/frontend/data/category.ts index 97b1ec78..6e7e4536 100644 --- a/frontend/data/category.ts +++ b/frontend/data/category.ts @@ -23,4 +23,10 @@ export const categoryData = [ createCategory('postgresql', 'PostgreSQL', 11), createCategory('mongodb', 'MongoDB', 12), createCategory('python', 'Python', 13), + createCategory('django', 'Django', 14), + createCategory('docker', 'Docker', 15), + createCategory('kubernetes', 'Kubernetes', 16), + createCategory('aws', 'AWS', 17), + createCategory('azure', 'Azure', 18), + createCategory('devops', 'DevOps', 19), ]; diff --git a/frontend/data/categoryStyles.ts b/frontend/data/categoryStyles.ts index 02a81d69..a3a1af3c 100644 --- a/frontend/data/categoryStyles.ts +++ b/frontend/data/categoryStyles.ts @@ -116,6 +116,49 @@ export const categoryTabStyles = { glow: 'bg-[#3776AB]', accent: '#3776AB', }, + django: { + icon: '/icons/django.svg', + color: + 'group-hover:border-[#092E20]/50 group-hover:bg-[#092E20]/10 data-[state=active]:border-[#092E20]/50 data-[state=active]:bg-[#092E20]/10', + glow: 'bg-[#092E20]', + accent: '#092E20', + }, + docker: { + icon: '/icons/docker.svg', + color: + 'group-hover:border-[#1D63ED]/50 group-hover:bg-[#1D63ED]/10 data-[state=active]:border-[#1D63ED]/50 data-[state=active]:bg-[#1D63ED]/10', + glow: 'bg-[#1D63ED]', + accent: '#1D63ED', + }, + kubernetes: { + icon: '/icons/kubernetes.svg', + color: + 'group-hover:border-[#326CE5]/50 group-hover:bg-[#326CE5]/10 data-[state=active]:border-[#326CE5]/50 data-[state=active]:bg-[#326CE5]/10', + glow: 'bg-[#326CE5]', + accent: '#326CE5', + }, + aws: { + icon: '/icons/aws.svg', + color: + 'group-hover:border-[#FF9900]/50 group-hover:bg-[#FF9900]/10 data-[state=active]:border-[#FF9900]/50 data-[state=active]:bg-[#FF9900]/10', + glow: 'bg-[#FF9900]', + accent: '#FF9900', + iconClassName: 'dark:invert dark:hue-rotate-180 dark:brightness-110', + }, + azure: { + icon: '/icons/azure.svg', + color: + 'group-hover:border-[#0078D4]/50 group-hover:bg-[#0078D4]/10 data-[state=active]:border-[#0078D4]/50 data-[state=active]:bg-[#0078D4]/10', + glow: 'bg-[#0078D4]', + accent: '#0078D4', + }, + devops: { + icon: '/icons/devops.svg', + color: + 'group-hover:border-[#0052CC]/50 group-hover:bg-[#0052CC]/10 data-[state=active]:border-[#0052CC]/50 data-[state=active]:bg-[#0052CC]/10', + glow: 'bg-[#0052CC]', + accent: '#0052CC', + }, } as const satisfies Partial>; export function getCategoryTabStyle(slug: string): CategoryTabStyle { diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 40058b68..f58ea350 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -12,6 +12,34 @@ dotenv.config(); type AppDatabase = PgDatabase; const APP_ENV = process.env.APP_ENV ?? 'local'; +const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; +const REQUIRED_LOCAL_DB_URL = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL; + +if (STRICT_LOCAL_DB_GUARD) { + if (APP_ENV !== 'local') { + throw new Error( + `[db] SHOP_STRICT_LOCAL_DB=1 requires APP_ENV=local (got "${APP_ENV}")` + ); + } + if (!process.env.DATABASE_URL_LOCAL?.trim()) { + throw new Error( + '[db] SHOP_STRICT_LOCAL_DB=1 requires DATABASE_URL_LOCAL to be set' + ); + } + if (process.env.DATABASE_URL?.trim()) { + throw new Error( + '[db] SHOP_STRICT_LOCAL_DB=1 forbids DATABASE_URL during shop-local tests' + ); + } + if ( + REQUIRED_LOCAL_DB_URL && + process.env.DATABASE_URL_LOCAL !== REQUIRED_LOCAL_DB_URL + ) { + throw new Error( + '[db] SHOP_STRICT_LOCAL_DB=1 requires DATABASE_URL_LOCAL to match SHOP_REQUIRED_DATABASE_URL_LOCAL exactly' + ); + } +} let db: AppDatabase; diff --git a/frontend/db/schema/shop.ts b/frontend/db/schema/shop.ts index 85927930..7b5de334 100644 --- a/frontend/db/schema/shop.ts +++ b/frontend/db/schema/shop.ts @@ -3,6 +3,7 @@ import { bigint, boolean, check, + foreignKey, index, integer, jsonb, @@ -42,6 +43,21 @@ export const orderStatusEnum = pgEnum('order_status', [ 'CANCELED', ]); +export const fulfillmentModeEnum = pgEnum('fulfillment_mode', [ + 'ua_np', + 'intl', +]); + +export const quoteStatusEnum = pgEnum('quote_status', [ + 'none', + 'requested', + 'offered', + 'accepted', + 'declined', + 'expired', + 'requires_requote', +]); + export const inventoryStatusEnum = pgEnum('inventory_status', [ 'none', 'reserving', @@ -91,6 +107,19 @@ export const shippingShipmentStatusEnum = pgEnum('shipping_shipment_status', [ 'needs_attention', ]); +export const notificationChannelEnum = pgEnum('notification_channel', [ + 'email', + 'sms', +]); + +export const returnRequestStatusEnum = pgEnum('return_request_status', [ + 'requested', + 'approved', + 'rejected', + 'received', + 'refunded', +]); + export const products = pgTable( 'products', { @@ -156,6 +185,23 @@ export const orders = pgTable( .notNull(), currency: currencyEnum('currency').notNull().default('USD'), + fulfillmentMode: fulfillmentModeEnum('fulfillment_mode') + .notNull() + .default('ua_np'), + quoteStatus: quoteStatusEnum('quote_status').notNull().default('none'), + quoteVersion: integer('quote_version'), + shippingQuoteMinor: bigint('shipping_quote_minor', { mode: 'number' }), + itemsSubtotalMinor: bigint('items_subtotal_minor', { mode: 'number' }) + .notNull() + .default(0), + quoteAcceptedAt: timestamp('quote_accepted_at', { + withTimezone: true, + mode: 'date', + }), + quotePaymentDeadlineAt: timestamp('quote_payment_deadline_at', { + withTimezone: true, + mode: 'date', + }), shippingRequired: boolean('shipping_required'), shippingPayer: shippingPayerEnum('shipping_payer'), @@ -217,6 +263,14 @@ export const orders = pgTable( 'orders_total_amount_minor_non_negative', sql`${table.totalAmountMinor} >= 0` ), + check( + 'orders_items_subtotal_minor_non_negative', + sql`${table.itemsSubtotalMinor} >= 0` + ), + check( + 'orders_shipping_quote_minor_non_negative', + sql`${table.shippingQuoteMinor} is null or ${table.shippingQuoteMinor} >= 0` + ), check( 'orders_payment_intent_id_null_when_none', sql`${table.paymentProvider} <> 'none' OR ${table.paymentIntentId} IS NULL` @@ -271,12 +325,25 @@ export const orders = pgTable( 'orders_shipping_payer_present_when_required_chk', sql`${table.shippingRequired} IS DISTINCT FROM TRUE OR ${table.shippingPayer} IS NOT NULL` ), + check( + 'orders_intl_provider_restriction_chk', + sql`${table.fulfillmentMode} <> 'intl' OR ${table.paymentProvider} in ('stripe', 'none')` + ), index('orders_sweep_claim_expires_idx').on(table.sweepClaimExpiresAt), index('idx_orders_user_id_created_at').on(table.userId, table.createdAt), index('orders_shipping_status_idx').on( table.shippingStatus, table.updatedAt ), + index('orders_quote_status_deadline_idx').on( + table.fulfillmentMode, + table.quoteStatus, + table.quotePaymentDeadlineAt + ), + index('orders_quote_status_updated_idx').on( + table.quoteStatus, + table.updatedAt + ), ] ); @@ -413,6 +480,116 @@ export const monobankEvents = pgTable( ] ); +export const paymentEvents = pgTable( + 'payment_events', + { + id: uuid('id').defaultRandom().primaryKey(), + orderId: uuid('order_id') + .notNull() + .references(() => orders.id, { onDelete: 'cascade' }), + provider: text('provider').notNull(), + eventName: text('event_name').notNull(), + eventSource: text('event_source').notNull(), + eventRef: text('event_ref'), + attemptId: uuid('attempt_id').references(() => paymentAttempts.id, { + onDelete: 'set null', + }), + providerPaymentIntentId: text('provider_payment_intent_id'), + providerChargeId: text('provider_charge_id'), + amountMinor: bigint('amount_minor', { mode: 'number' }).notNull(), + currency: currencyEnum('currency').notNull(), + payload: jsonb('payload') + .$type>() + .notNull() + .default(sql`'{}'::jsonb`), + dedupeKey: text('dedupe_key').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }) + .notNull() + .defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + t => [ + uniqueIndex('payment_events_dedupe_key_uq').on(t.dedupeKey), + index('payment_events_order_id_idx').on(t.orderId), + index('payment_events_attempt_id_idx').on(t.attemptId), + index('payment_events_event_ref_idx').on(t.eventRef), + index('payment_events_occurred_at_idx').on(t.occurredAt), + ] +); + +export const shippingEvents = pgTable( + 'shipping_events', + { + id: uuid('id').defaultRandom().primaryKey(), + orderId: uuid('order_id') + .notNull() + .references(() => orders.id, { onDelete: 'cascade' }), + shipmentId: uuid('shipment_id').references(() => shippingShipments.id, { + onDelete: 'set null', + }), + provider: text('provider').notNull(), + eventName: text('event_name').notNull(), + eventSource: text('event_source').notNull(), + eventRef: text('event_ref'), + statusFrom: text('status_from'), + statusTo: text('status_to'), + trackingNumber: text('tracking_number'), + payload: jsonb('payload') + .$type>() + .notNull() + .default(sql`'{}'::jsonb`), + dedupeKey: text('dedupe_key').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }) + .notNull() + .defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + t => [ + uniqueIndex('shipping_events_dedupe_key_uq').on(t.dedupeKey), + index('shipping_events_order_id_idx').on(t.orderId), + index('shipping_events_shipment_id_idx').on(t.shipmentId), + index('shipping_events_occurred_at_idx').on(t.occurredAt), + ] +); + +export const adminAuditLog = pgTable( + 'admin_audit_log', + { + id: uuid('id').defaultRandom().primaryKey(), + orderId: uuid('order_id').references(() => orders.id, { + onDelete: 'set null', + }), + actorUserId: text('actor_user_id').references(() => users.id, { + onDelete: 'set null', + }), + action: text('action').notNull(), + targetType: text('target_type').notNull(), + targetId: text('target_id').notNull(), + requestId: text('request_id'), + payload: jsonb('payload') + .$type>() + .notNull() + .default(sql`'{}'::jsonb`), + dedupeKey: text('dedupe_key').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }) + .notNull() + .defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + t => [ + uniqueIndex('admin_audit_log_dedupe_key_uq').on(t.dedupeKey), + index('admin_audit_log_order_id_idx').on(t.orderId), + index('admin_audit_log_actor_user_id_idx').on(t.actorUserId), + index('admin_audit_log_occurred_at_idx').on(t.occurredAt), + ] +); + export const monobankRefunds = pgTable( 'monobank_refunds', { @@ -603,6 +780,46 @@ export const orderShipping = pgTable( table => [index('order_shipping_updated_idx').on(table.updatedAt)] ); +export const orderLegalConsents = pgTable( + 'order_legal_consents', + { + orderId: uuid('order_id') + .primaryKey() + .references(() => orders.id, { onDelete: 'cascade' }), + termsAccepted: boolean('terms_accepted').notNull().default(true), + privacyAccepted: boolean('privacy_accepted').notNull().default(true), + termsVersion: text('terms_version').notNull(), + privacyVersion: text('privacy_version').notNull(), + consentedAt: timestamp('consented_at', { + withTimezone: true, + mode: 'date', + }) + .notNull() + .defaultNow(), + source: text('source').notNull().default('checkout'), + locale: text('locale'), + country: varchar('country', { length: 2 }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + table => [ + index('order_legal_consents_consented_idx').on(table.consentedAt), + check( + 'order_legal_consents_terms_accepted_chk', + sql`${table.termsAccepted} = true` + ), + check( + 'order_legal_consents_privacy_accepted_chk', + sql`${table.privacyAccepted} = true` + ), + ] +); + export const shippingShipments = pgTable( 'shipping_shipments', { @@ -640,6 +857,248 @@ export const shippingShipments = pgTable( ] ); +export const shippingQuotes = pgTable( + 'shipping_quotes', + { + id: uuid('id').defaultRandom().primaryKey(), + orderId: uuid('order_id') + .notNull() + .references(() => orders.id, { onDelete: 'cascade' }), + version: integer('version').notNull(), + status: quoteStatusEnum('status').notNull(), + currency: currencyEnum('currency').notNull(), + shippingQuoteMinor: bigint('shipping_quote_minor', { + mode: 'number', + }).notNull(), + offeredBy: text('offered_by').references(() => users.id, { + onDelete: 'set null', + }), + offeredAt: timestamp('offered_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow(), + expiresAt: timestamp('expires_at', { + withTimezone: true, + mode: 'date', + }).notNull(), + acceptedAt: timestamp('accepted_at', { withTimezone: true, mode: 'date' }), + declinedAt: timestamp('declined_at', { withTimezone: true, mode: 'date' }), + payload: jsonb('payload') + .$type>() + .notNull() + .default(sql`'{}'::jsonb`), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + table => [ + uniqueIndex('shipping_quotes_order_version_uq').on( + table.orderId, + table.version + ), + index('shipping_quotes_order_status_idx').on(table.orderId, table.status), + index('shipping_quotes_status_expires_idx').on( + table.status, + table.expiresAt + ), + index('shipping_quotes_order_updated_idx').on( + table.orderId, + table.updatedAt + ), + check('shipping_quotes_version_positive_chk', sql`${table.version} >= 1`), + check( + 'shipping_quotes_quote_minor_non_negative_chk', + sql`${table.shippingQuoteMinor} >= 0` + ), + ] +); + +export const notificationOutbox = pgTable( + 'notification_outbox', + { + id: uuid('id').defaultRandom().primaryKey(), + orderId: uuid('order_id') + .notNull() + .references(() => orders.id, { onDelete: 'cascade' }), + channel: notificationChannelEnum('channel').notNull().default('email'), + templateKey: text('template_key').notNull(), + sourceDomain: text('source_domain').notNull(), + sourceEventId: uuid('source_event_id').notNull(), + payload: jsonb('payload') + .$type>() + .notNull() + .default(sql`'{}'::jsonb`), + status: text('status').notNull().default('pending'), + attemptCount: integer('attempt_count').notNull().default(0), + maxAttempts: integer('max_attempts').notNull().default(5), + nextAttemptAt: timestamp('next_attempt_at', { + withTimezone: true, + mode: 'date', + }) + .notNull() + .defaultNow(), + leaseOwner: varchar('lease_owner', { length: 64 }), + leaseExpiresAt: timestamp('lease_expires_at', { + withTimezone: true, + mode: 'date', + }), + lastErrorCode: text('last_error_code'), + lastErrorMessage: text('last_error_message'), + sentAt: timestamp('sent_at', { withTimezone: true, mode: 'date' }), + deadLetteredAt: timestamp('dead_lettered_at', { + withTimezone: true, + mode: 'date', + }), + dedupeKey: text('dedupe_key').notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + t => [ + uniqueIndex('notification_outbox_dedupe_key_uq').on(t.dedupeKey), + index('notification_outbox_status_next_attempt_idx').on( + t.status, + t.nextAttemptAt + ), + index('notification_outbox_status_lease_expires_idx').on( + t.status, + t.leaseExpiresAt + ), + index('notification_outbox_order_created_idx').on(t.orderId, t.createdAt), + index('notification_outbox_template_status_idx').on(t.templateKey, t.status), + check( + 'notification_outbox_source_domain_chk', + sql`${t.sourceDomain} in ('shipping_event','payment_event')` + ), + check( + 'notification_outbox_status_chk', + sql`${t.status} in ('pending','processing','sent','failed','dead_letter')` + ), + check( + 'notification_outbox_attempt_count_non_negative_chk', + sql`${t.attemptCount} >= 0` + ), + check( + 'notification_outbox_max_attempts_positive_chk', + sql`${t.maxAttempts} >= 1` + ), + ] +); + +export const returnRequests = pgTable( + 'return_requests', + { + id: uuid('id').defaultRandom().primaryKey(), + orderId: uuid('order_id') + .notNull() + .references(() => orders.id, { onDelete: 'cascade' }), + userId: text('user_id').references(() => users.id, { + onDelete: 'set null', + }), + status: returnRequestStatusEnum('status').notNull().default('requested'), + reason: text('reason'), + policyRestock: boolean('policy_restock').notNull().default(true), + refundAmountMinor: bigint('refund_amount_minor', { mode: 'number' }) + .notNull() + .default(0), + currency: currencyEnum('currency').notNull(), + idempotencyKey: varchar('idempotency_key', { length: 128 }).notNull(), + approvedAt: timestamp('approved_at', { withTimezone: true, mode: 'date' }), + approvedBy: text('approved_by').references(() => users.id, { + onDelete: 'set null', + }), + rejectedAt: timestamp('rejected_at', { withTimezone: true, mode: 'date' }), + rejectedBy: text('rejected_by').references(() => users.id, { + onDelete: 'set null', + }), + receivedAt: timestamp('received_at', { withTimezone: true, mode: 'date' }), + receivedBy: text('received_by').references(() => users.id, { + onDelete: 'set null', + }), + refundedAt: timestamp('refunded_at', { withTimezone: true, mode: 'date' }), + refundedBy: text('refunded_by').references(() => users.id, { + onDelete: 'set null', + }), + refundProviderRef: text('refund_provider_ref'), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + table => [ + uniqueIndex('return_requests_order_id_uq').on(table.orderId), + uniqueIndex('return_requests_id_order_id_uq').on(table.id, table.orderId), + uniqueIndex('return_requests_idempotency_key_uq').on(table.idempotencyKey), + index('return_requests_status_created_idx').on(table.status, table.createdAt), + index('return_requests_user_id_created_idx').on(table.userId, table.createdAt), + check( + 'return_requests_refund_amount_minor_non_negative_chk', + sql`${table.refundAmountMinor} >= 0` + ), + ] +); + +export const returnItems = pgTable( + 'return_items', + { + id: uuid('id').defaultRandom().primaryKey(), + returnRequestId: uuid('return_request_id') + .notNull() + .references(() => returnRequests.id, { onDelete: 'cascade' }), + orderId: uuid('order_id') + .notNull() + .references(() => orders.id, { onDelete: 'cascade' }), + orderItemId: uuid('order_item_id').references(() => orderItems.id, { + onDelete: 'set null', + }), + productId: uuid('product_id').references(() => products.id, { + onDelete: 'set null', + }), + quantity: integer('quantity').notNull(), + unitPriceMinor: integer('unit_price_minor').notNull(), + lineTotalMinor: integer('line_total_minor').notNull(), + currency: currencyEnum('currency').notNull(), + idempotencyKey: varchar('idempotency_key', { length: 200 }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }) + .notNull() + .defaultNow(), + }, + table => [ + uniqueIndex('return_items_idempotency_key_uq').on(table.idempotencyKey), + index('return_items_return_request_idx').on(table.returnRequestId), + index('return_items_order_id_idx').on(table.orderId), + index('return_items_product_id_idx').on(table.productId), + foreignKey({ + name: 'return_items_return_request_order_fk', + columns: [table.returnRequestId, table.orderId], + foreignColumns: [returnRequests.id, returnRequests.orderId], + }).onDelete('cascade'), + check('return_items_quantity_positive_chk', sql`${table.quantity} > 0`), + check( + 'return_items_unit_price_minor_non_negative_chk', + sql`${table.unitPriceMinor} >= 0` + ), + check( + 'return_items_line_total_minor_non_negative_chk', + sql`${table.lineTotalMinor} >= 0` + ), + check( + 'return_items_line_total_consistent_chk', + sql`${table.lineTotalMinor} = (${table.unitPriceMinor} * ${table.quantity})` + ), + ] +); + export const npCities = pgTable( 'np_cities', { @@ -837,10 +1296,18 @@ export type DbInternalJobState = typeof internalJobState.$inferSelect; export type DbPaymentAttempt = typeof paymentAttempts.$inferSelect; export type DbApiRateLimit = typeof apiRateLimits.$inferSelect; export type DbMonobankEvent = typeof monobankEvents.$inferSelect; +export type DbPaymentEvent = typeof paymentEvents.$inferSelect; +export type DbShippingEvent = typeof shippingEvents.$inferSelect; +export type DbAdminAuditLog = typeof adminAuditLog.$inferSelect; export type DbMonobankRefund = typeof monobankRefunds.$inferSelect; export type DbMonobankPaymentCancel = typeof monobankPaymentCancels.$inferSelect; export type DbOrderShipping = typeof orderShipping.$inferSelect; +export type DbOrderLegalConsent = typeof orderLegalConsents.$inferSelect; export type DbShippingShipment = typeof shippingShipments.$inferSelect; +export type DbShippingQuote = typeof shippingQuotes.$inferSelect; +export type DbNotificationOutbox = typeof notificationOutbox.$inferSelect; +export type DbReturnRequest = typeof returnRequests.$inferSelect; +export type DbReturnItem = typeof returnItems.$inferSelect; export type DbNpCity = typeof npCities.$inferSelect; export type DbNpWarehouse = typeof npWarehouses.$inferSelect; diff --git a/frontend/drizzle/0021_solid_sage.sql b/frontend/drizzle/0021_solid_sage.sql new file mode 100644 index 00000000..07414e1c --- /dev/null +++ b/frontend/drizzle/0021_solid_sage.sql @@ -0,0 +1,68 @@ +CREATE TABLE "admin_audit_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "order_id" uuid, + "actor_user_id" text, + "action" text NOT NULL, + "target_type" text NOT NULL, + "target_id" text NOT NULL, + "request_id" text, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "dedupe_key" text NOT NULL, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "payment_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "order_id" uuid NOT NULL, + "provider" text NOT NULL, + "event_name" text NOT NULL, + "event_source" text NOT NULL, + "event_ref" text, + "attempt_id" uuid, + "provider_payment_intent_id" text, + "provider_charge_id" text, + "amount_minor" bigint NOT NULL, + "currency" "currency" NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "dedupe_key" text NOT NULL, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "shipping_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "order_id" uuid NOT NULL, + "shipment_id" uuid, + "provider" text NOT NULL, + "event_name" text NOT NULL, + "event_source" text NOT NULL, + "event_ref" text, + "status_from" text, + "status_to" text, + "tracking_number" text, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "dedupe_key" text NOT NULL, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "admin_audit_log" ADD CONSTRAINT "admin_audit_log_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "admin_audit_log" ADD CONSTRAINT "admin_audit_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_attempt_id_payment_attempts_id_fk" FOREIGN KEY ("attempt_id") REFERENCES "public"."payment_attempts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shipping_events" ADD CONSTRAINT "shipping_events_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shipping_events" ADD CONSTRAINT "shipping_events_shipment_id_shipping_shipments_id_fk" FOREIGN KEY ("shipment_id") REFERENCES "public"."shipping_shipments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "admin_audit_log_dedupe_key_uq" ON "admin_audit_log" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "admin_audit_log_order_id_idx" ON "admin_audit_log" USING btree ("order_id");--> statement-breakpoint +CREATE INDEX "admin_audit_log_actor_user_id_idx" ON "admin_audit_log" USING btree ("actor_user_id");--> statement-breakpoint +CREATE INDEX "admin_audit_log_occurred_at_idx" ON "admin_audit_log" USING btree ("occurred_at");--> statement-breakpoint +CREATE UNIQUE INDEX "payment_events_dedupe_key_uq" ON "payment_events" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "payment_events_order_id_idx" ON "payment_events" USING btree ("order_id");--> statement-breakpoint +CREATE INDEX "payment_events_attempt_id_idx" ON "payment_events" USING btree ("attempt_id");--> statement-breakpoint +CREATE INDEX "payment_events_event_ref_idx" ON "payment_events" USING btree ("event_ref");--> statement-breakpoint +CREATE INDEX "payment_events_occurred_at_idx" ON "payment_events" USING btree ("occurred_at");--> statement-breakpoint +CREATE UNIQUE INDEX "shipping_events_dedupe_key_uq" ON "shipping_events" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "shipping_events_order_id_idx" ON "shipping_events" USING btree ("order_id");--> statement-breakpoint +CREATE INDEX "shipping_events_shipment_id_idx" ON "shipping_events" USING btree ("shipment_id");--> statement-breakpoint +CREATE INDEX "shipping_events_occurred_at_idx" ON "shipping_events" USING btree ("occurred_at"); \ No newline at end of file diff --git a/frontend/drizzle/0022_demonic_vapor.sql b/frontend/drizzle/0022_demonic_vapor.sql new file mode 100644 index 00000000..161d8df1 --- /dev/null +++ b/frontend/drizzle/0022_demonic_vapor.sql @@ -0,0 +1,39 @@ +CREATE TYPE "public"."fulfillment_mode" AS ENUM('ua_np', 'intl');--> statement-breakpoint +CREATE TYPE "public"."quote_status" AS ENUM('none', 'requested', 'offered', 'accepted', 'declined', 'expired', 'requires_requote');--> statement-breakpoint +CREATE TABLE "shipping_quotes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "order_id" uuid NOT NULL, + "version" integer NOT NULL, + "status" "quote_status" NOT NULL, + "currency" "currency" NOT NULL, + "shipping_quote_minor" bigint NOT NULL, + "offered_by" text, + "offered_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "accepted_at" timestamp with time zone, + "declined_at" timestamp with time zone, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "shipping_quotes_version_positive_chk" CHECK ("shipping_quotes"."version" >= 1), + CONSTRAINT "shipping_quotes_quote_minor_non_negative_chk" CHECK ("shipping_quotes"."shipping_quote_minor" >= 0) +); +--> statement-breakpoint +ALTER TABLE "orders" ADD COLUMN "fulfillment_mode" "fulfillment_mode" DEFAULT 'ua_np' NOT NULL;--> statement-breakpoint +ALTER TABLE "orders" ADD COLUMN "quote_status" "quote_status" DEFAULT 'none' NOT NULL;--> statement-breakpoint +ALTER TABLE "orders" ADD COLUMN "quote_version" integer;--> statement-breakpoint +ALTER TABLE "orders" ADD COLUMN "shipping_quote_minor" bigint;--> statement-breakpoint +ALTER TABLE "orders" ADD COLUMN "items_subtotal_minor" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "orders" ADD COLUMN "quote_accepted_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "orders" ADD COLUMN "quote_payment_deadline_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "shipping_quotes" ADD CONSTRAINT "shipping_quotes_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shipping_quotes" ADD CONSTRAINT "shipping_quotes_offered_by_users_id_fk" FOREIGN KEY ("offered_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "shipping_quotes_order_version_uq" ON "shipping_quotes" USING btree ("order_id","version");--> statement-breakpoint +CREATE INDEX "shipping_quotes_order_status_idx" ON "shipping_quotes" USING btree ("order_id","status");--> statement-breakpoint +CREATE INDEX "shipping_quotes_status_expires_idx" ON "shipping_quotes" USING btree ("status","expires_at");--> statement-breakpoint +CREATE INDEX "shipping_quotes_order_updated_idx" ON "shipping_quotes" USING btree ("order_id","updated_at");--> statement-breakpoint +CREATE INDEX "orders_quote_status_deadline_idx" ON "orders" USING btree ("fulfillment_mode","quote_status","quote_payment_deadline_at");--> statement-breakpoint +CREATE INDEX "orders_quote_status_updated_idx" ON "orders" USING btree ("quote_status","updated_at");--> statement-breakpoint +ALTER TABLE "orders" ADD CONSTRAINT "orders_items_subtotal_minor_non_negative" CHECK ("orders"."items_subtotal_minor" >= 0);--> statement-breakpoint +ALTER TABLE "orders" ADD CONSTRAINT "orders_shipping_quote_minor_non_negative" CHECK ("orders"."shipping_quote_minor" is null or "orders"."shipping_quote_minor" >= 0);--> statement-breakpoint +ALTER TABLE "orders" ADD CONSTRAINT "orders_intl_provider_restriction_chk" CHECK ("orders"."fulfillment_mode" <> 'intl' OR "orders"."payment_provider" in ('stripe', 'none')); \ No newline at end of file diff --git a/frontend/drizzle/0023_clean_madame_masque.sql b/frontend/drizzle/0023_clean_madame_masque.sql new file mode 100644 index 00000000..d0b6da60 --- /dev/null +++ b/frontend/drizzle/0023_clean_madame_masque.sql @@ -0,0 +1,34 @@ +CREATE TYPE "public"."notification_channel" AS ENUM('email', 'sms');--> statement-breakpoint +CREATE TABLE "notification_outbox" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "order_id" uuid NOT NULL, + "channel" "notification_channel" DEFAULT 'email' NOT NULL, + "template_key" text NOT NULL, + "source_domain" text NOT NULL, + "source_event_id" uuid NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "attempt_count" integer DEFAULT 0 NOT NULL, + "max_attempts" integer DEFAULT 5 NOT NULL, + "next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL, + "lease_owner" varchar(64), + "lease_expires_at" timestamp with time zone, + "last_error_code" text, + "last_error_message" text, + "sent_at" timestamp with time zone, + "dead_lettered_at" timestamp with time zone, + "dedupe_key" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "notification_outbox_source_domain_chk" CHECK ("notification_outbox"."source_domain" in ('shipping_event','payment_event')), + CONSTRAINT "notification_outbox_status_chk" CHECK ("notification_outbox"."status" in ('pending','processing','sent','failed','dead_letter')), + CONSTRAINT "notification_outbox_attempt_count_non_negative_chk" CHECK ("notification_outbox"."attempt_count" >= 0), + CONSTRAINT "notification_outbox_max_attempts_positive_chk" CHECK ("notification_outbox"."max_attempts" >= 1) +); +--> statement-breakpoint +ALTER TABLE "notification_outbox" ADD CONSTRAINT "notification_outbox_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "notification_outbox_dedupe_key_uq" ON "notification_outbox" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "notification_outbox_status_next_attempt_idx" ON "notification_outbox" USING btree ("status","next_attempt_at");--> statement-breakpoint +CREATE INDEX "notification_outbox_status_lease_expires_idx" ON "notification_outbox" USING btree ("status","lease_expires_at");--> statement-breakpoint +CREATE INDEX "notification_outbox_order_created_idx" ON "notification_outbox" USING btree ("order_id","created_at");--> statement-breakpoint +CREATE INDEX "notification_outbox_template_status_idx" ON "notification_outbox" USING btree ("template_key","status"); \ No newline at end of file diff --git a/frontend/drizzle/0024_gigantic_annihilus.sql b/frontend/drizzle/0024_gigantic_annihilus.sql new file mode 100644 index 00000000..555ccf38 --- /dev/null +++ b/frontend/drizzle/0024_gigantic_annihilus.sql @@ -0,0 +1,61 @@ +CREATE TYPE "public"."return_request_status" AS ENUM('requested', 'approved', 'rejected', 'received', 'refunded');--> statement-breakpoint +CREATE TABLE "return_items" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "return_request_id" uuid NOT NULL, + "order_id" uuid NOT NULL, + "order_item_id" uuid, + "product_id" uuid, + "quantity" integer NOT NULL, + "unit_price_minor" integer NOT NULL, + "line_total_minor" integer NOT NULL, + "currency" "currency" NOT NULL, + "idempotency_key" varchar(200) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "return_items_quantity_positive_chk" CHECK ("return_items"."quantity" > 0), + CONSTRAINT "return_items_unit_price_minor_non_negative_chk" CHECK ("return_items"."unit_price_minor" >= 0), + CONSTRAINT "return_items_line_total_minor_non_negative_chk" CHECK ("return_items"."line_total_minor" >= 0), + CONSTRAINT "return_items_line_total_consistent_chk" CHECK ("return_items"."line_total_minor" = ("return_items"."unit_price_minor" * "return_items"."quantity")) +); +--> statement-breakpoint +CREATE TABLE "return_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "order_id" uuid NOT NULL, + "user_id" text, + "status" "return_request_status" DEFAULT 'requested' NOT NULL, + "reason" text, + "policy_restock" boolean DEFAULT true NOT NULL, + "refund_amount_minor" bigint DEFAULT 0 NOT NULL, + "currency" "currency" NOT NULL, + "idempotency_key" varchar(128) NOT NULL, + "approved_at" timestamp with time zone, + "approved_by" text, + "rejected_at" timestamp with time zone, + "rejected_by" text, + "received_at" timestamp with time zone, + "received_by" text, + "refunded_at" timestamp with time zone, + "refunded_by" text, + "refund_provider_ref" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "return_requests_refund_amount_minor_non_negative_chk" CHECK ("return_requests"."refund_amount_minor" >= 0) +); +--> statement-breakpoint +ALTER TABLE "return_items" ADD CONSTRAINT "return_items_return_request_id_return_requests_id_fk" FOREIGN KEY ("return_request_id") REFERENCES "public"."return_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "return_items" ADD CONSTRAINT "return_items_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "return_items" ADD CONSTRAINT "return_items_order_item_id_order_items_id_fk" FOREIGN KEY ("order_item_id") REFERENCES "public"."order_items"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "return_items" ADD CONSTRAINT "return_items_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_approved_by_users_id_fk" FOREIGN KEY ("approved_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_rejected_by_users_id_fk" FOREIGN KEY ("rejected_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_received_by_users_id_fk" FOREIGN KEY ("received_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_refunded_by_users_id_fk" FOREIGN KEY ("refunded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "return_items_idempotency_key_uq" ON "return_items" USING btree ("idempotency_key");--> statement-breakpoint +CREATE INDEX "return_items_return_request_idx" ON "return_items" USING btree ("return_request_id");--> statement-breakpoint +CREATE INDEX "return_items_order_id_idx" ON "return_items" USING btree ("order_id");--> statement-breakpoint +CREATE INDEX "return_items_product_id_idx" ON "return_items" USING btree ("product_id");--> statement-breakpoint +CREATE UNIQUE INDEX "return_requests_order_id_uq" ON "return_requests" USING btree ("order_id");--> statement-breakpoint +CREATE UNIQUE INDEX "return_requests_idempotency_key_uq" ON "return_requests" USING btree ("idempotency_key");--> statement-breakpoint +CREATE INDEX "return_requests_status_created_idx" ON "return_requests" USING btree ("status","created_at");--> statement-breakpoint +CREATE INDEX "return_requests_user_id_created_idx" ON "return_requests" USING btree ("user_id","created_at"); \ No newline at end of file diff --git a/frontend/drizzle/0025_cute_mentor.sql b/frontend/drizzle/0025_cute_mentor.sql new file mode 100644 index 00000000..8d86391d --- /dev/null +++ b/frontend/drizzle/0025_cute_mentor.sql @@ -0,0 +1,18 @@ +CREATE TABLE "order_legal_consents" ( + "order_id" uuid PRIMARY KEY NOT NULL, + "terms_accepted" boolean DEFAULT true NOT NULL, + "privacy_accepted" boolean DEFAULT true NOT NULL, + "terms_version" text NOT NULL, + "privacy_version" text NOT NULL, + "consented_at" timestamp with time zone DEFAULT now() NOT NULL, + "source" text DEFAULT 'checkout' NOT NULL, + "locale" text, + "country" varchar(2), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "order_legal_consents_terms_accepted_chk" CHECK ("order_legal_consents"."terms_accepted" = true), + CONSTRAINT "order_legal_consents_privacy_accepted_chk" CHECK ("order_legal_consents"."privacy_accepted" = true) +); +--> statement-breakpoint +ALTER TABLE "order_legal_consents" ADD CONSTRAINT "order_legal_consents_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "order_legal_consents_consented_idx" ON "order_legal_consents" USING btree ("consented_at"); \ No newline at end of file diff --git a/frontend/drizzle/0026_gray_stone_men.sql b/frontend/drizzle/0026_gray_stone_men.sql new file mode 100644 index 00000000..330db660 --- /dev/null +++ b/frontend/drizzle/0026_gray_stone_men.sql @@ -0,0 +1,9 @@ +CREATE UNIQUE INDEX "return_requests_id_order_id_uq" + ON "return_requests" USING btree ("id","order_id"); +--> statement-breakpoint +ALTER TABLE "return_items" + ADD CONSTRAINT "return_items_return_request_order_fk" + FOREIGN KEY ("return_request_id","order_id") + REFERENCES "public"."return_requests"("id","order_id") + ON DELETE cascade + ON UPDATE no action; \ No newline at end of file diff --git a/frontend/drizzle/meta/0021_snapshot.json b/frontend/drizzle/meta/0021_snapshot.json new file mode 100644 index 00000000..630ee402 --- /dev/null +++ b/frontend/drizzle/meta/0021_snapshot.json @@ -0,0 +1,4931 @@ +{ + "id": "74a8ae2c-6dad-4f20-b300-5cb7410b860c", + "prevId": "ca7a31cd-6fd8-4d8b-b9b1-69eda1703b55", + "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", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "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": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_log_dedupe_key_uq": { + "name": "admin_audit_log_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_order_id_idx": { + "name": "admin_audit_log_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_actor_user_id_idx": { + "name": "admin_audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_occurred_at_idx": { + "name": "admin_audit_log_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_order_id_orders_id_fk": { + "name": "admin_audit_log_order_id_orders_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_audit_log_actor_user_id_users_id_fk": { + "name": "admin_audit_log_actor_user_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "np_warehouses_settlement_ref_np_cities_ref_fk": { + "name": "np_warehouses_settlement_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "tableTo": "np_cities", + "columnsFrom": [ + "settlement_ref" + ], + "columnsTo": [ + "ref" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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.payment_events": { + "name": "payment_events", + "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 + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_charge_id": { + "name": "provider_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_events_dedupe_key_uq": { + "name": "payment_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_order_id_idx": { + "name": "payment_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_attempt_id_idx": { + "name": "payment_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_event_ref_idx": { + "name": "payment_events_event_ref_idx", + "columns": [ + { + "expression": "event_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_occurred_at_idx": { + "name": "payment_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_events_attempt_id_payment_attempts_id_fk": { + "name": "payment_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "payment_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_events": { + "name": "shipping_events", + "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 + }, + "shipment_id": { + "name": "shipment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_from": { + "name": "status_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_to": { + "name": "status_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_events_dedupe_key_uq": { + "name": "shipping_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_order_id_idx": { + "name": "shipping_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_shipment_id_idx": { + "name": "shipping_events_shipment_id_idx", + "columns": [ + { + "expression": "shipment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_occurred_at_idx": { + "name": "shipping_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_events_order_id_orders_id_fk": { + "name": "shipping_events_order_id_orders_id_fk", + "tableFrom": "shipping_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_events_shipment_id_shipping_shipments_id_fk": { + "name": "shipping_events_shipment_id_shipping_shipments_id_fk", + "tableFrom": "shipping_events", + "tableTo": "shipping_shipments", + "columnsFrom": [ + "shipment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "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": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0022_snapshot.json b/frontend/drizzle/meta/0022_snapshot.json new file mode 100644 index 00000000..c0a77898 --- /dev/null +++ b/frontend/drizzle/meta/0022_snapshot.json @@ -0,0 +1,5284 @@ +{ + "id": "908df3f5-3e51-4d37-bab6-d06257820304", + "prevId": "74a8ae2c-6dad-4f20-b300-5cb7410b860c", + "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", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "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": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_log_dedupe_key_uq": { + "name": "admin_audit_log_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_order_id_idx": { + "name": "admin_audit_log_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_actor_user_id_idx": { + "name": "admin_audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_occurred_at_idx": { + "name": "admin_audit_log_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_order_id_orders_id_fk": { + "name": "admin_audit_log_order_id_orders_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_audit_log_actor_user_id_users_id_fk": { + "name": "admin_audit_log_actor_user_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "np_warehouses_settlement_ref_np_cities_ref_fk": { + "name": "np_warehouses_settlement_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "tableTo": "np_cities", + "columnsFrom": [ + "settlement_ref" + ], + "columnsTo": [ + "ref" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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'" + }, + "fulfillment_mode": { + "name": "fulfillment_mode", + "type": "fulfillment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ua_np'" + }, + "quote_status": { + "name": "quote_status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "quote_version": { + "name": "quote_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "items_subtotal_minor": { + "name": "items_subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quote_accepted_at": { + "name": "quote_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quote_payment_deadline_at": { + "name": "quote_payment_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_deadline_idx": { + "name": "orders_quote_status_deadline_idx", + "columns": [ + { + "expression": "fulfillment_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_payment_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_updated_idx": { + "name": "orders_quote_status_updated_idx", + "columns": [ + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "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_items_subtotal_minor_non_negative": { + "name": "orders_items_subtotal_minor_non_negative", + "value": "\"orders\".\"items_subtotal_minor\" >= 0" + }, + "orders_shipping_quote_minor_non_negative": { + "name": "orders_shipping_quote_minor_non_negative", + "value": "\"orders\".\"shipping_quote_minor\" is null or \"orders\".\"shipping_quote_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" + }, + "orders_intl_provider_restriction_chk": { + "name": "orders_intl_provider_restriction_chk", + "value": "\"orders\".\"fulfillment_mode\" <> 'intl' OR \"orders\".\"payment_provider\" in ('stripe', 'none')" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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.payment_events": { + "name": "payment_events", + "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 + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_charge_id": { + "name": "provider_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_events_dedupe_key_uq": { + "name": "payment_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_order_id_idx": { + "name": "payment_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_attempt_id_idx": { + "name": "payment_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_event_ref_idx": { + "name": "payment_events_event_ref_idx", + "columns": [ + { + "expression": "event_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_occurred_at_idx": { + "name": "payment_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_events_attempt_id_payment_attempts_id_fk": { + "name": "payment_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "payment_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_events": { + "name": "shipping_events", + "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 + }, + "shipment_id": { + "name": "shipment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_from": { + "name": "status_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_to": { + "name": "status_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_events_dedupe_key_uq": { + "name": "shipping_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_order_id_idx": { + "name": "shipping_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_shipment_id_idx": { + "name": "shipping_events_shipment_id_idx", + "columns": [ + { + "expression": "shipment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_occurred_at_idx": { + "name": "shipping_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_events_order_id_orders_id_fk": { + "name": "shipping_events_order_id_orders_id_fk", + "tableFrom": "shipping_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_events_shipment_id_shipping_shipments_id_fk": { + "name": "shipping_events_shipment_id_shipping_shipments_id_fk", + "tableFrom": "shipping_events", + "tableTo": "shipping_shipments", + "columnsFrom": [ + "shipment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shipping_quotes": { + "name": "shipping_quotes", + "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 + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "offered_by": { + "name": "offered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "offered_at": { + "name": "offered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "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": { + "shipping_quotes_order_version_uq": { + "name": "shipping_quotes_order_version_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_status_idx": { + "name": "shipping_quotes_order_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_status_expires_idx": { + "name": "shipping_quotes_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_updated_idx": { + "name": "shipping_quotes_order_updated_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_quotes_order_id_orders_id_fk": { + "name": "shipping_quotes_order_id_orders_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_quotes_offered_by_users_id_fk": { + "name": "shipping_quotes_offered_by_users_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "users", + "columnsFrom": [ + "offered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_quotes_version_positive_chk": { + "name": "shipping_quotes_version_positive_chk", + "value": "\"shipping_quotes\".\"version\" >= 1" + }, + "shipping_quotes_quote_minor_non_negative_chk": { + "name": "shipping_quotes_quote_minor_non_negative_chk", + "value": "\"shipping_quotes\".\"shipping_quote_minor\" >= 0" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.fulfillment_mode": { + "name": "fulfillment_mode", + "schema": "public", + "values": [ + "ua_np", + "intl" + ] + }, + "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.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "none", + "requested", + "offered", + "accepted", + "declined", + "expired", + "requires_requote" + ] + }, + "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": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0023_snapshot.json b/frontend/drizzle/meta/0023_snapshot.json new file mode 100644 index 00000000..e177fcf1 --- /dev/null +++ b/frontend/drizzle/meta/0023_snapshot.json @@ -0,0 +1,5566 @@ +{ + "id": "25cbe161-8adc-4a6d-b725-ef23daf537d8", + "prevId": "908df3f5-3e51-4d37-bab6-d06257820304", + "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", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "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": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_log_dedupe_key_uq": { + "name": "admin_audit_log_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_order_id_idx": { + "name": "admin_audit_log_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_actor_user_id_idx": { + "name": "admin_audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_occurred_at_idx": { + "name": "admin_audit_log_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_order_id_orders_id_fk": { + "name": "admin_audit_log_order_id_orders_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_audit_log_actor_user_id_users_id_fk": { + "name": "admin_audit_log_actor_user_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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.notification_outbox": { + "name": "notification_outbox", + "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 + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "template_key": { + "name": "template_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_domain": { + "name": "source_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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 + }, + "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 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dead_lettered_at": { + "name": "dead_lettered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "notification_outbox_dedupe_key_uq": { + "name": "notification_outbox_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_next_attempt_idx": { + "name": "notification_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_lease_expires_idx": { + "name": "notification_outbox_status_lease_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_order_created_idx": { + "name": "notification_outbox_order_created_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_template_status_idx": { + "name": "notification_outbox_template_status_idx", + "columns": [ + { + "expression": "template_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_outbox_order_id_orders_id_fk": { + "name": "notification_outbox_order_id_orders_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notification_outbox_source_domain_chk": { + "name": "notification_outbox_source_domain_chk", + "value": "\"notification_outbox\".\"source_domain\" in ('shipping_event','payment_event')" + }, + "notification_outbox_status_chk": { + "name": "notification_outbox_status_chk", + "value": "\"notification_outbox\".\"status\" in ('pending','processing','sent','failed','dead_letter')" + }, + "notification_outbox_attempt_count_non_negative_chk": { + "name": "notification_outbox_attempt_count_non_negative_chk", + "value": "\"notification_outbox\".\"attempt_count\" >= 0" + }, + "notification_outbox_max_attempts_positive_chk": { + "name": "notification_outbox_max_attempts_positive_chk", + "value": "\"notification_outbox\".\"max_attempts\" >= 1" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "np_warehouses_settlement_ref_np_cities_ref_fk": { + "name": "np_warehouses_settlement_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "tableTo": "np_cities", + "columnsFrom": [ + "settlement_ref" + ], + "columnsTo": [ + "ref" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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'" + }, + "fulfillment_mode": { + "name": "fulfillment_mode", + "type": "fulfillment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ua_np'" + }, + "quote_status": { + "name": "quote_status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "quote_version": { + "name": "quote_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "items_subtotal_minor": { + "name": "items_subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quote_accepted_at": { + "name": "quote_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quote_payment_deadline_at": { + "name": "quote_payment_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_deadline_idx": { + "name": "orders_quote_status_deadline_idx", + "columns": [ + { + "expression": "fulfillment_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_payment_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_updated_idx": { + "name": "orders_quote_status_updated_idx", + "columns": [ + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "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_items_subtotal_minor_non_negative": { + "name": "orders_items_subtotal_minor_non_negative", + "value": "\"orders\".\"items_subtotal_minor\" >= 0" + }, + "orders_shipping_quote_minor_non_negative": { + "name": "orders_shipping_quote_minor_non_negative", + "value": "\"orders\".\"shipping_quote_minor\" is null or \"orders\".\"shipping_quote_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" + }, + "orders_intl_provider_restriction_chk": { + "name": "orders_intl_provider_restriction_chk", + "value": "\"orders\".\"fulfillment_mode\" <> 'intl' OR \"orders\".\"payment_provider\" in ('stripe', 'none')" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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.payment_events": { + "name": "payment_events", + "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 + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_charge_id": { + "name": "provider_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_events_dedupe_key_uq": { + "name": "payment_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_order_id_idx": { + "name": "payment_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_attempt_id_idx": { + "name": "payment_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_event_ref_idx": { + "name": "payment_events_event_ref_idx", + "columns": [ + { + "expression": "event_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_occurred_at_idx": { + "name": "payment_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_events_attempt_id_payment_attempts_id_fk": { + "name": "payment_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "payment_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_events": { + "name": "shipping_events", + "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 + }, + "shipment_id": { + "name": "shipment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_from": { + "name": "status_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_to": { + "name": "status_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_events_dedupe_key_uq": { + "name": "shipping_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_order_id_idx": { + "name": "shipping_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_shipment_id_idx": { + "name": "shipping_events_shipment_id_idx", + "columns": [ + { + "expression": "shipment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_occurred_at_idx": { + "name": "shipping_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_events_order_id_orders_id_fk": { + "name": "shipping_events_order_id_orders_id_fk", + "tableFrom": "shipping_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_events_shipment_id_shipping_shipments_id_fk": { + "name": "shipping_events_shipment_id_shipping_shipments_id_fk", + "tableFrom": "shipping_events", + "tableTo": "shipping_shipments", + "columnsFrom": [ + "shipment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shipping_quotes": { + "name": "shipping_quotes", + "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 + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "offered_by": { + "name": "offered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "offered_at": { + "name": "offered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "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": { + "shipping_quotes_order_version_uq": { + "name": "shipping_quotes_order_version_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_status_idx": { + "name": "shipping_quotes_order_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_status_expires_idx": { + "name": "shipping_quotes_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_updated_idx": { + "name": "shipping_quotes_order_updated_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_quotes_order_id_orders_id_fk": { + "name": "shipping_quotes_order_id_orders_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_quotes_offered_by_users_id_fk": { + "name": "shipping_quotes_offered_by_users_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "users", + "columnsFrom": [ + "offered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_quotes_version_positive_chk": { + "name": "shipping_quotes_version_positive_chk", + "value": "\"shipping_quotes\".\"version\" >= 1" + }, + "shipping_quotes_quote_minor_non_negative_chk": { + "name": "shipping_quotes_quote_minor_non_negative_chk", + "value": "\"shipping_quotes\".\"shipping_quote_minor\" >= 0" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.fulfillment_mode": { + "name": "fulfillment_mode", + "schema": "public", + "values": [ + "ua_np", + "intl" + ] + }, + "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.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "email", + "sms" + ] + }, + "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.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "none", + "requested", + "offered", + "accepted", + "declined", + "expired", + "requires_requote" + ] + }, + "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": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0024_snapshot.json b/frontend/drizzle/meta/0024_snapshot.json new file mode 100644 index 00000000..e1b0fba3 --- /dev/null +++ b/frontend/drizzle/meta/0024_snapshot.json @@ -0,0 +1,6088 @@ +{ + "id": "f5e9ec7c-9861-4784-94bc-7b000a67759a", + "prevId": "25cbe161-8adc-4a6d-b725-ef23daf537d8", + "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", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "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": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_log_dedupe_key_uq": { + "name": "admin_audit_log_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_order_id_idx": { + "name": "admin_audit_log_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_actor_user_id_idx": { + "name": "admin_audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_occurred_at_idx": { + "name": "admin_audit_log_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_order_id_orders_id_fk": { + "name": "admin_audit_log_order_id_orders_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_audit_log_actor_user_id_users_id_fk": { + "name": "admin_audit_log_actor_user_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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.notification_outbox": { + "name": "notification_outbox", + "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 + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "template_key": { + "name": "template_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_domain": { + "name": "source_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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 + }, + "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 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dead_lettered_at": { + "name": "dead_lettered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "notification_outbox_dedupe_key_uq": { + "name": "notification_outbox_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_next_attempt_idx": { + "name": "notification_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_lease_expires_idx": { + "name": "notification_outbox_status_lease_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_order_created_idx": { + "name": "notification_outbox_order_created_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_template_status_idx": { + "name": "notification_outbox_template_status_idx", + "columns": [ + { + "expression": "template_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_outbox_order_id_orders_id_fk": { + "name": "notification_outbox_order_id_orders_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notification_outbox_source_domain_chk": { + "name": "notification_outbox_source_domain_chk", + "value": "\"notification_outbox\".\"source_domain\" in ('shipping_event','payment_event')" + }, + "notification_outbox_status_chk": { + "name": "notification_outbox_status_chk", + "value": "\"notification_outbox\".\"status\" in ('pending','processing','sent','failed','dead_letter')" + }, + "notification_outbox_attempt_count_non_negative_chk": { + "name": "notification_outbox_attempt_count_non_negative_chk", + "value": "\"notification_outbox\".\"attempt_count\" >= 0" + }, + "notification_outbox_max_attempts_positive_chk": { + "name": "notification_outbox_max_attempts_positive_chk", + "value": "\"notification_outbox\".\"max_attempts\" >= 1" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "np_warehouses_settlement_ref_np_cities_ref_fk": { + "name": "np_warehouses_settlement_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "tableTo": "np_cities", + "columnsFrom": [ + "settlement_ref" + ], + "columnsTo": [ + "ref" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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'" + }, + "fulfillment_mode": { + "name": "fulfillment_mode", + "type": "fulfillment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ua_np'" + }, + "quote_status": { + "name": "quote_status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "quote_version": { + "name": "quote_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "items_subtotal_minor": { + "name": "items_subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quote_accepted_at": { + "name": "quote_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quote_payment_deadline_at": { + "name": "quote_payment_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_deadline_idx": { + "name": "orders_quote_status_deadline_idx", + "columns": [ + { + "expression": "fulfillment_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_payment_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_updated_idx": { + "name": "orders_quote_status_updated_idx", + "columns": [ + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "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_items_subtotal_minor_non_negative": { + "name": "orders_items_subtotal_minor_non_negative", + "value": "\"orders\".\"items_subtotal_minor\" >= 0" + }, + "orders_shipping_quote_minor_non_negative": { + "name": "orders_shipping_quote_minor_non_negative", + "value": "\"orders\".\"shipping_quote_minor\" is null or \"orders\".\"shipping_quote_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" + }, + "orders_intl_provider_restriction_chk": { + "name": "orders_intl_provider_restriction_chk", + "value": "\"orders\".\"fulfillment_mode\" <> 'intl' OR \"orders\".\"payment_provider\" in ('stripe', 'none')" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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.payment_events": { + "name": "payment_events", + "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 + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_charge_id": { + "name": "provider_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_events_dedupe_key_uq": { + "name": "payment_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_order_id_idx": { + "name": "payment_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_attempt_id_idx": { + "name": "payment_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_event_ref_idx": { + "name": "payment_events_event_ref_idx", + "columns": [ + { + "expression": "event_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_occurred_at_idx": { + "name": "payment_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_events_attempt_id_payment_attempts_id_fk": { + "name": "payment_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "payment_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.return_items": { + "name": "return_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "return_request_id": { + "name": "return_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "return_items_idempotency_key_uq": { + "name": "return_items_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_return_request_idx": { + "name": "return_items_return_request_idx", + "columns": [ + { + "expression": "return_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_order_id_idx": { + "name": "return_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_product_id_idx": { + "name": "return_items_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_items_return_request_id_return_requests_id_fk": { + "name": "return_items_return_request_id_return_requests_id_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_id_orders_id_fk": { + "name": "return_items_order_id_orders_id_fk", + "tableFrom": "return_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_item_id_order_items_id_fk": { + "name": "return_items_order_item_id_order_items_id_fk", + "tableFrom": "return_items", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_product_id_products_id_fk": { + "name": "return_items_product_id_products_id_fk", + "tableFrom": "return_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_items_quantity_positive_chk": { + "name": "return_items_quantity_positive_chk", + "value": "\"return_items\".\"quantity\" > 0" + }, + "return_items_unit_price_minor_non_negative_chk": { + "name": "return_items_unit_price_minor_non_negative_chk", + "value": "\"return_items\".\"unit_price_minor\" >= 0" + }, + "return_items_line_total_minor_non_negative_chk": { + "name": "return_items_line_total_minor_non_negative_chk", + "value": "\"return_items\".\"line_total_minor\" >= 0" + }, + "return_items_line_total_consistent_chk": { + "name": "return_items_line_total_consistent_chk", + "value": "\"return_items\".\"line_total_minor\" = (\"return_items\".\"unit_price_minor\" * \"return_items\".\"quantity\")" + } + }, + "isRLSEnabled": false + }, + "public.return_requests": { + "name": "return_requests", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "return_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_restock": { + "name": "policy_restock", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "refund_amount_minor": { + "name": "refund_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refunded_by": { + "name": "refunded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refund_provider_ref": { + "name": "refund_provider_ref", + "type": "text", + "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": { + "return_requests_order_id_uq": { + "name": "return_requests_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_idempotency_key_uq": { + "name": "return_requests_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_status_created_idx": { + "name": "return_requests_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_user_id_created_idx": { + "name": "return_requests_user_id_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_requests_order_id_orders_id_fk": { + "name": "return_requests_order_id_orders_id_fk", + "tableFrom": "return_requests", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_requests_user_id_users_id_fk": { + "name": "return_requests_user_id_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_approved_by_users_id_fk": { + "name": "return_requests_approved_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_rejected_by_users_id_fk": { + "name": "return_requests_rejected_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "rejected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_received_by_users_id_fk": { + "name": "return_requests_received_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "received_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_refunded_by_users_id_fk": { + "name": "return_requests_refunded_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "refunded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_requests_refund_amount_minor_non_negative_chk": { + "name": "return_requests_refund_amount_minor_non_negative_chk", + "value": "\"return_requests\".\"refund_amount_minor\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.shipping_events": { + "name": "shipping_events", + "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 + }, + "shipment_id": { + "name": "shipment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_from": { + "name": "status_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_to": { + "name": "status_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_events_dedupe_key_uq": { + "name": "shipping_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_order_id_idx": { + "name": "shipping_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_shipment_id_idx": { + "name": "shipping_events_shipment_id_idx", + "columns": [ + { + "expression": "shipment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_occurred_at_idx": { + "name": "shipping_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_events_order_id_orders_id_fk": { + "name": "shipping_events_order_id_orders_id_fk", + "tableFrom": "shipping_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_events_shipment_id_shipping_shipments_id_fk": { + "name": "shipping_events_shipment_id_shipping_shipments_id_fk", + "tableFrom": "shipping_events", + "tableTo": "shipping_shipments", + "columnsFrom": [ + "shipment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shipping_quotes": { + "name": "shipping_quotes", + "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 + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "offered_by": { + "name": "offered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "offered_at": { + "name": "offered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "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": { + "shipping_quotes_order_version_uq": { + "name": "shipping_quotes_order_version_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_status_idx": { + "name": "shipping_quotes_order_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_status_expires_idx": { + "name": "shipping_quotes_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_updated_idx": { + "name": "shipping_quotes_order_updated_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_quotes_order_id_orders_id_fk": { + "name": "shipping_quotes_order_id_orders_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_quotes_offered_by_users_id_fk": { + "name": "shipping_quotes_offered_by_users_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "users", + "columnsFrom": [ + "offered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_quotes_version_positive_chk": { + "name": "shipping_quotes_version_positive_chk", + "value": "\"shipping_quotes\".\"version\" >= 1" + }, + "shipping_quotes_quote_minor_non_negative_chk": { + "name": "shipping_quotes_quote_minor_non_negative_chk", + "value": "\"shipping_quotes\".\"shipping_quote_minor\" >= 0" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.fulfillment_mode": { + "name": "fulfillment_mode", + "schema": "public", + "values": [ + "ua_np", + "intl" + ] + }, + "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.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "email", + "sms" + ] + }, + "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.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "none", + "requested", + "offered", + "accepted", + "declined", + "expired", + "requires_requote" + ] + }, + "public.return_request_status": { + "name": "return_request_status", + "schema": "public", + "values": [ + "requested", + "approved", + "rejected", + "received", + "refunded" + ] + }, + "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": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0025_snapshot.json b/frontend/drizzle/meta/0025_snapshot.json new file mode 100644 index 00000000..321f84f0 --- /dev/null +++ b/frontend/drizzle/meta/0025_snapshot.json @@ -0,0 +1,6212 @@ +{ + "id": "e37a0d18-5954-4073-aa6a-b2d39f9c6c13", + "prevId": "f5e9ec7c-9861-4784-94bc-7b000a67759a", + "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", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "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": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_log_dedupe_key_uq": { + "name": "admin_audit_log_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_order_id_idx": { + "name": "admin_audit_log_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_actor_user_id_idx": { + "name": "admin_audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_occurred_at_idx": { + "name": "admin_audit_log_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_order_id_orders_id_fk": { + "name": "admin_audit_log_order_id_orders_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_audit_log_actor_user_id_users_id_fk": { + "name": "admin_audit_log_actor_user_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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.notification_outbox": { + "name": "notification_outbox", + "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 + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "template_key": { + "name": "template_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_domain": { + "name": "source_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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 + }, + "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 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dead_lettered_at": { + "name": "dead_lettered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "notification_outbox_dedupe_key_uq": { + "name": "notification_outbox_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_next_attempt_idx": { + "name": "notification_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_lease_expires_idx": { + "name": "notification_outbox_status_lease_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_order_created_idx": { + "name": "notification_outbox_order_created_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_template_status_idx": { + "name": "notification_outbox_template_status_idx", + "columns": [ + { + "expression": "template_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_outbox_order_id_orders_id_fk": { + "name": "notification_outbox_order_id_orders_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notification_outbox_source_domain_chk": { + "name": "notification_outbox_source_domain_chk", + "value": "\"notification_outbox\".\"source_domain\" in ('shipping_event','payment_event')" + }, + "notification_outbox_status_chk": { + "name": "notification_outbox_status_chk", + "value": "\"notification_outbox\".\"status\" in ('pending','processing','sent','failed','dead_letter')" + }, + "notification_outbox_attempt_count_non_negative_chk": { + "name": "notification_outbox_attempt_count_non_negative_chk", + "value": "\"notification_outbox\".\"attempt_count\" >= 0" + }, + "notification_outbox_max_attempts_positive_chk": { + "name": "notification_outbox_max_attempts_positive_chk", + "value": "\"notification_outbox\".\"max_attempts\" >= 1" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "np_warehouses_settlement_ref_np_cities_ref_fk": { + "name": "np_warehouses_settlement_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "tableTo": "np_cities", + "columnsFrom": [ + "settlement_ref" + ], + "columnsTo": [ + "ref" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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_legal_consents": { + "name": "order_legal_consents", + "schema": "", + "columns": { + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "privacy_accepted": { + "name": "privacy_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "terms_version": { + "name": "terms_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "privacy_version": { + "name": "privacy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'checkout'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(2)", + "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": { + "order_legal_consents_consented_idx": { + "name": "order_legal_consents_consented_idx", + "columns": [ + { + "expression": "consented_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_legal_consents_order_id_orders_id_fk": { + "name": "order_legal_consents_order_id_orders_id_fk", + "tableFrom": "order_legal_consents", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_legal_consents_terms_accepted_chk": { + "name": "order_legal_consents_terms_accepted_chk", + "value": "\"order_legal_consents\".\"terms_accepted\" = true" + }, + "order_legal_consents_privacy_accepted_chk": { + "name": "order_legal_consents_privacy_accepted_chk", + "value": "\"order_legal_consents\".\"privacy_accepted\" = true" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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'" + }, + "fulfillment_mode": { + "name": "fulfillment_mode", + "type": "fulfillment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ua_np'" + }, + "quote_status": { + "name": "quote_status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "quote_version": { + "name": "quote_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "items_subtotal_minor": { + "name": "items_subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quote_accepted_at": { + "name": "quote_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quote_payment_deadline_at": { + "name": "quote_payment_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_deadline_idx": { + "name": "orders_quote_status_deadline_idx", + "columns": [ + { + "expression": "fulfillment_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_payment_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_updated_idx": { + "name": "orders_quote_status_updated_idx", + "columns": [ + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "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_items_subtotal_minor_non_negative": { + "name": "orders_items_subtotal_minor_non_negative", + "value": "\"orders\".\"items_subtotal_minor\" >= 0" + }, + "orders_shipping_quote_minor_non_negative": { + "name": "orders_shipping_quote_minor_non_negative", + "value": "\"orders\".\"shipping_quote_minor\" is null or \"orders\".\"shipping_quote_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" + }, + "orders_intl_provider_restriction_chk": { + "name": "orders_intl_provider_restriction_chk", + "value": "\"orders\".\"fulfillment_mode\" <> 'intl' OR \"orders\".\"payment_provider\" in ('stripe', 'none')" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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.payment_events": { + "name": "payment_events", + "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 + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_charge_id": { + "name": "provider_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_events_dedupe_key_uq": { + "name": "payment_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_order_id_idx": { + "name": "payment_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_attempt_id_idx": { + "name": "payment_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_event_ref_idx": { + "name": "payment_events_event_ref_idx", + "columns": [ + { + "expression": "event_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_occurred_at_idx": { + "name": "payment_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_events_attempt_id_payment_attempts_id_fk": { + "name": "payment_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "payment_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.return_items": { + "name": "return_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "return_request_id": { + "name": "return_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "return_items_idempotency_key_uq": { + "name": "return_items_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_return_request_idx": { + "name": "return_items_return_request_idx", + "columns": [ + { + "expression": "return_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_order_id_idx": { + "name": "return_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_product_id_idx": { + "name": "return_items_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_items_return_request_id_return_requests_id_fk": { + "name": "return_items_return_request_id_return_requests_id_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_id_orders_id_fk": { + "name": "return_items_order_id_orders_id_fk", + "tableFrom": "return_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_item_id_order_items_id_fk": { + "name": "return_items_order_item_id_order_items_id_fk", + "tableFrom": "return_items", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_product_id_products_id_fk": { + "name": "return_items_product_id_products_id_fk", + "tableFrom": "return_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_items_quantity_positive_chk": { + "name": "return_items_quantity_positive_chk", + "value": "\"return_items\".\"quantity\" > 0" + }, + "return_items_unit_price_minor_non_negative_chk": { + "name": "return_items_unit_price_minor_non_negative_chk", + "value": "\"return_items\".\"unit_price_minor\" >= 0" + }, + "return_items_line_total_minor_non_negative_chk": { + "name": "return_items_line_total_minor_non_negative_chk", + "value": "\"return_items\".\"line_total_minor\" >= 0" + }, + "return_items_line_total_consistent_chk": { + "name": "return_items_line_total_consistent_chk", + "value": "\"return_items\".\"line_total_minor\" = (\"return_items\".\"unit_price_minor\" * \"return_items\".\"quantity\")" + } + }, + "isRLSEnabled": false + }, + "public.return_requests": { + "name": "return_requests", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "return_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_restock": { + "name": "policy_restock", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "refund_amount_minor": { + "name": "refund_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refunded_by": { + "name": "refunded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refund_provider_ref": { + "name": "refund_provider_ref", + "type": "text", + "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": { + "return_requests_order_id_uq": { + "name": "return_requests_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_idempotency_key_uq": { + "name": "return_requests_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_status_created_idx": { + "name": "return_requests_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_user_id_created_idx": { + "name": "return_requests_user_id_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_requests_order_id_orders_id_fk": { + "name": "return_requests_order_id_orders_id_fk", + "tableFrom": "return_requests", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_requests_user_id_users_id_fk": { + "name": "return_requests_user_id_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_approved_by_users_id_fk": { + "name": "return_requests_approved_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_rejected_by_users_id_fk": { + "name": "return_requests_rejected_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "rejected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_received_by_users_id_fk": { + "name": "return_requests_received_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "received_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_refunded_by_users_id_fk": { + "name": "return_requests_refunded_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "refunded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_requests_refund_amount_minor_non_negative_chk": { + "name": "return_requests_refund_amount_minor_non_negative_chk", + "value": "\"return_requests\".\"refund_amount_minor\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.shipping_events": { + "name": "shipping_events", + "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 + }, + "shipment_id": { + "name": "shipment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_from": { + "name": "status_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_to": { + "name": "status_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_events_dedupe_key_uq": { + "name": "shipping_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_order_id_idx": { + "name": "shipping_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_shipment_id_idx": { + "name": "shipping_events_shipment_id_idx", + "columns": [ + { + "expression": "shipment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_occurred_at_idx": { + "name": "shipping_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_events_order_id_orders_id_fk": { + "name": "shipping_events_order_id_orders_id_fk", + "tableFrom": "shipping_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_events_shipment_id_shipping_shipments_id_fk": { + "name": "shipping_events_shipment_id_shipping_shipments_id_fk", + "tableFrom": "shipping_events", + "tableTo": "shipping_shipments", + "columnsFrom": [ + "shipment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shipping_quotes": { + "name": "shipping_quotes", + "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 + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "offered_by": { + "name": "offered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "offered_at": { + "name": "offered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "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": { + "shipping_quotes_order_version_uq": { + "name": "shipping_quotes_order_version_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_status_idx": { + "name": "shipping_quotes_order_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_status_expires_idx": { + "name": "shipping_quotes_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_updated_idx": { + "name": "shipping_quotes_order_updated_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_quotes_order_id_orders_id_fk": { + "name": "shipping_quotes_order_id_orders_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_quotes_offered_by_users_id_fk": { + "name": "shipping_quotes_offered_by_users_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "users", + "columnsFrom": [ + "offered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_quotes_version_positive_chk": { + "name": "shipping_quotes_version_positive_chk", + "value": "\"shipping_quotes\".\"version\" >= 1" + }, + "shipping_quotes_quote_minor_non_negative_chk": { + "name": "shipping_quotes_quote_minor_non_negative_chk", + "value": "\"shipping_quotes\".\"shipping_quote_minor\" >= 0" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.fulfillment_mode": { + "name": "fulfillment_mode", + "schema": "public", + "values": [ + "ua_np", + "intl" + ] + }, + "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.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "email", + "sms" + ] + }, + "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.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "none", + "requested", + "offered", + "accepted", + "declined", + "expired", + "requires_requote" + ] + }, + "public.return_request_status": { + "name": "return_request_status", + "schema": "public", + "values": [ + "requested", + "approved", + "rejected", + "received", + "refunded" + ] + }, + "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": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0026_snapshot.json b/frontend/drizzle/meta/0026_snapshot.json new file mode 100644 index 00000000..d11f466b --- /dev/null +++ b/frontend/drizzle/meta/0026_snapshot.json @@ -0,0 +1,6248 @@ +{ + "id": "f4e91e8f-78b2-466f-b7aa-2dfbb52d5a09", + "prevId": "e37a0d18-5954-4073-aa6a-b2d39f9c6c13", + "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", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "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": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_log_dedupe_key_uq": { + "name": "admin_audit_log_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_order_id_idx": { + "name": "admin_audit_log_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_actor_user_id_idx": { + "name": "admin_audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_occurred_at_idx": { + "name": "admin_audit_log_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_order_id_orders_id_fk": { + "name": "admin_audit_log_order_id_orders_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_audit_log_actor_user_id_users_id_fk": { + "name": "admin_audit_log_actor_user_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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.notification_outbox": { + "name": "notification_outbox", + "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 + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "template_key": { + "name": "template_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_domain": { + "name": "source_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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 + }, + "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 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dead_lettered_at": { + "name": "dead_lettered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "notification_outbox_dedupe_key_uq": { + "name": "notification_outbox_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_next_attempt_idx": { + "name": "notification_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_lease_expires_idx": { + "name": "notification_outbox_status_lease_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_order_created_idx": { + "name": "notification_outbox_order_created_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_template_status_idx": { + "name": "notification_outbox_template_status_idx", + "columns": [ + { + "expression": "template_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_outbox_order_id_orders_id_fk": { + "name": "notification_outbox_order_id_orders_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notification_outbox_source_domain_chk": { + "name": "notification_outbox_source_domain_chk", + "value": "\"notification_outbox\".\"source_domain\" in ('shipping_event','payment_event')" + }, + "notification_outbox_status_chk": { + "name": "notification_outbox_status_chk", + "value": "\"notification_outbox\".\"status\" in ('pending','processing','sent','failed','dead_letter')" + }, + "notification_outbox_attempt_count_non_negative_chk": { + "name": "notification_outbox_attempt_count_non_negative_chk", + "value": "\"notification_outbox\".\"attempt_count\" >= 0" + }, + "notification_outbox_max_attempts_positive_chk": { + "name": "notification_outbox_max_attempts_positive_chk", + "value": "\"notification_outbox\".\"max_attempts\" >= 1" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "np_warehouses_settlement_ref_np_cities_ref_fk": { + "name": "np_warehouses_settlement_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "tableTo": "np_cities", + "columnsFrom": [ + "settlement_ref" + ], + "columnsTo": [ + "ref" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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_legal_consents": { + "name": "order_legal_consents", + "schema": "", + "columns": { + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "privacy_accepted": { + "name": "privacy_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "terms_version": { + "name": "terms_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "privacy_version": { + "name": "privacy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'checkout'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(2)", + "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": { + "order_legal_consents_consented_idx": { + "name": "order_legal_consents_consented_idx", + "columns": [ + { + "expression": "consented_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_legal_consents_order_id_orders_id_fk": { + "name": "order_legal_consents_order_id_orders_id_fk", + "tableFrom": "order_legal_consents", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_legal_consents_terms_accepted_chk": { + "name": "order_legal_consents_terms_accepted_chk", + "value": "\"order_legal_consents\".\"terms_accepted\" = true" + }, + "order_legal_consents_privacy_accepted_chk": { + "name": "order_legal_consents_privacy_accepted_chk", + "value": "\"order_legal_consents\".\"privacy_accepted\" = true" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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'" + }, + "fulfillment_mode": { + "name": "fulfillment_mode", + "type": "fulfillment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ua_np'" + }, + "quote_status": { + "name": "quote_status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "quote_version": { + "name": "quote_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "items_subtotal_minor": { + "name": "items_subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quote_accepted_at": { + "name": "quote_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quote_payment_deadline_at": { + "name": "quote_payment_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_deadline_idx": { + "name": "orders_quote_status_deadline_idx", + "columns": [ + { + "expression": "fulfillment_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_payment_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_updated_idx": { + "name": "orders_quote_status_updated_idx", + "columns": [ + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "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_items_subtotal_minor_non_negative": { + "name": "orders_items_subtotal_minor_non_negative", + "value": "\"orders\".\"items_subtotal_minor\" >= 0" + }, + "orders_shipping_quote_minor_non_negative": { + "name": "orders_shipping_quote_minor_non_negative", + "value": "\"orders\".\"shipping_quote_minor\" is null or \"orders\".\"shipping_quote_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" + }, + "orders_intl_provider_restriction_chk": { + "name": "orders_intl_provider_restriction_chk", + "value": "\"orders\".\"fulfillment_mode\" <> 'intl' OR \"orders\".\"payment_provider\" in ('stripe', 'none')" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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.payment_events": { + "name": "payment_events", + "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 + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_charge_id": { + "name": "provider_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_events_dedupe_key_uq": { + "name": "payment_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_order_id_idx": { + "name": "payment_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_attempt_id_idx": { + "name": "payment_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_event_ref_idx": { + "name": "payment_events_event_ref_idx", + "columns": [ + { + "expression": "event_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_occurred_at_idx": { + "name": "payment_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_events_attempt_id_payment_attempts_id_fk": { + "name": "payment_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "payment_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.return_items": { + "name": "return_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "return_request_id": { + "name": "return_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "return_items_idempotency_key_uq": { + "name": "return_items_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_return_request_idx": { + "name": "return_items_return_request_idx", + "columns": [ + { + "expression": "return_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_order_id_idx": { + "name": "return_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_product_id_idx": { + "name": "return_items_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_items_return_request_id_return_requests_id_fk": { + "name": "return_items_return_request_id_return_requests_id_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_id_orders_id_fk": { + "name": "return_items_order_id_orders_id_fk", + "tableFrom": "return_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_item_id_order_items_id_fk": { + "name": "return_items_order_item_id_order_items_id_fk", + "tableFrom": "return_items", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_product_id_products_id_fk": { + "name": "return_items_product_id_products_id_fk", + "tableFrom": "return_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_return_request_order_fk": { + "name": "return_items_return_request_order_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id", + "order_id" + ], + "columnsTo": [ + "id", + "order_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_items_quantity_positive_chk": { + "name": "return_items_quantity_positive_chk", + "value": "\"return_items\".\"quantity\" > 0" + }, + "return_items_unit_price_minor_non_negative_chk": { + "name": "return_items_unit_price_minor_non_negative_chk", + "value": "\"return_items\".\"unit_price_minor\" >= 0" + }, + "return_items_line_total_minor_non_negative_chk": { + "name": "return_items_line_total_minor_non_negative_chk", + "value": "\"return_items\".\"line_total_minor\" >= 0" + }, + "return_items_line_total_consistent_chk": { + "name": "return_items_line_total_consistent_chk", + "value": "\"return_items\".\"line_total_minor\" = (\"return_items\".\"unit_price_minor\" * \"return_items\".\"quantity\")" + } + }, + "isRLSEnabled": false + }, + "public.return_requests": { + "name": "return_requests", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "return_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_restock": { + "name": "policy_restock", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "refund_amount_minor": { + "name": "refund_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refunded_by": { + "name": "refunded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refund_provider_ref": { + "name": "refund_provider_ref", + "type": "text", + "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": { + "return_requests_order_id_uq": { + "name": "return_requests_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_id_order_id_uq": { + "name": "return_requests_id_order_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_idempotency_key_uq": { + "name": "return_requests_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_status_created_idx": { + "name": "return_requests_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_user_id_created_idx": { + "name": "return_requests_user_id_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_requests_order_id_orders_id_fk": { + "name": "return_requests_order_id_orders_id_fk", + "tableFrom": "return_requests", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_requests_user_id_users_id_fk": { + "name": "return_requests_user_id_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_approved_by_users_id_fk": { + "name": "return_requests_approved_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_rejected_by_users_id_fk": { + "name": "return_requests_rejected_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "rejected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_received_by_users_id_fk": { + "name": "return_requests_received_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "received_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_refunded_by_users_id_fk": { + "name": "return_requests_refunded_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "refunded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_requests_refund_amount_minor_non_negative_chk": { + "name": "return_requests_refund_amount_minor_non_negative_chk", + "value": "\"return_requests\".\"refund_amount_minor\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.shipping_events": { + "name": "shipping_events", + "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 + }, + "shipment_id": { + "name": "shipment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_from": { + "name": "status_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_to": { + "name": "status_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_events_dedupe_key_uq": { + "name": "shipping_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_order_id_idx": { + "name": "shipping_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_shipment_id_idx": { + "name": "shipping_events_shipment_id_idx", + "columns": [ + { + "expression": "shipment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_occurred_at_idx": { + "name": "shipping_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_events_order_id_orders_id_fk": { + "name": "shipping_events_order_id_orders_id_fk", + "tableFrom": "shipping_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_events_shipment_id_shipping_shipments_id_fk": { + "name": "shipping_events_shipment_id_shipping_shipments_id_fk", + "tableFrom": "shipping_events", + "tableTo": "shipping_shipments", + "columnsFrom": [ + "shipment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shipping_quotes": { + "name": "shipping_quotes", + "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 + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "offered_by": { + "name": "offered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "offered_at": { + "name": "offered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "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": { + "shipping_quotes_order_version_uq": { + "name": "shipping_quotes_order_version_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_status_idx": { + "name": "shipping_quotes_order_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_status_expires_idx": { + "name": "shipping_quotes_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_updated_idx": { + "name": "shipping_quotes_order_updated_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_quotes_order_id_orders_id_fk": { + "name": "shipping_quotes_order_id_orders_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_quotes_offered_by_users_id_fk": { + "name": "shipping_quotes_offered_by_users_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "users", + "columnsFrom": [ + "offered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_quotes_version_positive_chk": { + "name": "shipping_quotes_version_positive_chk", + "value": "\"shipping_quotes\".\"version\" >= 1" + }, + "shipping_quotes_quote_minor_non_negative_chk": { + "name": "shipping_quotes_quote_minor_non_negative_chk", + "value": "\"shipping_quotes\".\"shipping_quote_minor\" >= 0" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.fulfillment_mode": { + "name": "fulfillment_mode", + "schema": "public", + "values": [ + "ua_np", + "intl" + ] + }, + "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.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "email", + "sms" + ] + }, + "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.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "none", + "requested", + "offered", + "accepted", + "declined", + "expired", + "requires_requote" + ] + }, + "public.return_request_status": { + "name": "return_request_status", + "schema": "public", + "values": [ + "requested", + "approved", + "rejected", + "received", + "refunded" + ] + }, + "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": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_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 7ebde737..941263c4 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -148,6 +148,48 @@ "when": 1772135863883, "tag": "0020_shop_orders_sweeps_partial_indexes", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1772213459590, + "tag": "0021_solid_sage", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1772219957446, + "tag": "0022_demonic_vapor", + "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1772223636248, + "tag": "0023_clean_madame_masque", + "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1772225141854, + "tag": "0024_gigantic_annihilus", + "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1772234409785, + "tag": "0025_cute_mentor", + "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1772252564812, + "tag": "0026_gray_stone_men", + "breakpoints": true } ] } \ No newline at end of file diff --git a/frontend/lib/about/stats.ts b/frontend/lib/about/stats.ts index 131b8dfb..c4ee20ef 100644 --- a/frontend/lib/about/stats.ts +++ b/frontend/lib/about/stats.ts @@ -43,7 +43,7 @@ export const getPlatformStats = unstable_cache( const linkedinCount = process.env.LINKEDIN_FOLLOWER_COUNT ? parseInt(process.env.LINKEDIN_FOLLOWER_COUNT) - : 1600; + : 1700; let totalUsers = 243; let solvedTests = 1890; diff --git a/frontend/lib/env/index.ts b/frontend/lib/env/index.ts index 96c74c35..eceb0b7f 100644 --- a/frontend/lib/env/index.ts +++ b/frontend/lib/env/index.ts @@ -53,6 +53,8 @@ export const serverEnvSchema = z.object({ .optional() .default('false'), SHOP_SHIPPING_RETENTION_DAYS: z.string().optional().default('180'), + SHOP_TERMS_VERSION: z.string().min(1).optional().default('terms-v1'), + SHOP_PRIVACY_VERSION: z.string().min(1).optional().default('privacy-v1'), NP_API_KEY: z.string().min(1).optional(), NP_API_BASE: z.string().url().optional(), diff --git a/frontend/lib/env/monobank.ts b/frontend/lib/env/monobank.ts index f2658c1d..c8ed9e43 100644 --- a/frontend/lib/env/monobank.ts +++ b/frontend/lib/env/monobank.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { getRuntimeEnv, getServerEnv } from '@/lib/env'; +import { getRuntimeEnv } from '@/lib/env'; export type MonobankEnv = { token: string | null; @@ -19,19 +19,17 @@ function parseWebhookMode(raw: string | undefined): MonobankWebhookMode { } export function getMonobankConfig(): MonobankConfig { - const env = getServerEnv(); - - const rawMode = process.env.MONO_WEBHOOK_MODE ?? env.MONO_WEBHOOK_MODE; + const rawMode = process.env.MONO_WEBHOOK_MODE; return { webhookMode: parseWebhookMode(rawMode), - refundEnabled: env.MONO_REFUND_ENABLED === 'true', + refundEnabled: process.env.MONO_REFUND_ENABLED === 'true', invoiceValiditySeconds: parsePositiveInt( - env.MONO_INVOICE_VALIDITY_SECONDS, + process.env.MONO_INVOICE_VALIDITY_SECONDS, 86400 ), timeSkewToleranceSec: parsePositiveInt( - env.MONO_TIME_SKEW_TOLERANCE_SEC, + process.env.MONO_TIME_SKEW_TOLERANCE_SEC, 300 ), baseUrlSource: resolveBaseUrlSource(), @@ -69,15 +67,13 @@ function parsePositiveInt(raw: string | undefined, fallback: number): number { } function resolveMonobankToken(): string | null { - const env = getServerEnv(); - return nonEmpty(env.MONO_MERCHANT_TOKEN); + return nonEmpty(process.env.MONO_MERCHANT_TOKEN); } function resolveBaseUrlSource(): MonobankConfig['baseUrlSource'] { - const env = getServerEnv(); - if (nonEmpty(env.SHOP_BASE_URL ?? undefined)) return 'shop_base_url'; - if (nonEmpty(env.APP_ORIGIN ?? undefined)) return 'app_origin'; - if (nonEmpty(env.NEXT_PUBLIC_SITE_URL ?? undefined)) + if (nonEmpty(process.env.SHOP_BASE_URL)) return 'shop_base_url'; + if (nonEmpty(process.env.APP_ORIGIN)) return 'app_origin'; + if (nonEmpty(process.env.NEXT_PUBLIC_SITE_URL)) return 'next_public_site_url'; return 'unknown'; } @@ -92,19 +88,19 @@ export function requireMonobankToken(): string { export function getMonobankEnv(): MonobankEnv { const runtimeEnv = getRuntimeEnv(); - const env = getServerEnv(); const token = resolveMonobankToken(); - const publicKey = nonEmpty(env.MONO_PUBLIC_KEY); + const publicKey = nonEmpty(process.env.MONO_PUBLIC_KEY); - const apiBaseUrl = nonEmpty(env.MONO_API_BASE) ?? 'https://api.monobank.ua'; + const apiBaseUrl = + nonEmpty(process.env.MONO_API_BASE) ?? 'https://api.monobank.ua'; - const paymentsFlag = env.PAYMENTS_ENABLED ?? 'false'; + const paymentsFlag = process.env.PAYMENTS_ENABLED ?? 'false'; const configured = !!token; const paymentsEnabled = String(paymentsFlag).trim() === 'true' && configured; const invoiceTimeoutMs = parseTimeoutMs( - env.MONO_INVOICE_TIMEOUT_MS, + process.env.MONO_INVOICE_TIMEOUT_MS, runtimeEnv.NODE_ENV === 'production' ? 8000 : 12000 ); diff --git a/frontend/lib/env/nova-poshta.ts b/frontend/lib/env/nova-poshta.ts index eb5b8fe6..a92c1e73 100644 --- a/frontend/lib/env/nova-poshta.ts +++ b/frontend/lib/env/nova-poshta.ts @@ -1,7 +1,5 @@ import 'server-only'; -import { getServerEnv } from '@/lib/env'; - const DEFAULT_NP_API_BASE = 'https://api.novaposhta.ua/v2.0/json/'; function nonEmpty(value: string | undefined): string | null { @@ -51,28 +49,32 @@ export class NovaPoshtaConfigError extends Error { } export function getShopShippingFlags(): ShopShippingFlags { - const env = getServerEnv(); const retentionDays = Math.max( 1, - Math.min(3650, parsePositiveInt(env.SHOP_SHIPPING_RETENTION_DAYS, 180)) + Math.min( + 3650, + parsePositiveInt(process.env.SHOP_SHIPPING_RETENTION_DAYS, 180) + ) ); return { - shippingEnabled: env.SHOP_SHIPPING_ENABLED === 'true', - npEnabled: env.SHOP_SHIPPING_NP_ENABLED === 'true', - syncEnabled: env.SHOP_SHIPPING_SYNC_ENABLED === 'true', - retentionEnabled: env.SHOP_SHIPPING_RETENTION_ENABLED === 'true', + shippingEnabled: process.env.SHOP_SHIPPING_ENABLED === 'true', + npEnabled: process.env.SHOP_SHIPPING_NP_ENABLED === 'true', + syncEnabled: process.env.SHOP_SHIPPING_SYNC_ENABLED === 'true', + retentionEnabled: process.env.SHOP_SHIPPING_RETENTION_ENABLED === 'true', retentionDays, }; } export function getNovaPoshtaConfig(): NovaPoshtaConfig { - const env = getServerEnv(); const flags = getShopShippingFlags(); - const apiBaseUrl = nonEmpty(env.NP_API_BASE) ?? DEFAULT_NP_API_BASE; - const defaultCargoType = nonEmpty(env.NP_DEFAULT_CARGO_TYPE) ?? 'Cargo'; - const defaultWeightGrams = parsePositiveInt(env.NP_DEFAULT_WEIGHT_GRAMS, 1000); + const apiBaseUrl = nonEmpty(process.env.NP_API_BASE) ?? DEFAULT_NP_API_BASE; + const defaultCargoType = nonEmpty(process.env.NP_DEFAULT_CARGO_TYPE) ?? 'Cargo'; + const defaultWeightGrams = parsePositiveInt( + process.env.NP_DEFAULT_WEIGHT_GRAMS, + 1000 + ); if (!flags.shippingEnabled || !flags.npEnabled) { return { @@ -85,15 +87,15 @@ export function getNovaPoshtaConfig(): NovaPoshtaConfig { }; } - const apiKey = nonEmpty(env.NP_API_KEY); + const apiKey = nonEmpty(process.env.NP_API_KEY); const sender = { - cityRef: nonEmpty(env.NP_SENDER_CITY_REF), - warehouseRef: nonEmpty(env.NP_SENDER_WAREHOUSE_REF), - senderRef: nonEmpty(env.NP_SENDER_REF), - contactRef: nonEmpty(env.NP_SENDER_CONTACT_REF), - name: nonEmpty(env.NP_SENDER_NAME), - phone: nonEmpty(env.NP_SENDER_PHONE), - edrpou: nonEmpty(env.NP_SENDER_EDRPOU), + cityRef: nonEmpty(process.env.NP_SENDER_CITY_REF), + warehouseRef: nonEmpty(process.env.NP_SENDER_WAREHOUSE_REF), + senderRef: nonEmpty(process.env.NP_SENDER_REF), + contactRef: nonEmpty(process.env.NP_SENDER_CONTACT_REF), + name: nonEmpty(process.env.NP_SENDER_NAME), + phone: nonEmpty(process.env.NP_SENDER_PHONE), + edrpou: nonEmpty(process.env.NP_SENDER_EDRPOU), }; const missing: string[] = []; diff --git a/frontend/lib/env/shop-canonical-events.ts b/frontend/lib/env/shop-canonical-events.ts new file mode 100644 index 00000000..4ff1a523 --- /dev/null +++ b/frontend/lib/env/shop-canonical-events.ts @@ -0,0 +1,37 @@ +import 'server-only'; + +function normalizedFlag(value: string | undefined): string { + return (value ?? '').trim().toLowerCase(); +} + +function isProductionRuntime(): boolean { + const appEnv = normalizedFlag(process.env.APP_ENV); + const nodeEnv = normalizedFlag(process.env.NODE_ENV); + return appEnv === 'production' || nodeEnv === 'production'; +} + +export function isCanonicalEventsDualWriteEnabled(): boolean { + const raw = normalizedFlag(process.env.SHOP_CANONICAL_EVENTS_DUAL_WRITE); + + if (!raw) { + return true; + } + + if (raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on') { + return true; + } + + if (raw === 'false' || raw === '0' || raw === 'no' || raw === 'off') { + if (isProductionRuntime()) { + throw new Error( + 'SHOP_CANONICAL_EVENTS_DUAL_WRITE cannot be disabled in production.' + ); + } + return false; + } + + throw new Error( + `Invalid SHOP_CANONICAL_EVENTS_DUAL_WRITE value: "${process.env.SHOP_CANONICAL_EVENTS_DUAL_WRITE ?? ''}".` + ); +} + diff --git a/frontend/lib/env/shop-intl.ts b/frontend/lib/env/shop-intl.ts new file mode 100644 index 00000000..fab32f21 --- /dev/null +++ b/frontend/lib/env/shop-intl.ts @@ -0,0 +1,15 @@ +import 'server-only'; + +function parsePositiveInt(raw: string | undefined, fallback: number): number { + const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +export function getIntlAcceptedPaymentTtlMinutes(): number { + return parsePositiveInt(process.env.SHOP_INTL_ACCEPTED_PAYMENT_TTL_MINUTES, 30); +} + +export function getIntlQuoteOfferTtlMinutes(): number { + return parsePositiveInt(process.env.SHOP_INTL_QUOTE_OFFER_TTL_MINUTES, 1440); +} diff --git a/frontend/lib/env/shop-legal.ts b/frontend/lib/env/shop-legal.ts new file mode 100644 index 00000000..094d85d3 --- /dev/null +++ b/frontend/lib/env/shop-legal.ts @@ -0,0 +1,27 @@ +import 'server-only'; + +const DEFAULT_TERMS_VERSION = 'terms-v1'; +const DEFAULT_PRIVACY_VERSION = 'privacy-v1'; + +function readVersion(raw: string | undefined, fallback: string): string { + const trimmed = (raw ?? '').trim(); + return trimmed.length > 0 ? trimmed : fallback; +} + +export type ShopLegalVersions = { + termsVersion: string; + privacyVersion: string; +}; + +export function getShopLegalVersions(): ShopLegalVersions { + return { + termsVersion: readVersion( + process.env.SHOP_TERMS_VERSION, + DEFAULT_TERMS_VERSION + ), + privacyVersion: readVersion( + process.env.SHOP_PRIVACY_VERSION, + DEFAULT_PRIVACY_VERSION + ), + }; +} diff --git a/frontend/lib/services/orders/_shared.ts b/frontend/lib/services/orders/_shared.ts index f874fefe..c1d739d9 100644 --- a/frontend/lib/services/orders/_shared.ts +++ b/frontend/lib/services/orders/_shared.ts @@ -191,6 +191,12 @@ export function hashIdempotencyRequest(params: { cityRef: string; warehouseRef: string | null; } | null; + legalConsent: { + termsAccepted: boolean; + privacyAccepted: boolean; + termsVersion: string; + privacyVersion: string; + }; }) { const normalized = [...params.items] .map(i => { @@ -209,11 +215,12 @@ export function hashIdempotencyRequest(params: { }); const payload = JSON.stringify({ - v: 2, + v: 3, currency: params.currency, locale: normVariant(params.locale).toLowerCase(), paymentProvider: params.paymentProvider, shipping: params.shipping, + legalConsent: params.legalConsent, items: normalized, }); diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index 3c8cfca1..5e796b8f 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -6,12 +6,14 @@ import { npCities, npWarehouses, orderItems, + orderLegalConsents, orders, orderShipping, productPrices, products, } from '@/db/schema/shop'; import { getShopShippingFlags } from '@/lib/env/nova-poshta'; +import { getShopLegalVersions } from '@/lib/env/shop-legal'; import { isPaymentsEnabled } from '@/lib/env/stripe'; import { logError, logWarn } from '@/lib/logging'; import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability'; @@ -26,6 +28,7 @@ import { import { type PaymentProvider, type PaymentStatus } from '@/lib/shop/payments'; import { type CheckoutItem, + type CheckoutLegalConsentInput, type CheckoutResult, type CheckoutShippingInput, type OrderSummaryWithMinor, @@ -325,6 +328,36 @@ type PreparedShipping = { snapshot: Record | null; }; +type PreparedLegalConsent = { + hashRefs: { + termsAccepted: true; + privacyAccepted: true; + termsVersion: string; + privacyVersion: string; + }; + snapshot: { + termsAccepted: true; + privacyAccepted: true; + termsVersion: string; + privacyVersion: string; + consentedAt: Date; + source: string; + locale: string | null; + country: string | null; + }; +}; + +function normalizeLegalVersion(raw: string | undefined, fallback: string): string { + const normalized = (raw ?? '').trim(); + return normalized.length > 0 ? normalized : fallback; +} + +function normalizeCountryCode(raw: string | null | undefined): string | null { + const normalized = (raw ?? '').trim().toUpperCase(); + if (normalized.length !== 2) return null; + return normalized; +} + function readShippingRefFromSnapshot( value: unknown, field: 'cityRef' | 'warehouseRef' @@ -532,6 +565,66 @@ async function prepareCheckoutShipping(args: { snapshot, }; } + +function resolveCheckoutLegalConsent(args: { + legalConsent?: CheckoutLegalConsentInput | null; + locale: string | null | undefined; + country: string | null | undefined; +}): PreparedLegalConsent { + const versions = getShopLegalVersions(); + + const termsAccepted = args.legalConsent?.termsAccepted ?? true; + const privacyAccepted = args.legalConsent?.privacyAccepted ?? true; + + if (!termsAccepted) { + throw new InvalidPayloadError('Terms must be accepted before checkout.', { + code: 'TERMS_NOT_ACCEPTED', + }); + } + + if (!privacyAccepted) { + throw new InvalidPayloadError('Privacy policy must be accepted.', { + code: 'PRIVACY_NOT_ACCEPTED', + }); + } + + const termsVersion = normalizeLegalVersion( + args.legalConsent?.termsVersion, + versions.termsVersion + ); + const privacyVersion = normalizeLegalVersion( + args.legalConsent?.privacyVersion, + versions.privacyVersion + ); + + const consentedAt = new Date(); + const source = + args.legalConsent == null ? 'checkout_implicit' : 'checkout_explicit'; + const normalizedLocale = normVariant(args.locale).toLowerCase() || null; + const normalizedCountry = normalizeCountryCode( + args.country ?? localeToCountry(args.locale) + ); + + return { + hashRefs: { + termsAccepted: true, + privacyAccepted: true, + termsVersion, + privacyVersion, + }, + snapshot: { + termsAccepted: true, + privacyAccepted: true, + termsVersion, + privacyVersion, + consentedAt, + source, + locale: normalizedLocale, + country: normalizedCountry, + }, + }; +} + type OrderShippingSnapshotDbClient = Pick; async function ensureOrderShippingSnapshot(args: { orderId: string; @@ -549,6 +642,30 @@ async function ensureOrderShippingSnapshot(args: { .onConflictDoNothing({ target: orderShipping.orderId }); } +type OrderLegalConsentSnapshotDbClient = Pick; +async function ensureOrderLegalConsentSnapshot(args: { + orderId: string; + snapshot: PreparedLegalConsent['snapshot']; + dbClient?: OrderLegalConsentSnapshotDbClient; +}) { + const client = args.dbClient ?? db; + + await client + .insert(orderLegalConsents) + .values({ + orderId: args.orderId, + termsAccepted: args.snapshot.termsAccepted, + privacyAccepted: args.snapshot.privacyAccepted, + termsVersion: args.snapshot.termsVersion, + privacyVersion: args.snapshot.privacyVersion, + consentedAt: args.snapshot.consentedAt, + source: args.snapshot.source, + locale: args.snapshot.locale, + country: args.snapshot.country, + }) + .onConflictDoNothing({ target: orderLegalConsents.orderId }); +} + function priceItems( items: CheckoutItemWithVariant[], productMap: Map, @@ -623,6 +740,7 @@ export async function createOrderWithItems({ locale, country, shipping, + legalConsent, paymentProvider: requestedProvider, }: { items: CheckoutItem[]; @@ -631,6 +749,7 @@ export async function createOrderWithItems({ locale: string | null | undefined; country?: string | null; shipping?: CheckoutShippingInput | null; + legalConsent?: CheckoutLegalConsentInput | null; paymentProvider?: PaymentProvider; }): Promise { const isMonobankRequested = requestedProvider === 'monobank'; @@ -660,6 +779,11 @@ export async function createOrderWithItems({ country: country ?? null, currency, }); + const preparedLegalConsent = resolveCheckoutLegalConsent({ + legalConsent: legalConsent ?? null, + locale, + country: country ?? null, + }); const requestHash = hashIdempotencyRequest({ items: normalizedItems, @@ -667,6 +791,7 @@ export async function createOrderWithItems({ locale: locale ?? null, paymentProvider, shipping: preparedShipping.hashRefs, + legalConsent: preparedLegalConsent.hashRefs, }); async function assertIdempotencyCompatible(existing: OrderSummaryWithMinor) { @@ -701,6 +826,26 @@ export async function createOrderWithItems({ .from(orderShipping) .where(eq(orderShipping.orderId, row.id)) .limit(1); + const [existingLegalConsentRow] = await db + .select({ + termsAccepted: orderLegalConsents.termsAccepted, + privacyAccepted: orderLegalConsents.privacyAccepted, + termsVersion: orderLegalConsents.termsVersion, + privacyVersion: orderLegalConsents.privacyVersion, + }) + .from(orderLegalConsents) + .where(eq(orderLegalConsents.orderId, row.id)) + .limit(1); + + if (!existingLegalConsentRow) { + throw new IdempotencyConflictError( + 'Idempotency key cannot be replayed because persisted legal consent evidence is missing.', + { + orderId: row.id, + reason: 'LEGAL_CONSENT_MISSING', + } + ); + } const existingCityRef = readShippingRefFromSnapshot( existingShippingRow?.shippingAddress, @@ -710,45 +855,69 @@ export async function createOrderWithItems({ existingShippingRow?.shippingAddress, 'warehouseRef' ); + const existingLegalHashRefs = { + termsAccepted: existingLegalConsentRow.termsAccepted, + privacyAccepted: existingLegalConsentRow.privacyAccepted, + termsVersion: existingLegalConsentRow.termsVersion, + privacyVersion: existingLegalConsentRow.privacyVersion, + }; - const derivedExistingHash = - row.idempotencyRequestHash ?? - hashIdempotencyRequest({ - items: (existing.items as any[]).map(i => ({ - productId: i.productId, - quantity: i.quantity, - selectedSize: normVariant((i as any).selectedSize), - selectedColor: normVariant((i as any).selectedColor), - options: { - ...(normVariant((i as any).selectedSize) - ? { size: normVariant((i as any).selectedSize) } - : {}), - ...(normVariant((i as any).selectedColor) - ? { color: normVariant((i as any).selectedColor) } - : {}), - }, - })) as CheckoutItemWithVariant[], - currency: row.currency, - locale: locale ?? null, - paymentProvider: resolvePaymentProvider({ - paymentProvider: row.paymentProvider, - paymentIntentId: existing.paymentIntentId ?? null, - paymentStatus: row.paymentStatus, - }), - shipping: - row.shippingProvider === 'nova_poshta' && - row.shippingMethodCode && - existingCityRef - ? { - provider: 'nova_poshta', - methodCode: row.shippingMethodCode, - cityRef: existingCityRef, - warehouseRef: existingWarehouseRef, - } - : null, - }); + if ( + existingLegalHashRefs.termsAccepted !== + preparedLegalConsent.hashRefs.termsAccepted || + existingLegalHashRefs.privacyAccepted !== + preparedLegalConsent.hashRefs.privacyAccepted || + existingLegalHashRefs.termsVersion !== + preparedLegalConsent.hashRefs.termsVersion || + existingLegalHashRefs.privacyVersion !== + preparedLegalConsent.hashRefs.privacyVersion + ) { + throw new IdempotencyConflictError( + 'Idempotency key already used with different legal consent.', + { + existing: existingLegalHashRefs, + requested: preparedLegalConsent.hashRefs, + } + ); + } - if (!row.idempotencyRequestHash) { + const derivedExistingHash = hashIdempotencyRequest({ + items: (existing.items as any[]).map(i => ({ + productId: i.productId, + quantity: i.quantity, + selectedSize: normVariant((i as any).selectedSize), + selectedColor: normVariant((i as any).selectedColor), + options: { + ...(normVariant((i as any).selectedSize) + ? { size: normVariant((i as any).selectedSize) } + : {}), + ...(normVariant((i as any).selectedColor) + ? { color: normVariant((i as any).selectedColor) } + : {}), + }, + })) as CheckoutItemWithVariant[], + currency: row.currency, + locale: locale ?? null, + paymentProvider: resolvePaymentProvider({ + paymentProvider: row.paymentProvider, + paymentIntentId: existing.paymentIntentId ?? null, + paymentStatus: row.paymentStatus, + }), + shipping: + row.shippingProvider === 'nova_poshta' && + row.shippingMethodCode && + existingCityRef + ? { + provider: 'nova_poshta', + methodCode: row.shippingMethodCode, + cityRef: existingCityRef, + warehouseRef: existingWarehouseRef, + } + : null, + legalConsent: existingLegalHashRefs, + }); + + if (row.idempotencyRequestHash !== derivedExistingHash) { try { await db .update(orders) @@ -914,8 +1083,13 @@ export async function createOrderWithItems({ if (!created) throw new Error('Failed to create order'); - if (preparedShipping.required && preparedShipping.snapshot) { - try { + try { + await ensureOrderLegalConsentSnapshot({ + orderId: created.id, + snapshot: preparedLegalConsent.snapshot, + }); + + if (preparedShipping.required && preparedShipping.snapshot) { await ensureOrderShippingSnapshot({ orderId: created.id, snapshot: preparedShipping.snapshot, @@ -936,6 +1110,21 @@ export async function createOrderWithItems({ } throw e; } + } catch (e) { + // Neon HTTP: no interactive transactions. Do compensating cleanup. + logError( + `[createOrderWithItems] order 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; } orderId = created.id; diff --git a/frontend/lib/services/orders/monobank-retry.ts b/frontend/lib/services/orders/monobank-retry.ts new file mode 100644 index 00000000..9e7b0cf6 --- /dev/null +++ b/frontend/lib/services/orders/monobank-retry.ts @@ -0,0 +1,52 @@ +import { InvalidPayloadError } from '@/lib/services/errors'; + +const NON_RETRYABLE_APPLY_CODES = new Set([ + 'INVALID_PAYLOAD', + 'ORDER_NOT_FOUND', + 'INVOICE_NOT_FOUND', + 'QUOTE_NOT_APPLICABLE', + 'QUOTE_ALREADY_ACCEPTED', + 'QUOTE_VERSION_CONFLICT', + 'QUOTE_CURRENCY_MISMATCH', + 'QUOTE_EXPIRED', + 'QUOTE_NOT_OFFERED', + 'QUOTE_STOCK_UNAVAILABLE', + 'QUOTE_NOT_ACCEPTED', + 'QUOTE_PAYMENT_WINDOW_EXPIRED', + 'QUOTE_INVENTORY_NOT_RESERVED', + 'QUOTE_INVALID_EXPIRY', + 'SLUG_CONFLICT', + 'PRICE_CONFIG_ERROR', + 'ORDER_STATE_INVALID', +]); + +const TRANSIENT_APPLY_CODES = new Set([ + 'PSP_TIMEOUT', + 'PSP_UNAVAILABLE', + 'PSP_INVOICE_PERSIST_FAILED', + 'ETIMEDOUT', + 'ECONNRESET', + 'ECONNREFUSED', + 'EAI_AGAIN', + 'ENOTFOUND', + '40001', + '40P01', + '53300', + '57P01', +]); + +export function getMonobankApplyErrorCode(error: unknown): string | null { + if (!error || typeof error !== 'object') return null; + const maybeCode = (error as { code?: unknown }).code; + return typeof maybeCode === 'string' ? maybeCode : null; +} + +export function isRetryableApplyError(error: unknown): boolean { + if (error instanceof InvalidPayloadError) return false; + + const code = getMonobankApplyErrorCode(error); + if (!code) return true; + if (NON_RETRYABLE_APPLY_CODES.has(code)) return false; + + return TRANSIENT_APPLY_CODES.has(code); +} diff --git a/frontend/lib/services/orders/monobank-webhook.ts b/frontend/lib/services/orders/monobank-webhook.ts index ccf2b2ef..b4bb6811 100644 --- a/frontend/lib/services/orders/monobank-webhook.ts +++ b/frontend/lib/services/orders/monobank-webhook.ts @@ -6,6 +6,7 @@ import { and, eq, sql } from 'drizzle-orm'; import { db } from '@/db'; import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; +import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; import { MONO_DEDUP, MONO_MISMATCH, @@ -22,11 +23,13 @@ import { import { InvalidPayloadError } from '@/lib/services/errors'; import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state'; import { restockOrder } from '@/lib/services/orders/restock'; +import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; import { inventoryCommittedForShippingSql, isInventoryCommittedForShipping, } from '@/lib/services/shop/shipping/inventory-eligibility'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; +import { shippingStatusTransitionWhereSql } from '@/lib/services/shop/transitions/shipping-state'; import { isUuidV1toV5 } from '@/lib/utils/uuid'; type WebhookMode = 'apply' | 'store' | 'drop'; @@ -477,110 +480,265 @@ async function atomicMarkPaidOrderAndSucceedAttempt(args: { now: Date; orderId: string; attemptId: string; + eventId: string; invoiceId: string; mergedMetaSql: ReturnType; nextProviderModifiedAt: Date | null; enqueueShipment: boolean; + canonicalDualWriteEnabled: boolean; + canonicalEventDedupeKey: string; }): Promise<{ ok: boolean; shipmentQueued: boolean }> { - const res = await db.execute(sql` - with updated_order as ( + const res = args.canonicalDualWriteEnabled + ? await db.execute(sql` + 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, + total_amount_minor, + currency, + 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 + ), + inserted_payment_event as ( + insert into payment_events ( + order_id, + provider, + event_name, + event_source, + event_ref, + attempt_id, + provider_payment_intent_id, + provider_charge_id, + amount_minor, + currency, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + 'monobank', + 'paid_applied', + 'monobank_webhook', + ${args.eventId}, + ${args.attemptId}::uuid, + ${args.invoiceId}, + null, + uo.total_amount_minor::bigint, + uo.currency, + ${JSON.stringify({ + monobankEventId: args.eventId, + invoiceId: args.invoiceId, + status: 'success', + })}::jsonb, + ${args.canonicalEventDedupeKey}, + ${args.now}, + ${args.now} + from updated_order uo + on conflict (dedupe_key) do nothing + 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 + ) + 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 status = 'PAID', - payment_status = 'paid', - psp_charge_id = ${args.invoiceId}, - psp_metadata = ${args.mergedMetaSql}, + set shipping_status = 'queued'::shipping_status, updated_at = ${args.now} - where id = ${args.orderId}::uuid - and payment_provider = 'monobank' - and exists ( - select 1 - from payment_attempts + where id in (select order_id from queued_order_ids) + and shipping_status is distinct from 'queued'::shipping_status + and ${shippingStatusTransitionWhereSql({ + column: sql`shipping_status`, + to: 'queued', + allowNullFrom: true, + })} + 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 + `) + : await db.execute(sql` + 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 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) + 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 + ) + 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 + and ${shippingStatusTransitionWhereSql({ + column: sql`shipping_status`, + to: 'queued', + allowNullFrom: true, + })} 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 - ) - 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 - `); + ) + 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; @@ -643,6 +801,11 @@ returning order_id updated_at = ${args.now} where id = ${args.orderId}::uuid and shipping_status is distinct from 'queued'::shipping_status + and ${shippingStatusTransitionWhereSql({ + column: sql`shipping_status`, + to: 'queued', + allowNullFrom: true, + })} and exists ( select 1 from shipping_shipments s @@ -950,15 +1113,27 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { orderRow.shippingProvider === 'nova_poshta' && Boolean(orderRow.shippingMethodCode) && isInventoryCommittedForShipping(orderRow.inventoryStatus); + const canonicalDualWriteEnabled = isCanonicalEventsDualWriteEnabled(); const atomicResult = await atomicMarkPaidOrderAndSucceedAttempt({ now, orderId: orderRow.id, attemptId: attemptRow.id, + eventId, invoiceId: normalized.invoiceId, mergedMetaSql, nextProviderModifiedAt: nextProviderModifiedAt ?? null, enqueueShipment, + canonicalDualWriteEnabled, + canonicalEventDedupeKey: buildPaymentEventDedupeKey({ + provider: 'monobank', + orderId: orderRow.id, + attemptId: attemptRow.id, + eventName: 'paid_applied', + eventSource: 'monobank_webhook', + monobankEventId: eventId, + invoiceId: normalized.invoiceId, + }), }); if (!atomicResult.ok) { diff --git a/frontend/lib/services/orders/payment-attempts.ts b/frontend/lib/services/orders/payment-attempts.ts index 9c1f871d..9d643d19 100644 --- a/frontend/lib/services/orders/payment-attempts.ts +++ b/frontend/lib/services/orders/payment-attempts.ts @@ -9,6 +9,7 @@ import { createPaymentIntent, retrievePaymentIntent } from '@/lib/psp/stripe'; import { OrderStateInvalidError } from '@/lib/services/errors'; import { setOrderPaymentIntent } from '@/lib/services/orders'; import { readStripePaymentIntentParams } from '@/lib/services/orders/payment-intent'; +import { assertIntlPaymentInitAllowed } from '@/lib/services/shop/quotes'; import { buildStripeAttemptIdempotencyKey } from './attempt-idempotency'; @@ -198,6 +199,11 @@ export async function ensureStripePaymentIntentForOrder(args: { const provider: PaymentProvider = 'stripe'; const maxAttempts = args.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + await assertIntlPaymentInitAllowed({ + orderId, + provider, + }); + let attempt = await getActiveAttempt(orderId, provider); if (!attempt && existingPaymentIntentId && existingPaymentIntentId.trim()) { diff --git a/frontend/lib/services/orders/restock.ts b/frontend/lib/services/orders/restock.ts index 1a37c3cd..a81a221c 100644 --- a/frontend/lib/services/orders/restock.ts +++ b/frontend/lib/services/orders/restock.ts @@ -5,6 +5,7 @@ import { and, eq, isNull, lt, ne, or } from 'drizzle-orm'; import { db } from '@/db'; import { inventoryMoves, orders } from '@/db/schema/shop'; import { logWarn } from '@/lib/logging'; +import { isOrderNonPaymentStatusTransitionAllowed } from '@/lib/services/shop/transitions/order-state'; import { type PaymentStatus } from '@/lib/shop/payments'; import { OrderNotFoundError, OrderStateInvalidError } from '../errors'; @@ -54,6 +55,40 @@ async function tryClaimRestockLease(params: { return !!row; } +function validateRestockTransition( + orderId: string, + currentStatus: string, + reason: RestockReason | undefined +): void { + const shouldCancel = reason === 'canceled'; + const shouldFail = reason === 'failed' || reason === 'stale'; + const targetOrderStatus = shouldFail + ? 'INVENTORY_FAILED' + : shouldCancel + ? 'CANCELED' + : null; + + if (!targetOrderStatus) return; + + if ( + !isOrderNonPaymentStatusTransitionAllowed(currentStatus, targetOrderStatus, { + includeSame: true, + }) + ) { + throw new OrderStateInvalidError( + `Invalid order status transition: ${currentStatus} -> ${targetOrderStatus}`, + { + orderId, + details: { + reason, + fromStatus: currentStatus, + toStatus: targetOrderStatus, + }, + } + ); + } +} + export async function restockOrder( orderId: string, options?: RestockOptions @@ -63,6 +98,7 @@ export async function restockOrder( const [order] = await db .select({ id: orders.id, + status: orders.status, paymentProvider: orders.paymentProvider, [PAYMENT_STATUS_KEY]: orders.paymentStatus, paymentIntentId: orders.paymentIntentId, @@ -122,6 +158,7 @@ export async function restockOrder( const now = new Date(); const shouldCancel = reason === 'canceled'; const shouldFail = reason === 'failed' || reason === 'stale'; + validateRestockTransition(orderId, order.status, reason); const orphanFailureCode = order.failureCode ?? @@ -149,10 +186,28 @@ export async function restockOrder( restockedAt: now, updatedAt: now, }) - .where(and(eq(orders.id, orderId), eq(orders.stockRestored, false))) + .where( + and( + eq(orders.id, orderId), + eq(orders.stockRestored, false), + eq(orders.status, order.status) + ) + ) .returning({ id: orders.id }); - if (!touched) return; + if (!touched) { + throw new OrderStateInvalidError( + `Cannot finalize orphan restock due to concurrent order state change.`, + { + orderId, + details: { + reason, + expectedStatus: order.status, + phase: 'orphan_finalize', + }, + } + ); + } let normalizedStatus: PaymentStatus | undefined; if (reason === 'refunded' && !isNoPayment) normalizedStatus = 'refunded'; @@ -181,6 +236,10 @@ export async function restockOrder( } ); } + const shouldCancel = reason === 'canceled'; + const shouldFail = reason === 'failed' || reason === 'stale'; + validateRestockTransition(orderId, order.status, reason); + const claimTtlMinutes = options?.claimTtlMinutes ?? 5; const workerId = options?.workerId ?? 'restock'; if (!options?.alreadyClaimed) { @@ -274,32 +333,41 @@ export async function restockOrder( const [finalized] = await db .update(orders) .set({ + inventoryStatus: 'released', stockRestored: true, restockedAt: finalizedAt, updatedAt: finalizedAt, + ...(shouldFail ? { status: 'INVENTORY_FAILED' } : {}), + ...(shouldCancel ? { status: 'CANCELED' } : {}), }) - .where(and(eq(orders.id, orderId), eq(orders.stockRestored, false))) + .where( + and( + eq(orders.id, orderId), + eq(orders.stockRestored, false), + eq(orders.status, order.status) + ) + ) .returning({ id: orders.id }); - if (!finalized) return; + if (!finalized) { + throw new OrderStateInvalidError( + `Cannot finalize restock due to concurrent order state change.`, + { + orderId, + details: { + reason, + expectedStatus: order.status, + phase: 'finalize', + }, + } + ); + } let normalizedStatus: PaymentStatus | undefined; if (reason === 'refunded' && !isNoPayment) normalizedStatus = 'refunded'; else if (reason === 'failed' || reason === 'canceled' || reason === 'stale') normalizedStatus = 'failed'; - const shouldCancel = reason === 'canceled'; - const shouldFail = reason === 'failed' || reason === 'stale'; - await db - .update(orders) - .set({ - inventoryStatus: 'released', - updatedAt: finalizedAt, - ...(shouldFail ? { status: 'INVENTORY_FAILED' } : {}), - ...(shouldCancel ? { status: 'CANCELED' } : {}), - }) - .where(eq(orders.id, orderId)); - if (normalizedStatus) { await guardedPaymentStatusUpdate({ orderId, diff --git a/frontend/lib/services/products/mutations/create.ts b/frontend/lib/services/products/mutations/create.ts index a25be302..bec3d9f6 100644 --- a/frontend/lib/services/products/mutations/create.ts +++ b/frontend/lib/services/products/mutations/create.ts @@ -17,11 +17,14 @@ import { } from '../prices'; import { normalizeSlug } from '../slug'; -export async function createProduct(input: ProductInput): Promise { - const slug = await normalizeSlug( - db, - (input as any).slug ?? (input as any).title - ); +type ProductMutationExecutor = Pick; + +export async function createProduct( + input: ProductInput, + options?: { db?: ProductMutationExecutor } +): Promise { + const executor = options?.db ?? db; + const slug = await normalizeSlug(executor, (input as any).slug ?? (input as any).title); let uploaded: { secureUrl: string; publicId: string } | null = null; @@ -47,7 +50,7 @@ export async function createProduct(input: ProductInput): Promise { let createdProductId: string | null = null; try { - const [row] = await db + const [row] = await executor .insert(products) .values({ slug, @@ -81,7 +84,7 @@ export async function createProduct(input: ProductInput): Promise { createdProductId = row.id; - await db.insert(productPrices).values( + await executor.insert(productPrices).values( prices.map(p => { const priceMinor = p.priceMinor; const originalMinor = p.originalPriceMinor; @@ -100,7 +103,7 @@ export async function createProduct(input: ProductInput): Promise { return mapRowToProduct(row); } catch (error) { - if (createdProductId) { + if (createdProductId && !options?.db) { try { await db.delete(products).where(eq(products.id, createdProductId)); } catch (cleanupDbError) { diff --git a/frontend/lib/services/products/mutations/update.ts b/frontend/lib/services/products/mutations/update.ts index 9c841563..c9abfa8d 100644 --- a/frontend/lib/services/products/mutations/update.ts +++ b/frontend/lib/services/products/mutations/update.ts @@ -125,23 +125,9 @@ export async function updateProduct( ? (input as any).sku : null : existing.sku, - - currency: 'USD', - price: existing.price, - originalPrice: existing.originalPrice, }; - - if (prices.length) { - const usd = prices.find(p => p.currency === 'USD'); - if (usd?.priceMinor) { - updateData.price = toDbMoney(usd.priceMinor); - updateData.originalPrice = - usd.originalPriceMinor == null - ? null - : toDbMoney(usd.originalPriceMinor); - updateData.currency = 'USD'; - } - } + // Legacy products.price/original_price are intentionally not updated here. + // product_prices is the single write-authority for catalog pricing. try { if (prices.length) { diff --git a/frontend/lib/services/products/slug.ts b/frontend/lib/services/products/slug.ts index a7d90253..6b7addc3 100644 --- a/frontend/lib/services/products/slug.ts +++ b/frontend/lib/services/products/slug.ts @@ -4,7 +4,7 @@ import { products } from '@/db/schema'; import { slugify } from '@/lib/shop/slug'; import { SlugConflictError } from '../errors'; -import type { DbClient } from './types'; +import type { SlugDbClient } from './types'; function randomSuffix(length = 6) { return Math.random() @@ -13,7 +13,7 @@ function randomSuffix(length = 6) { } async function ensureUniqueSlug( - db: DbClient, + db: SlugDbClient, baseSlug: string, options?: { excludeId?: string } ): Promise { @@ -46,7 +46,7 @@ async function ensureUniqueSlug( } export async function normalizeSlug( - db: DbClient, + db: SlugDbClient, slug: string, options?: { excludeId?: string } ) { diff --git a/frontend/lib/services/products/types.ts b/frontend/lib/services/products/types.ts index c9193f06..d49c2273 100644 --- a/frontend/lib/services/products/types.ts +++ b/frontend/lib/services/products/types.ts @@ -19,6 +19,7 @@ export type AdminProductsFilter = { export type ProductsTable = typeof import('@/db/schema').products; export type ProductRow = ProductsTable['$inferSelect']; export type DbClient = typeof import('@/db').db; +export type SlugDbClient = Pick; export type NormalizedPriceRow = { currency: CurrencyCode; diff --git a/frontend/lib/services/shop/events/dedupe-key.ts b/frontend/lib/services/shop/events/dedupe-key.ts new file mode 100644 index 00000000..8382b302 --- /dev/null +++ b/frontend/lib/services/shop/events/dedupe-key.ts @@ -0,0 +1,74 @@ +import crypto from 'node:crypto'; + +type CanonicalValue = + | null + | boolean + | number + | string + | CanonicalValue[] + | { [key: string]: CanonicalValue }; + +function normalizeNumber(value: number): number | string { + if (Number.isFinite(value)) return value; + return String(value); +} + +function toCanonicalValue(value: unknown): CanonicalValue { + if (value === null || value === undefined) return null; + + if (value instanceof Date) return value.toISOString(); + + const valueType = typeof value; + if (valueType === 'boolean') return value as boolean; + if (valueType === 'number') return normalizeNumber(value as number); + if (valueType === 'string') return value as string; + if (valueType === 'bigint') return String(value); + + if (Array.isArray(value)) { + return value.map(entry => toCanonicalValue(entry)); + } + + if (valueType === 'object') { + const source = value as Record; + const keys = Object.keys(source).sort((a, b) => a.localeCompare(b)); + const out: Record = {}; + for (const key of keys) { + const entry = source[key]; + if (entry === undefined) continue; + out[key] = toCanonicalValue(entry); + } + return out; + } + + return String(value); +} + +function stableSerialize(value: unknown): string { + return JSON.stringify(toCanonicalValue(value)); +} + +export function buildDedupeKey(namespace: string, seed: unknown): string { + const normalizedNamespace = namespace.trim().toLowerCase(); + const canonical = stableSerialize(seed); + const hash = crypto + .createHash('sha256') + .update(`${normalizedNamespace}|v1|${canonical}`, 'utf8') + .digest('hex'); + return `${normalizedNamespace}:v1:${hash}`; +} + +export function buildPaymentEventDedupeKey(seed: unknown): string { + return buildDedupeKey('payment_event', seed); +} + +export function buildShippingEventDedupeKey(seed: unknown): string { + return buildDedupeKey('shipping_event', seed); +} + +export function buildAdminAuditDedupeKey(seed: unknown): string { + return buildDedupeKey('admin_audit', seed); +} + +export function buildNotificationOutboxDedupeKey(seed: unknown): string { + return buildDedupeKey('notification_outbox', seed); +} diff --git a/frontend/lib/services/shop/events/write-admin-audit.ts b/frontend/lib/services/shop/events/write-admin-audit.ts new file mode 100644 index 00000000..e1486c6c --- /dev/null +++ b/frontend/lib/services/shop/events/write-admin-audit.ts @@ -0,0 +1,61 @@ +import 'server-only'; + +import { db } from '@/db'; +import { adminAuditLog } from '@/db/schema'; +import { buildAdminAuditDedupeKey } from '@/lib/services/shop/events/dedupe-key'; + +export type WriteAdminAuditArgs = { + orderId?: string | null; + actorUserId?: string | null; + action: string; + targetType: string; + targetId: string; + requestId?: string | null; + payload?: Record; + occurredAt?: Date; + dedupeKey?: string; + dedupeSeed?: unknown; +}; + +type AdminAuditExecutor = Pick; + +export async function writeAdminAudit( + args: WriteAdminAuditArgs, + options?: { db?: AdminAuditExecutor } +): Promise<{ inserted: boolean; dedupeKey: string; id: string | null }> { + const executor = options?.db ?? db; + const dedupeKey = + args.dedupeKey ?? + buildAdminAuditDedupeKey( + args.dedupeSeed ?? { + orderId: args.orderId ?? null, + actorUserId: args.actorUserId ?? null, + action: args.action, + targetType: args.targetType, + targetId: args.targetId, + requestId: args.requestId ?? null, + } + ); + + const inserted = await executor + .insert(adminAuditLog) + .values({ + orderId: args.orderId ?? null, + actorUserId: args.actorUserId ?? null, + action: args.action, + targetType: args.targetType, + targetId: args.targetId, + requestId: args.requestId ?? null, + payload: args.payload ?? {}, + dedupeKey, + occurredAt: args.occurredAt ?? new Date(), + }) + .onConflictDoNothing() + .returning({ id: adminAuditLog.id }); + + return { + inserted: inserted.length > 0, + dedupeKey, + id: inserted[0]?.id ?? null, + }; +} diff --git a/frontend/lib/services/shop/events/write-payment-event.ts b/frontend/lib/services/shop/events/write-payment-event.ts new file mode 100644 index 00000000..db062b70 --- /dev/null +++ b/frontend/lib/services/shop/events/write-payment-event.ts @@ -0,0 +1,71 @@ +import 'server-only'; + +import { db } from '@/db'; +import { paymentEvents } from '@/db/schema'; +import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; + +export type WritePaymentEventArgs = { + orderId: string; + provider: string; + eventName: string; + eventSource: string; + eventRef?: string | null; + attemptId?: string | null; + providerPaymentIntentId?: string | null; + providerChargeId?: string | null; + amountMinor: number; + currency: 'USD' | 'UAH'; + payload?: Record; + occurredAt?: Date; + dedupeKey?: string; + dedupeSeed?: unknown; +}; + +export async function writePaymentEvent(args: WritePaymentEventArgs): Promise<{ + inserted: boolean; + dedupeKey: string; + id: string | null; +}> { + const dedupeKey = + args.dedupeKey ?? + buildPaymentEventDedupeKey( + args.dedupeSeed ?? { + orderId: args.orderId, + provider: args.provider, + eventName: args.eventName, + eventSource: args.eventSource, + eventRef: args.eventRef ?? null, + attemptId: args.attemptId ?? null, + providerPaymentIntentId: args.providerPaymentIntentId ?? null, + providerChargeId: args.providerChargeId ?? null, + amountMinor: args.amountMinor, + currency: args.currency, + } + ); + + const inserted = await db + .insert(paymentEvents) + .values({ + orderId: args.orderId, + provider: args.provider, + eventName: args.eventName, + eventSource: args.eventSource, + eventRef: args.eventRef ?? null, + attemptId: args.attemptId ?? null, + providerPaymentIntentId: args.providerPaymentIntentId ?? null, + providerChargeId: args.providerChargeId ?? null, + amountMinor: args.amountMinor, + currency: args.currency, + payload: args.payload ?? {}, + dedupeKey, + occurredAt: args.occurredAt ?? new Date(), + }) + .onConflictDoNothing() + .returning({ id: paymentEvents.id }); + + return { + inserted: inserted.length > 0, + dedupeKey, + id: inserted[0]?.id ?? null, + }; +} diff --git a/frontend/lib/services/shop/events/write-shipping-event.ts b/frontend/lib/services/shop/events/write-shipping-event.ts new file mode 100644 index 00000000..299f3818 --- /dev/null +++ b/frontend/lib/services/shop/events/write-shipping-event.ts @@ -0,0 +1,65 @@ +import 'server-only'; + +import { db } from '@/db'; +import { shippingEvents } from '@/db/schema'; +import { buildShippingEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; + +export type WriteShippingEventArgs = { + orderId: string; + shipmentId?: string | null; + provider: string; + eventName: string; + eventSource: string; + eventRef?: string | null; + statusFrom?: string | null; + statusTo?: string | null; + trackingNumber?: string | null; + payload?: Record; + occurredAt?: Date; + dedupeKey?: string; + dedupeSeed?: unknown; +}; + +export async function writeShippingEvent( + args: WriteShippingEventArgs +): Promise<{ inserted: boolean; dedupeKey: string; id: string | null }> { + const dedupeKey = + args.dedupeKey ?? + buildShippingEventDedupeKey( + args.dedupeSeed ?? { + orderId: args.orderId, + shipmentId: args.shipmentId ?? null, + provider: args.provider, + eventName: args.eventName, + eventSource: args.eventSource, + eventRef: args.eventRef ?? null, + statusFrom: args.statusFrom ?? null, + statusTo: args.statusTo ?? null, + } + ); + + const inserted = await db + .insert(shippingEvents) + .values({ + orderId: args.orderId, + shipmentId: args.shipmentId ?? null, + provider: args.provider, + eventName: args.eventName, + eventSource: args.eventSource, + eventRef: args.eventRef ?? null, + statusFrom: args.statusFrom ?? null, + statusTo: args.statusTo ?? null, + trackingNumber: args.trackingNumber ?? null, + payload: args.payload ?? {}, + dedupeKey, + occurredAt: args.occurredAt ?? new Date(), + }) + .onConflictDoNothing() + .returning({ id: shippingEvents.id }); + + return { + inserted: inserted.length > 0, + dedupeKey, + id: inserted[0]?.id ?? null, + }; +} diff --git a/frontend/lib/services/shop/notifications/outbox-worker.ts b/frontend/lib/services/shop/notifications/outbox-worker.ts new file mode 100644 index 00000000..1032b6cd --- /dev/null +++ b/frontend/lib/services/shop/notifications/outbox-worker.ts @@ -0,0 +1,449 @@ +import 'server-only'; + +import { sql } from 'drizzle-orm'; + +import { db } from '@/db'; +import { notificationOutbox } from '@/db/schema'; +import { logInfo, logWarn } from '@/lib/logging'; +import { + renderShopNotificationTemplate, + type ShopNotificationTemplateKey, +} from '@/lib/services/shop/notifications/templates'; +import { + sendShopNotificationEmail, + ShopNotificationTransportError, +} from '@/lib/services/shop/notifications/transport'; + +type OutboxClaimedRow = { + id: string; + order_id: string; + channel: string; + template_key: string; + source_domain: string; + source_event_id: string; + payload: unknown; + status: string; + attempt_count: number; + max_attempts: number; + dedupe_key: string; +}; + +type PreviewCountRow = { total: number }; + +type NotificationRecipientLookupRow = { + shipping_email: string | null; + user_email: string | null; +}; + +type NotificationRecipient = { + email: string; +}; + +export type NotificationWorkerRunArgs = { + runId: string; + limit: number; + leaseSeconds: number; + maxAttempts: number; + baseBackoffSeconds: number; +}; + +export type NotificationWorkerRunResult = { + claimed: number; + processed: number; + sent: number; + retried: number; + deadLettered: number; + failed: number; +}; + +export class NotificationSendError extends Error { + readonly code: string; + readonly transient: boolean; + + constructor(code: string, message: string, transient: boolean) { + super(message); + this.name = 'NotificationSendError'; + this.code = code; + this.transient = transient; + } +} + +function readRows(res: unknown): T[] { + if (Array.isArray(res)) return res as T[]; + const anyRes = res as { rows?: unknown }; + if (Array.isArray(anyRes?.rows)) return anyRes.rows as T[]; + return []; +} + +function asObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function computeBackoffSeconds( + attemptCount: number, + baseBackoffSeconds: number +): number { + const cappedAttempt = Math.max(1, Math.min(attemptCount, 8)); + const exponential = Math.pow(2, cappedAttempt - 1); + const backoff = baseBackoffSeconds * exponential; + return Math.min(backoff, 6 * 60 * 60); +} + +function readTestFailureMode(payload: unknown): { + forceFail: boolean; + code: string; + transient: boolean; + message: string; +} { + const obj = asObject(payload); + const testMode = asObject(obj.testMode); + if (testMode.forceFail !== true) { + return { + forceFail: false, + code: 'NONE', + transient: false, + message: '', + }; + } + + const code = + typeof testMode.code === 'string' && testMode.code.trim().length > 0 + ? testMode.code.trim() + : 'NOTIFICATION_SEND_FAILED'; + const transient = testMode.transient !== false; + const message = + typeof testMode.message === 'string' && testMode.message.trim().length > 0 + ? testMode.message.trim() + : 'Notification sending failed.'; + + return { forceFail: true, code, transient, message }; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function normalizeEmailOrNull(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim().toLowerCase(); + if (!trimmed) return null; + if (trimmed === '[redacted]') return null; + if (!EMAIL_REGEX.test(trimmed)) return null; + return trimmed; +} + +async function loadNotificationRecipient( + orderId: string +): Promise { + const res = await db.execute(sql` + select + nullif(trim(os.shipping_address #>> '{recipient,email}'), '') as shipping_email, + nullif(trim(u.email), '') as user_email + from orders o + left join order_shipping os on os.order_id = o.id + left join users u on u.id = o.user_id + where o.id = ${orderId}::uuid + limit 1 + `); + + const row = readRows(res)[0]; + if (!row) return null; + + const shippingEmail = normalizeEmailOrNull(row.shipping_email); + if (shippingEmail) { + return { email: shippingEmail }; + } + + const userEmail = normalizeEmailOrNull(row.user_email); + if (userEmail) { + return { email: userEmail }; + } + + return null; +} + +function toNotificationSendError(error: unknown): NotificationSendError { + if (error instanceof NotificationSendError) return error; + + if (error instanceof ShopNotificationTransportError) { + return new NotificationSendError(error.code, error.message, error.transient); + } + + return new NotificationSendError( + 'NOTIFICATION_SEND_FAILED', + error instanceof Error ? error.message : 'Notification send failed.', + true + ); +} + +async function sendNotification(row: OutboxClaimedRow): Promise { + const failMode = readTestFailureMode(row.payload); + if (failMode.forceFail) { + throw new NotificationSendError( + failMode.code, + failMode.message, + failMode.transient + ); + } + + if (row.channel !== 'email') { + throw new NotificationSendError( + 'NOTIFICATION_CHANNEL_UNSUPPORTED', + `Unsupported notification channel: ${row.channel}`, + false + ); + } + + const recipient = await loadNotificationRecipient(row.order_id); + if (!recipient) { + throw new NotificationSendError( + 'NOTIFICATION_RECIPIENT_MISSING', + 'Notification recipient email is missing for order.', + false + ); + } + + const template = renderShopNotificationTemplate({ + templateKey: row.template_key as ShopNotificationTemplateKey, + orderId: row.order_id, + payload: asObject(row.payload), + }); + + if (!template) { + throw new NotificationSendError( + 'NOTIFICATION_TEMPLATE_UNSUPPORTED', + `Unsupported notification template: ${row.template_key}`, + false + ); + } + + const sendResult = await sendShopNotificationEmail({ + to: recipient.email, + subject: template.subject, + text: template.text, + html: template.html, + }); + + logInfo('shop_notification_sent', { + outboxId: row.id, + orderId: row.order_id, + channel: row.channel, + templateKey: row.template_key, + sourceDomain: row.source_domain, + sourceEventId: row.source_event_id, + messageId: sendResult.messageId, + }); +} + +export async function countRunnableNotificationOutboxRows(): Promise { + const res = await db.execute(sql` + select count(*)::int as total + from notification_outbox n + where ( + ( + n.status in ('pending', 'failed') + and n.next_attempt_at <= now() + and (n.lease_expires_at is null or n.lease_expires_at < now()) + ) + or ( + n.status = 'processing' + and n.lease_expires_at < now() + ) + ) + `); + return Number(readRows(res)[0]?.total ?? 0); +} + +export async function claimNotificationOutboxBatch(args: { + runId: string; + limit: number; + leaseSeconds: number; +}): Promise { + const res = await db.execute(sql` + with candidates as ( + select n.id + from notification_outbox n + where ( + ( + n.status in ('pending', 'failed') + and n.next_attempt_at <= now() + and (n.lease_expires_at is null or n.lease_expires_at < now()) + ) + or ( + n.status = 'processing' + and n.lease_expires_at < now() + ) + ) + order by n.next_attempt_at asc, n.created_at asc + for update skip locked + limit ${args.limit} + ), + claimed as ( + update notification_outbox n + set status = 'processing', + lease_owner = ${args.runId}, + lease_expires_at = now() + make_interval(secs => ${args.leaseSeconds}), + updated_at = now() + where n.id in (select id from candidates) + returning + n.id, + n.order_id, + n.channel, + n.template_key, + n.source_domain, + n.source_event_id, + n.payload, + n.status, + n.attempt_count, + n.max_attempts, + n.dedupe_key + ) + select * from claimed + `); + + return readRows(res); +} + +async function markSent(args: { + outboxId: string; + runId: string; +}): Promise { + const res = await db.execute<{ id: string }>(sql` + update notification_outbox n + set status = 'sent', + attempt_count = n.attempt_count + 1, + lease_owner = null, + lease_expires_at = null, + next_attempt_at = now(), + last_error_code = null, + last_error_message = null, + sent_at = now(), + updated_at = now() + where n.id = ${args.outboxId}::uuid + and n.lease_owner = ${args.runId} + returning n.id + `); + return readRows<{ id: string }>(res).length > 0; +} + +async function markFailedOrDeadLetter(args: { + row: OutboxClaimedRow; + runId: string; + maxAttempts: number; + baseBackoffSeconds: number; + code: string; + message: string; + transient: boolean; +}): Promise<'failed' | 'dead_letter' | 'lease_lost'> { + const attemptCount = Math.max(0, Number(args.row.attempt_count)) + 1; + const configuredMaxAttempts = Math.max( + 1, + Math.min( + args.maxAttempts, + Number(args.row.max_attempts) || args.maxAttempts + ) + ); + + const toDeadLetter = !args.transient || attemptCount >= configuredMaxAttempts; + const nextAttemptAt = new Date( + Date.now() + + computeBackoffSeconds(attemptCount, args.baseBackoffSeconds) * 1000 + ); + + const res = await db.execute<{ id: string }>(sql` + update notification_outbox n + set status = ${toDeadLetter ? 'dead_letter' : 'failed'}, + attempt_count = n.attempt_count + 1, + lease_owner = null, + lease_expires_at = null, + next_attempt_at = case + when ${toDeadLetter}::boolean then now() + else ${nextAttemptAt} + end, + last_error_code = ${args.code}, + last_error_message = ${args.message}, + dead_lettered_at = case + when ${toDeadLetter}::boolean then now() + else n.dead_lettered_at + end, + updated_at = now() + where n.id = ${args.row.id}::uuid + and n.lease_owner = ${args.runId} + returning n.id +`); + + if (readRows<{ id: string }>(res).length === 0) return 'lease_lost'; + return toDeadLetter ? 'dead_letter' : 'failed'; +} + +export async function runNotificationOutboxWorker( + args: NotificationWorkerRunArgs +): Promise { + const claimed = await claimNotificationOutboxBatch({ + runId: args.runId, + limit: args.limit, + leaseSeconds: args.leaseSeconds, + }); + + let processed = 0; + let sent = 0; + let retried = 0; + let deadLettered = 0; + let failed = 0; + + for (const row of claimed) { + processed += 1; + + try { + await sendNotification(row); + + const updated = await markSent({ + outboxId: row.id, + runId: args.runId, + }); + if (!updated) { + failed += 1; + } else { + sent += 1; + } + continue; + } catch (error) { + const sendError = toNotificationSendError(error); + + const transition = await markFailedOrDeadLetter({ + row, + runId: args.runId, + maxAttempts: args.maxAttempts, + baseBackoffSeconds: args.baseBackoffSeconds, + code: sendError.code, + message: sendError.message, + transient: sendError.transient, + }); + + if (transition === 'dead_letter') { + deadLettered += 1; + } else if (transition === 'failed') { + retried += 1; + } else { + failed += 1; + } + + logWarn('shop_notification_send_failed', { + outboxId: row.id, + orderId: row.order_id, + templateKey: row.template_key, + code: sendError.code, + transient: sendError.transient, + transition, + }); + } + } + + return { + claimed: claimed.length, + processed, + sent, + retried, + deadLettered, + failed, + }; +} diff --git a/frontend/lib/services/shop/notifications/projector.ts b/frontend/lib/services/shop/notifications/projector.ts new file mode 100644 index 00000000..0887d3d9 --- /dev/null +++ b/frontend/lib/services/shop/notifications/projector.ts @@ -0,0 +1,230 @@ +import 'server-only'; + +import { asc } from 'drizzle-orm'; + +import { db } from '@/db'; +import { + notificationOutbox, + paymentEvents, + shippingEvents, +} from '@/db/schema'; +import { buildNotificationOutboxDedupeKey } from '@/lib/services/shop/events/dedupe-key'; +import { + mapPaymentEventToTemplate, + mapShippingEventToTemplate, + SHOP_NOTIFICATION_CHANNEL, +} from '@/lib/services/shop/notifications/templates'; + +type ShippingCanonicalRow = { + id: string; + orderId: string; + eventName: string; + eventSource: string; + eventRef: string | null; + payload: Record; + occurredAt: Date; +}; + +type PaymentCanonicalRow = { + id: string; + orderId: string; + eventName: string; + eventSource: string; + eventRef: string | null; + payload: Record; + occurredAt: Date; +}; + +export type NotificationProjectorResult = { + scanned: number; + inserted: number; + insertedFromShippingEvents: number; + insertedFromPaymentEvents: number; +}; + +function asObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function buildOutboxDedupeKey(args: { + templateKey: string; + channel: string; + orderId: string; + canonicalEventId: string; +}): string { + return buildNotificationOutboxDedupeKey({ + templateKey: args.templateKey, + channel: args.channel, + orderId: args.orderId, + canonicalEventId: args.canonicalEventId, + }); +} + +function buildOutboxPayload(args: { + sourceDomain: 'shipping_event' | 'payment_event'; + canonicalEventId: string; + canonicalEventName: string; + canonicalEventSource: string; + canonicalEventRef: string | null; + canonicalOccurredAt: Date; + canonicalPayload: Record; +}) { + return { + sourceDomain: args.sourceDomain, + canonicalEventId: args.canonicalEventId, + canonicalEventName: args.canonicalEventName, + canonicalEventSource: args.canonicalEventSource, + canonicalEventRef: args.canonicalEventRef, + canonicalOccurredAt: args.canonicalOccurredAt.toISOString(), + canonicalPayload: args.canonicalPayload, + }; +} + +async function projectShippingEvents(limit: number): Promise<{ + scanned: number; + inserted: number; +}> { + if (limit <= 0) return { scanned: 0, inserted: 0 }; + + const candidates = (await db + .select({ + id: shippingEvents.id, + orderId: shippingEvents.orderId, + eventName: shippingEvents.eventName, + eventSource: shippingEvents.eventSource, + eventRef: shippingEvents.eventRef, + payload: shippingEvents.payload, + occurredAt: shippingEvents.occurredAt, + }) + .from(shippingEvents) + .orderBy(asc(shippingEvents.occurredAt), asc(shippingEvents.id)) + .limit(limit)) as ShippingCanonicalRow[]; + + let inserted = 0; + for (const event of candidates) { + const templateKey = mapShippingEventToTemplate(event.eventName); + if (!templateKey) continue; + + const dedupeKey = buildOutboxDedupeKey({ + templateKey, + channel: SHOP_NOTIFICATION_CHANNEL, + orderId: event.orderId, + canonicalEventId: event.id, + }); + + const insertedRows = await db + .insert(notificationOutbox) + .values({ + orderId: event.orderId, + channel: SHOP_NOTIFICATION_CHANNEL, + templateKey, + sourceDomain: 'shipping_event', + sourceEventId: event.id, + payload: buildOutboxPayload({ + sourceDomain: 'shipping_event', + canonicalEventId: event.id, + canonicalEventName: event.eventName, + canonicalEventSource: event.eventSource, + canonicalEventRef: event.eventRef, + canonicalOccurredAt: event.occurredAt, + canonicalPayload: asObject(event.payload), + }), + status: 'pending', + nextAttemptAt: new Date(), + dedupeKey, + }) + .onConflictDoNothing() + .returning({ id: notificationOutbox.id }); + + if (insertedRows.length > 0) inserted += 1; + } + + return { + scanned: candidates.length, + inserted, + }; +} + +async function projectPaymentEvents(limit: number): Promise<{ + scanned: number; + inserted: number; +}> { + if (limit <= 0) return { scanned: 0, inserted: 0 }; + + const candidates = (await db + .select({ + id: paymentEvents.id, + orderId: paymentEvents.orderId, + eventName: paymentEvents.eventName, + eventSource: paymentEvents.eventSource, + eventRef: paymentEvents.eventRef, + payload: paymentEvents.payload, + occurredAt: paymentEvents.occurredAt, + }) + .from(paymentEvents) + .orderBy(asc(paymentEvents.occurredAt), asc(paymentEvents.id)) + .limit(limit)) as PaymentCanonicalRow[]; + + let inserted = 0; + for (const event of candidates) { + const templateKey = mapPaymentEventToTemplate(event.eventName); + if (!templateKey) continue; + + const dedupeKey = buildOutboxDedupeKey({ + templateKey, + channel: SHOP_NOTIFICATION_CHANNEL, + orderId: event.orderId, + canonicalEventId: event.id, + }); + + const insertedRows = await db + .insert(notificationOutbox) + .values({ + orderId: event.orderId, + channel: SHOP_NOTIFICATION_CHANNEL, + templateKey, + sourceDomain: 'payment_event', + sourceEventId: event.id, + payload: buildOutboxPayload({ + sourceDomain: 'payment_event', + canonicalEventId: event.id, + canonicalEventName: event.eventName, + canonicalEventSource: event.eventSource, + canonicalEventRef: event.eventRef, + canonicalOccurredAt: event.occurredAt, + canonicalPayload: asObject(event.payload), + }), + status: 'pending', + nextAttemptAt: new Date(), + dedupeKey, + }) + .onConflictDoNothing() + .returning({ id: notificationOutbox.id }); + + if (insertedRows.length > 0) inserted += 1; + } + + return { + scanned: candidates.length, + inserted, + }; +} + +export async function runNotificationOutboxProjector(args?: { + limit?: number; +}): Promise { + const limit = Math.max(1, Math.min(500, Math.floor(args?.limit ?? 100))); + const shippingLimit = Math.max(1, Math.floor(limit / 2)); + const paymentLimit = Math.max(1, limit - shippingLimit); + + const shipping = await projectShippingEvents(shippingLimit); + const payment = await projectPaymentEvents(paymentLimit); + + return { + scanned: shipping.scanned + payment.scanned, + inserted: shipping.inserted + payment.inserted, + insertedFromShippingEvents: shipping.inserted, + insertedFromPaymentEvents: payment.inserted, + }; +} diff --git a/frontend/lib/services/shop/notifications/templates.ts b/frontend/lib/services/shop/notifications/templates.ts new file mode 100644 index 00000000..c61e93bf --- /dev/null +++ b/frontend/lib/services/shop/notifications/templates.ts @@ -0,0 +1,151 @@ +import 'server-only'; + +export const SHOP_NOTIFICATION_CHANNEL = 'email' as const; + +export const shopNotificationTemplateKeys = [ + 'intl_quote_requested', + 'intl_quote_offered', + 'intl_quote_accepted', + 'intl_quote_declined', + 'intl_quote_expired', + 'payment_confirmed', + 'shipment_created', + 'refund_processed', +] as const; + +export type ShopNotificationTemplateKey = + (typeof shopNotificationTemplateKeys)[number]; + +export type RenderShopNotificationTemplateArgs = { + templateKey: ShopNotificationTemplateKey; + orderId: string; + payload: Record; +}; + +export type RenderedShopNotificationTemplate = { + subject: string; + text: string; + html: string; +}; + +function toDisplayOrderId(orderId: string): string { + const trimmed = orderId.trim(); + if (!trimmed) return 'unknown'; + return trimmed.length <= 12 ? trimmed : trimmed.slice(0, 12); +} + +function escapeHtml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function readCanonicalEventName(payload: Record): string | null { + const raw = payload.canonicalEventName; + if (typeof raw !== 'string') return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function renderShopNotificationTemplate( + args: RenderShopNotificationTemplateArgs +): RenderedShopNotificationTemplate | null { + const orderTag = toDisplayOrderId(args.orderId); + const canonicalEvent = readCanonicalEventName(args.payload); + + let subject: string; + let leadLine: string; + + switch (args.templateKey) { + case 'intl_quote_requested': + subject = `[DevLovers] Quote requested for order ${orderTag}`; + leadLine = 'We received your international shipping quote request.'; + break; + case 'intl_quote_offered': + subject = `[DevLovers] Quote offered for order ${orderTag}`; + leadLine = 'Your international shipping quote is now available.'; + break; + case 'intl_quote_accepted': + subject = `[DevLovers] Quote accepted for order ${orderTag}`; + leadLine = 'Your international shipping quote has been accepted.'; + break; + case 'intl_quote_declined': + subject = `[DevLovers] Quote declined for order ${orderTag}`; + leadLine = 'Your international shipping quote has been declined.'; + break; + case 'intl_quote_expired': + subject = `[DevLovers] Quote expired for order ${orderTag}`; + leadLine = 'Your international shipping quote has expired.'; + break; + case 'payment_confirmed': + subject = `[DevLovers] Payment confirmed for order ${orderTag}`; + leadLine = 'Your payment has been confirmed.'; + break; + case 'shipment_created': + subject = `[DevLovers] Shipment created for order ${orderTag}`; + leadLine = 'Your shipment label has been created.'; + break; + case 'refund_processed': + subject = `[DevLovers] Refund processed for order ${orderTag}`; + leadLine = 'Your refund has been processed.'; + break; + default: + return null; + } + + const eventLine = canonicalEvent ? `Canonical event: ${canonicalEvent}` : null; + const text = [leadLine, `Order: ${orderTag}`, eventLine] + .filter(Boolean) + .join('\n'); + + const html = [ + `

${escapeHtml(leadLine)}

`, + `

Order: ${escapeHtml(orderTag)}

`, + eventLine + ? `

Canonical event: ${escapeHtml(canonicalEvent!)}

` + : '', + ] + .filter(Boolean) + .join(''); + + return { subject, text, html }; +} + +export function mapShippingEventToTemplate( + eventName: string +): ShopNotificationTemplateKey | null { + switch (eventName) { + case 'quote_requested': + return 'intl_quote_requested'; + case 'quote_offered': + return 'intl_quote_offered'; + case 'quote_accepted': + return 'intl_quote_accepted'; + case 'quote_declined': + return 'intl_quote_declined'; + case 'quote_expired': + case 'quote_timeout_requires_requote': + return 'intl_quote_expired'; + case 'shipment_created': + case 'label_created': + return 'shipment_created'; + default: + return null; + } +} + +export function mapPaymentEventToTemplate( + eventName: string +): ShopNotificationTemplateKey | null { + switch (eventName) { + case 'paid_applied': + return 'payment_confirmed'; + case 'refund_applied': + return 'refund_processed'; + default: + return null; + } +} diff --git a/frontend/lib/services/shop/notifications/transport.ts b/frontend/lib/services/shop/notifications/transport.ts new file mode 100644 index 00000000..120fc90f --- /dev/null +++ b/frontend/lib/services/shop/notifications/transport.ts @@ -0,0 +1,150 @@ +import 'server-only'; + +import nodemailer, { type Transporter } from 'nodemailer'; + +export type ShopNotificationEmailArgs = { + to: string; + subject: string; + text: string; + html: string; +}; + +export type ShopNotificationEmailResult = { + messageId: string | null; +}; + +export class ShopNotificationTransportError extends Error { + readonly code: string; + readonly transient: boolean; + + constructor(code: string, message: string, transient: boolean) { + super(message); + this.name = 'ShopNotificationTransportError'; + this.code = code; + this.transient = transient; + } +} + +type TransportConfig = { + from: string; + gmailUser: string; + gmailAppPassword: string; +}; + +let cachedTransport: Transporter | null = null; +let cachedTransportKey: string | null = null; + +function trimOrNull(value: string | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function readTransportConfig(): TransportConfig { + const from = trimOrNull(process.env.EMAIL_FROM); + const gmailUser = trimOrNull(process.env.GMAIL_USER); + const gmailAppPassword = trimOrNull(process.env.GMAIL_APP_PASSWORD); + + if (!from || !gmailUser || !gmailAppPassword) { + throw new ShopNotificationTransportError( + 'NOTIFICATION_TRANSPORT_MISCONFIG', + 'Email transport is not configured (EMAIL_FROM/GMAIL_USER/GMAIL_APP_PASSWORD required).', + false + ); + } + + return { + from, + gmailUser, + gmailAppPassword, + }; +} + +function getTransport(config: TransportConfig): Transporter { + const key = `${config.gmailUser}|${config.from}`; + if (cachedTransport && cachedTransportKey === key) { + return cachedTransport; + } + + cachedTransport = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: config.gmailUser, + pass: config.gmailAppPassword, + }, + }); + cachedTransportKey = key; + + return cachedTransport; +} + +function classifySendFailure(error: unknown): ShopNotificationTransportError { + if (error instanceof ShopNotificationTransportError) return error; + + const err = error as { + code?: unknown; + responseCode?: unknown; + message?: unknown; + }; + + const codeRaw = + typeof err?.code === 'string' && err.code.trim().length > 0 + ? err.code.trim().toUpperCase() + : 'NOTIFICATION_TRANSPORT_SEND_FAILED'; + + const responseCode = + typeof err?.responseCode === 'number' && Number.isFinite(err.responseCode) + ? err.responseCode + : null; + + const message = + error instanceof Error && error.message.trim().length > 0 + ? error.message.trim() + : 'Notification transport send failed.'; + + const transientCodes = new Set(['ECONNECTION', 'ETIMEDOUT', 'EAI_AGAIN']); + const permanentCodes = new Set([ + 'EAUTH', + 'EENVELOPE', + 'EMESSAGE', + 'ESOCKET', + 'NOTIFICATION_TRANSPORT_MISCONFIG', + ]); + + let transient: boolean; + if (transientCodes.has(codeRaw)) { + transient = true; + } else if (permanentCodes.has(codeRaw)) { + transient = false; + } else if (responseCode !== null) { + if (responseCode >= 400 && responseCode < 500) transient = true; + else transient = false; + } else { + transient = true; + } + + return new ShopNotificationTransportError(codeRaw, message, transient); +} + +export async function sendShopNotificationEmail( + args: ShopNotificationEmailArgs +): Promise { + const config = readTransportConfig(); + const transport = getTransport(config); + + try { + const info = await transport.sendMail({ + from: config.from, + to: args.to, + subject: args.subject, + text: args.text, + html: args.html, + }); + + return { + messageId: typeof info?.messageId === 'string' ? info.messageId : null, + }; + } catch (error) { + throw classifySendFailure(error); + } +} diff --git a/frontend/lib/services/shop/order-access.ts b/frontend/lib/services/shop/order-access.ts new file mode 100644 index 00000000..9961e459 --- /dev/null +++ b/frontend/lib/services/shop/order-access.ts @@ -0,0 +1,116 @@ +import 'server-only'; + +import { eq } from 'drizzle-orm'; + +import { db } from '@/db'; +import { orders } from '@/db/schema'; +import { getCurrentUser } from '@/lib/auth'; +import { + hasStatusTokenScope, + type StatusTokenScope, + verifyStatusToken, +} from '@/lib/shop/status-token'; + +export type OrderAccessResult = { + authorized: boolean; + actorUserId: string | null; + code: + | 'OK' + | 'ORDER_NOT_FOUND' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'STATUS_TOKEN_REQUIRED' + | 'STATUS_TOKEN_INVALID' + | 'STATUS_TOKEN_SCOPE_FORBIDDEN' + | 'STATUS_TOKEN_MISCONFIGURED'; + status: number; +}; + +export async function authorizeOrderMutationAccess(args: { + orderId: string; + statusToken: string | null; + requiredScope: StatusTokenScope; +}): Promise { + const [orderRow] = await db + .select({ id: orders.id, userId: orders.userId }) + .from(orders) + .where(eq(orders.id, args.orderId)) + .limit(1); + + if (!orderRow) { + return { + authorized: false, + actorUserId: null, + code: 'ORDER_NOT_FOUND', + status: 404, + }; + } + + const user = await getCurrentUser(); + if (user) { + if (user.role === 'admin') { + return { + authorized: true, + actorUserId: user.id, + code: 'OK', + status: 200, + }; + } + + if (orderRow.userId === user.id) { + return { + authorized: true, + actorUserId: user.id, + code: 'OK', + status: 200, + }; + } + } + + if (!args.statusToken || !args.statusToken.trim()) { + return { + authorized: false, + actorUserId: null, + code: user ? 'FORBIDDEN' : 'STATUS_TOKEN_REQUIRED', + status: user ? 403 : 401, + }; + } + + const tokenRes = verifyStatusToken({ + token: args.statusToken.trim(), + orderId: args.orderId, + }); + if (!tokenRes.ok) { + if (tokenRes.reason === 'missing_secret') { + return { + authorized: false, + actorUserId: null, + code: 'STATUS_TOKEN_MISCONFIGURED', + status: 500, + }; + } + + return { + authorized: false, + actorUserId: null, + code: 'STATUS_TOKEN_INVALID', + status: 403, + }; + } + + if (!hasStatusTokenScope(tokenRes.payload, args.requiredScope)) { + return { + authorized: false, + actorUserId: null, + code: 'STATUS_TOKEN_SCOPE_FORBIDDEN', + status: 403, + }; + } + + return { + authorized: true, + actorUserId: null, + code: 'OK', + status: 200, + }; +} diff --git a/frontend/lib/services/shop/quotes.ts b/frontend/lib/services/shop/quotes.ts new file mode 100644 index 00000000..a2c4d0cc --- /dev/null +++ b/frontend/lib/services/shop/quotes.ts @@ -0,0 +1,1287 @@ +import 'server-only'; + +import { and, asc, desc, eq, lte } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; + +import { db } from '@/db'; +import { orderItems, orders, shippingQuotes } from '@/db/schema'; +import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; +import { + getIntlAcceptedPaymentTtlMinutes, + getIntlQuoteOfferTtlMinutes, +} from '@/lib/env/shop-intl'; +import { applyReleaseMove, applyReserveMove } from '@/lib/services/inventory'; +import { aggregateReserveByProductId } from '@/lib/services/orders/_shared'; +import { restockOrder } from '@/lib/services/orders'; +import { buildShippingEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; +import { + isOrderQuoteStatusTransitionAllowed, + orderQuoteTransitionWhereSql, + type OrderQuoteStatus, +} from '@/lib/services/shop/transitions/order-state'; +import { InvalidPayloadError, OrderNotFoundError } from '@/lib/services/errors'; + +type QuoteStatus = OrderQuoteStatus; + +type FulfillmentMode = 'ua_np' | 'intl'; + +type OrderQuoteRow = { + id: string; + currency: 'USD' | 'UAH'; + paymentProvider: string; + paymentStatus: string; + fulfillmentMode: FulfillmentMode; + quoteStatus: QuoteStatus; + quoteVersion: number | null; + shippingQuoteMinor: number | null; + itemsSubtotalMinor: number; + totalAmountMinor: number; + inventoryStatus: string; + quotePaymentDeadlineAt: Date | null; +}; + +type QuoteRow = { + id: string; + orderId: string; + version: number; + status: QuoteStatus; + currency: 'USD' | 'UAH'; + shippingQuoteMinor: number; + expiresAt: Date; +}; + +function parseDateOrNull(value: unknown): Date | null { + if (!value) return null; + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + const parsed = new Date(String(value)); + if (Number.isNaN(parsed.getTime())) return null; + return parsed; +} + +function quoteError( + code: + | 'INVALID_PAYLOAD' + | 'QUOTE_NOT_APPLICABLE' + | 'QUOTE_VERSION_CONFLICT' + | 'QUOTE_CURRENCY_MISMATCH' + | 'QUOTE_EXPIRED' + | 'QUOTE_NOT_OFFERED' + | 'QUOTE_ALREADY_ACCEPTED' + | 'QUOTE_STOCK_UNAVAILABLE' + | 'QUOTE_NOT_ACCEPTED' + | 'QUOTE_PAYMENT_WINDOW_EXPIRED' + | 'QUOTE_INVENTORY_NOT_RESERVED' + | 'PAYMENT_PROVIDER_NOT_ALLOWED_FOR_INTL' + | 'QUOTE_INVALID_EXPIRY', + message: string, + details?: Record +): InvalidPayloadError { + return new InvalidPayloadError(message, { code, details }); +} + +function canonicalFlag(): boolean { + return isCanonicalEventsDualWriteEnabled(); +} + +function makeQuoteEventDedupeKey(seed: Record): string { + return buildShippingEventDedupeKey({ + domain: 'intl_quote', + ...seed, + }); +} + +async function loadOrderQuote(orderId: string): Promise { + const [row] = await db + .select({ + id: orders.id, + currency: orders.currency, + paymentProvider: orders.paymentProvider, + paymentStatus: orders.paymentStatus, + fulfillmentMode: orders.fulfillmentMode, + quoteStatus: orders.quoteStatus, + quoteVersion: orders.quoteVersion, + shippingQuoteMinor: orders.shippingQuoteMinor, + itemsSubtotalMinor: orders.itemsSubtotalMinor, + totalAmountMinor: orders.totalAmountMinor, + inventoryStatus: orders.inventoryStatus, + quotePaymentDeadlineAt: orders.quotePaymentDeadlineAt, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + if (!row) throw new OrderNotFoundError('Order not found.'); + + return { + id: String(row.id), + currency: row.currency as 'USD' | 'UAH', + paymentProvider: String(row.paymentProvider), + paymentStatus: String(row.paymentStatus), + fulfillmentMode: row.fulfillmentMode as FulfillmentMode, + quoteStatus: row.quoteStatus as QuoteStatus, + quoteVersion: + typeof row.quoteVersion === 'number' ? row.quoteVersion : null, + shippingQuoteMinor: + typeof row.shippingQuoteMinor === 'number' ? row.shippingQuoteMinor : null, + itemsSubtotalMinor: + typeof row.itemsSubtotalMinor === 'number' ? row.itemsSubtotalMinor : 0, + totalAmountMinor: + typeof row.totalAmountMinor === 'number' ? row.totalAmountMinor : 0, + inventoryStatus: String(row.inventoryStatus), + quotePaymentDeadlineAt: parseDateOrNull(row.quotePaymentDeadlineAt), + }; +} + +function assertIntlOrder(order: OrderQuoteRow): void { + if (order.fulfillmentMode !== 'intl') { + throw quoteError( + 'QUOTE_NOT_APPLICABLE', + 'Quote workflow is available only for international orders.' + ); + } +} + +async function loadQuoteByVersion( + orderId: string, + version: number +): Promise { + const [row] = await db + .select({ + id: shippingQuotes.id, + orderId: shippingQuotes.orderId, + version: shippingQuotes.version, + status: shippingQuotes.status, + currency: shippingQuotes.currency, + shippingQuoteMinor: shippingQuotes.shippingQuoteMinor, + expiresAt: shippingQuotes.expiresAt, + }) + .from(shippingQuotes) + .where( + and( + eq(shippingQuotes.orderId, orderId), + eq(shippingQuotes.version, version) + ) + ) + .limit(1); + + if (!row) return null; + + const expiresAt = parseDateOrNull(row.expiresAt); + if (!expiresAt) { + throw quoteError( + 'INVALID_PAYLOAD', + 'Stored quote expiry is invalid.', + { orderId, version } + ); + } + + return { + id: String(row.id), + orderId: String(row.orderId), + version: Number(row.version), + status: row.status as QuoteStatus, + currency: row.currency as 'USD' | 'UAH', + shippingQuoteMinor: Number(row.shippingQuoteMinor), + expiresAt, + }; +} + +async function loadLatestQuote( + orderId: string +): Promise | null> { + const [row] = await db + .select({ + version: shippingQuotes.version, + status: shippingQuotes.status, + }) + .from(shippingQuotes) + .where(eq(shippingQuotes.orderId, orderId)) + .orderBy(desc(shippingQuotes.version)) + .limit(1); + + if (!row) return null; + return { + version: Number(row.version), + status: row.status as QuoteStatus, + }; +} + +async function atomicExpireQuote(args: { + orderId: string; + version: number; + now: Date; + eventSource: string; + eventRef: string; + reason: string; +}): Promise { + const dedupeKey = makeQuoteEventDedupeKey({ + action: 'quote_expired', + orderId: args.orderId, + version: args.version, + eventRef: args.eventRef, + reason: args.reason, + }); + + const res = await db.execute(sql` + with updated_quote as ( + update shipping_quotes q + set status = 'expired', + updated_at = ${args.now} + where q.order_id = ${args.orderId}::uuid + and q.version = ${args.version} + and q.status = 'offered' + returning q.order_id, q.version + ), + updated_order as ( + update orders o + set quote_status = 'expired', + quote_payment_deadline_at = null, + quote_accepted_at = null, + updated_at = ${args.now} + where o.id in (select order_id from updated_quote) + and o.fulfillment_mode = 'intl' + and o.quote_version = ${args.version} + and ${orderQuoteTransitionWhereSql({ + column: sql`o.quote_status`, + to: 'expired', + })} + returning o.id + ), + inserted_event as ( + insert into shipping_events ( + order_id, + shipment_id, + provider, + event_name, + event_source, + event_ref, + status_from, + status_to, + tracking_number, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + null, + 'intl_quote', + 'quote_expired', + ${args.eventSource}, + ${args.eventRef}, + 'offered', + 'expired', + null, + ${JSON.stringify({ + version: args.version, + reason: args.reason, + })}::jsonb, + ${dedupeKey}, + ${args.now}, + ${args.now} + from updated_order uo + where ${canonicalFlag()} = true + on conflict (dedupe_key) do nothing + returning id + ) + select + (select count(*)::int from updated_quote) as updated_quote_count + `); + + const row = (res as any)?.rows?.[0]; + return Number(row?.updated_quote_count ?? 0) > 0; +} + +export async function requestIntlQuote(args: { + orderId: string; + requestId: string; + actorUserId: string | null; +}) { + const now = new Date(); + const order = await loadOrderQuote(args.orderId); + assertIntlOrder(order); + + if (order.quoteStatus === 'accepted') { + throw quoteError( + 'QUOTE_ALREADY_ACCEPTED', + 'Quote is already accepted and awaiting payment.' + ); + } + + if (order.quoteStatus === 'offered') { + throw quoteError( + 'QUOTE_NOT_OFFERED', + 'Quote is already offered. Accept or decline the current quote first.' + ); + } + + if (order.quoteStatus === 'requested') { + return { + orderId: order.id, + quoteStatus: order.quoteStatus, + changed: false, + }; + } + + if (!isOrderQuoteStatusTransitionAllowed(order.quoteStatus, 'requested')) { + throw quoteError( + 'QUOTE_NOT_APPLICABLE', + `Invalid quote transition from ${order.quoteStatus} to requested.`, + { statusFrom: order.quoteStatus, statusTo: 'requested' } + ); + } + + const dedupeKey = makeQuoteEventDedupeKey({ + action: 'quote_requested', + orderId: order.id, + eventRef: args.requestId, + from: order.quoteStatus, + to: 'requested', + }); + + const res = await db.execute(sql` + with updated_order as ( + update orders o + set quote_status = 'requested', + quote_accepted_at = null, + quote_payment_deadline_at = null, + updated_at = ${now} + where o.id = ${order.id}::uuid + and o.fulfillment_mode = 'intl' + and ${orderQuoteTransitionWhereSql({ + column: sql`o.quote_status`, + to: 'requested', + })} + returning o.id + ), + inserted_event as ( + insert into shipping_events ( + order_id, + shipment_id, + provider, + event_name, + event_source, + event_ref, + status_from, + status_to, + tracking_number, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + null, + 'intl_quote', + 'quote_requested', + 'quote_request_route', + ${args.requestId}, + ${order.quoteStatus}, + 'requested', + null, + ${JSON.stringify({ + actorUserId: args.actorUserId, + })}::jsonb, + ${dedupeKey}, + ${now}, + ${now} + from updated_order uo + where ${canonicalFlag()} = true + on conflict (dedupe_key) do nothing + returning id + ) + select (select count(*)::int from updated_order) as updated_order_count + `); + + const updated = Number((res as any)?.rows?.[0]?.updated_order_count ?? 0) > 0; + return { + orderId: order.id, + quoteStatus: updated ? ('requested' as const) : order.quoteStatus, + changed: updated, + }; +} + +export async function offerIntlQuote(args: { + orderId: string; + requestId: string; + actorUserId: string | null; + version: number; + currency: 'USD' | 'UAH'; + shippingQuoteMinor: number; + expiresAt?: Date | null; + payload?: Record; +}) { + const now = new Date(); + const order = await loadOrderQuote(args.orderId); + assertIntlOrder(order); + + if (order.quoteStatus === 'accepted') { + throw quoteError( + 'QUOTE_ALREADY_ACCEPTED', + 'Accepted quote cannot be replaced.' + ); + } + + if (order.quoteStatus === 'offered') { + throw quoteError( + 'QUOTE_VERSION_CONFLICT', + 'Current quote must be accepted, declined, or expired before offering a new version.' + ); + } + + if (!isOrderQuoteStatusTransitionAllowed(order.quoteStatus, 'offered')) { + throw quoteError( + 'QUOTE_NOT_APPLICABLE', + `Invalid quote transition from ${order.quoteStatus} to offered.`, + { statusFrom: order.quoteStatus, statusTo: 'offered' } + ); + } + + if (args.currency !== order.currency) { + throw quoteError( + 'QUOTE_CURRENCY_MISMATCH', + 'Quote currency must match order currency.' + ); + } + + const latestQuote = await loadLatestQuote(order.id); + const expectedVersion = (latestQuote?.version ?? 0) + 1; + if (args.version !== expectedVersion) { + throw quoteError( + 'QUOTE_VERSION_CONFLICT', + 'Quote version conflict.', + { expectedVersion, gotVersion: args.version } + ); + } + + if (!Number.isInteger(args.shippingQuoteMinor) || args.shippingQuoteMinor < 0) { + throw quoteError( + 'INVALID_PAYLOAD', + 'shippingQuoteMinor must be a non-negative integer.' + ); + } + + const expiresAt = + args.expiresAt ?? + new Date(now.getTime() + getIntlQuoteOfferTtlMinutes() * 60 * 1000); + if (expiresAt.getTime() <= now.getTime()) { + throw quoteError( + 'QUOTE_INVALID_EXPIRY', + 'Quote expiry must be in the future.' + ); + } + + const dedupeKey = makeQuoteEventDedupeKey({ + action: 'quote_offered', + orderId: order.id, + version: args.version, + eventRef: args.requestId, + }); + + const res = await db.execute(sql` + with inserted_quote as ( + insert into shipping_quotes ( + order_id, + version, + status, + currency, + shipping_quote_minor, + offered_by, + offered_at, + expires_at, + payload, + created_at, + updated_at + ) + values ( + ${order.id}::uuid, + ${args.version}, + 'offered', + ${args.currency}, + ${args.shippingQuoteMinor}, + ${args.actorUserId}, + ${now}, + ${expiresAt}, + ${JSON.stringify(args.payload ?? {})}::jsonb, + ${now}, + ${now} + ) + on conflict (order_id, version) do nothing + returning order_id, version, shipping_quote_minor + ), + updated_order as ( + update orders o + set quote_status = 'offered', + quote_version = iq.version, + shipping_quote_minor = iq.shipping_quote_minor, + quote_accepted_at = null, + quote_payment_deadline_at = null, + items_subtotal_minor = case + when o.items_subtotal_minor > 0 then o.items_subtotal_minor + else o.total_amount_minor + end, + total_amount_minor = ( + case + when o.items_subtotal_minor > 0 then o.items_subtotal_minor + else o.total_amount_minor + end + ) + iq.shipping_quote_minor, + total_amount = (( + ( + case + when o.items_subtotal_minor > 0 then o.items_subtotal_minor + else o.total_amount_minor + end + ) + iq.shipping_quote_minor + )::numeric / 100), + updated_at = ${now} + from inserted_quote iq + where o.id = iq.order_id + returning o.id + ), + inserted_event as ( + insert into shipping_events ( + order_id, + shipment_id, + provider, + event_name, + event_source, + event_ref, + status_from, + status_to, + tracking_number, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + null, + 'intl_quote', + 'quote_offered', + 'admin_quote_offer_route', + ${args.requestId}, + ${order.quoteStatus}, + 'offered', + null, + ${JSON.stringify({ + version: args.version, + shippingQuoteMinor: args.shippingQuoteMinor, + expiresAt: expiresAt.toISOString(), + actorUserId: args.actorUserId, + })}::jsonb, + ${dedupeKey}, + ${now}, + ${now} + from updated_order uo + where ${canonicalFlag()} = true + on conflict (dedupe_key) do nothing + returning id + ) + select + (select count(*)::int from inserted_quote) as inserted_quote_count, + (select count(*)::int from updated_order) as updated_order_count + `); + + const row = (res as any)?.rows?.[0]; + const insertedCount = Number(row?.inserted_quote_count ?? 0); + if (insertedCount === 0) { + throw quoteError('QUOTE_VERSION_CONFLICT', 'Quote version already exists.', { + version: args.version, + }); + } + + return { + orderId: order.id, + version: args.version, + quoteStatus: 'offered' as const, + shippingQuoteMinor: args.shippingQuoteMinor, + currency: args.currency, + expiresAt, + }; +} + +export async function acceptIntlQuote(args: { + orderId: string; + requestId: string; + actorUserId: string | null; + version: number; +}) { + const now = new Date(); + const order = await loadOrderQuote(args.orderId); + assertIntlOrder(order); + + const quote = await loadQuoteByVersion(order.id, args.version); + if (!quote) { + throw quoteError('QUOTE_VERSION_CONFLICT', 'Quote version not found.'); + } + + const latestQuote = await loadLatestQuote(order.id); + if (latestQuote && latestQuote.version !== args.version) { + throw quoteError('QUOTE_VERSION_CONFLICT', 'Quote version conflict.', { + currentVersion: latestQuote.version, + requestedVersion: args.version, + }); + } + + if (quote.currency !== order.currency) { + throw quoteError( + 'QUOTE_CURRENCY_MISMATCH', + 'Quote currency must match order currency.' + ); + } + + if (quote.status === 'accepted') { + return { + orderId: order.id, + version: quote.version, + quoteStatus: quote.status, + changed: false, + paymentDeadlineAt: order.quotePaymentDeadlineAt, + }; + } + + if (quote.status !== 'offered') { + if (quote.status === 'expired') { + throw quoteError('QUOTE_EXPIRED', 'Quote has expired.'); + } + throw quoteError('QUOTE_NOT_OFFERED', 'Quote is not in offered state.'); + } + + if (!isOrderQuoteStatusTransitionAllowed(quote.status, 'accepted')) { + throw quoteError( + 'QUOTE_NOT_OFFERED', + `Invalid quote transition from ${quote.status} to accepted.`, + { statusFrom: quote.status, statusTo: 'accepted' } + ); + } + + if (quote.expiresAt.getTime() <= now.getTime()) { + await atomicExpireQuote({ + orderId: order.id, + version: quote.version, + now, + eventSource: 'quote_accept_route', + eventRef: args.requestId, + reason: 'accept_after_expiry', + }); + throw quoteError('QUOTE_EXPIRED', 'Quote has expired.'); + } + + const orderLineItems = await db + .select({ + productId: orderItems.productId, + quantity: orderItems.quantity, + }) + .from(orderItems) + .where(eq(orderItems.orderId, order.id)); + + if (orderLineItems.length === 0) { + throw quoteError('INVALID_PAYLOAD', 'Order has no line items.'); + } + + const reserves = aggregateReserveByProductId(orderLineItems); + const reservedApplied: Array<{ productId: string; quantity: number }> = []; + let stockFailureProductId: string | null = null; + const releaseReservedApplied = async () => { + for (const reserved of reservedApplied) { + await applyReleaseMove(order.id, reserved.productId, reserved.quantity); + } + }; + + for (const move of reserves) { + const reserve = await applyReserveMove(order.id, move.productId, move.quantity); + if (!reserve.ok) { + stockFailureProductId = move.productId; + break; + } + if (reserve.applied) { + reservedApplied.push(move); + } + } + + if (stockFailureProductId) { + await releaseReservedApplied(); + + const dedupeKey = makeQuoteEventDedupeKey({ + action: 'quote_requires_requote', + orderId: order.id, + version: quote.version, + eventRef: args.requestId, + reason: 'stock_unavailable', + }); + + await db.execute(sql` + with updated_quote as ( + update shipping_quotes q + set status = 'requires_requote', + updated_at = ${now} + where q.order_id = ${order.id}::uuid + and q.version = ${quote.version} + and q.status = 'offered' + returning q.order_id, q.version + ), + updated_order as ( + update orders o + set quote_status = 'requires_requote', + quote_payment_deadline_at = null, + quote_accepted_at = null, + inventory_status = 'failed', + failure_code = 'QUOTE_STOCK_UNAVAILABLE', + failure_message = 'Stock became unavailable for quote acceptance.', + updated_at = ${now} + where o.id in (select order_id from updated_quote) + and o.fulfillment_mode = 'intl' + and ${orderQuoteTransitionWhereSql({ + column: sql`o.quote_status`, + to: 'requires_requote', + })} + returning o.id + ), + inserted_event as ( + insert into shipping_events ( + order_id, + shipment_id, + provider, + event_name, + event_source, + event_ref, + status_from, + status_to, + tracking_number, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + null, + 'intl_quote', + 'quote_requires_requote', + 'quote_accept_route', + ${args.requestId}, + 'offered', + 'requires_requote', + null, + ${JSON.stringify({ + version: quote.version, + actorUserId: args.actorUserId, + productId: stockFailureProductId, + reason: 'stock_unavailable', + })}::jsonb, + ${dedupeKey}, + ${now}, + ${now} + from updated_order uo + where ${canonicalFlag()} = true + on conflict (dedupe_key) do nothing + returning id + ) + select (select count(*)::int from updated_order) as updated_order_count + `); + + throw quoteError( + 'QUOTE_STOCK_UNAVAILABLE', + 'Quote cannot be accepted because stock is unavailable.', + { productId: stockFailureProductId } + ); + } + + const paymentDeadlineAt = new Date( + now.getTime() + getIntlAcceptedPaymentTtlMinutes() * 60 * 1000 + ); + const dedupeKey = makeQuoteEventDedupeKey({ + action: 'quote_accepted', + orderId: order.id, + version: quote.version, + eventRef: args.requestId, + }); + + let res: unknown; + try { + res = await db.execute(sql` + with updated_quote as ( + update shipping_quotes q + set status = 'accepted', + accepted_at = ${now}, + updated_at = ${now} + where q.order_id = ${order.id}::uuid + and q.version = ${quote.version} + and q.status = 'offered' + and q.expires_at > ${now} + returning q.order_id, q.version, q.shipping_quote_minor + ), + updated_order as ( + update orders o + set quote_status = 'accepted', + quote_version = uq.version, + shipping_quote_minor = uq.shipping_quote_minor, + quote_accepted_at = ${now}, + quote_payment_deadline_at = ${paymentDeadlineAt}, + inventory_status = 'reserved', + failure_code = null, + failure_message = null, + payment_provider = case + when o.payment_provider = 'none' then 'stripe' + else o.payment_provider + end, + items_subtotal_minor = case + when o.items_subtotal_minor > 0 then o.items_subtotal_minor + else o.total_amount_minor + end, + total_amount_minor = ( + case + when o.items_subtotal_minor > 0 then o.items_subtotal_minor + else o.total_amount_minor + end + ) + uq.shipping_quote_minor, + total_amount = (( + ( + case + when o.items_subtotal_minor > 0 then o.items_subtotal_minor + else o.total_amount_minor + end + ) + uq.shipping_quote_minor + )::numeric / 100), + updated_at = ${now} + from updated_quote uq + where o.id = uq.order_id + and o.fulfillment_mode = 'intl' + and ${orderQuoteTransitionWhereSql({ + column: sql`o.quote_status`, + to: 'accepted', + })} + returning o.id, o.total_amount_minor, o.currency, o.quote_payment_deadline_at + ), + inserted_event as ( + insert into shipping_events ( + order_id, + shipment_id, + provider, + event_name, + event_source, + event_ref, + status_from, + status_to, + tracking_number, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + null, + 'intl_quote', + 'quote_accepted', + 'quote_accept_route', + ${args.requestId}, + 'offered', + 'accepted', + null, + ${JSON.stringify({ + version: quote.version, + actorUserId: args.actorUserId, + paymentDeadlineAt: paymentDeadlineAt.toISOString(), + })}::jsonb, + ${dedupeKey}, + ${now}, + ${now} + from updated_order uo + where ${canonicalFlag()} = true + on conflict (dedupe_key) do nothing + returning id + ) + select + (select count(*)::int from updated_quote) as updated_quote_count, + (select total_amount_minor from updated_order limit 1) as total_amount_minor + `); + } catch (error) { + await releaseReservedApplied(); + throw error; + } + + const row = (res as any)?.rows?.[0]; + if (Number(row?.updated_quote_count ?? 0) === 0) { + await releaseReservedApplied(); + throw quoteError('QUOTE_VERSION_CONFLICT', 'Quote is no longer offerable.'); + } + + return { + orderId: order.id, + version: quote.version, + quoteStatus: 'accepted' as const, + changed: true, + paymentDeadlineAt, + totalAmountMinor: Number(row?.total_amount_minor ?? 0), + }; +} + +export async function declineIntlQuote(args: { + orderId: string; + requestId: string; + actorUserId: string | null; + version?: number | null; +}) { + const now = new Date(); + const order = await loadOrderQuote(args.orderId); + assertIntlOrder(order); + + if (order.quoteStatus === 'accepted') { + throw quoteError( + 'QUOTE_ALREADY_ACCEPTED', + 'Accepted quote cannot be declined.' + ); + } + + const latestQuote = await loadLatestQuote(order.id); + if (!latestQuote) { + throw quoteError('QUOTE_NOT_OFFERED', 'No offered quote to decline.'); + } + + const version = args.version ?? latestQuote.version; + if (version !== latestQuote.version) { + throw quoteError('QUOTE_VERSION_CONFLICT', 'Quote version conflict.', { + currentVersion: latestQuote.version, + requestedVersion: version, + }); + } + + if (latestQuote.status === 'declined') { + return { orderId: order.id, quoteStatus: 'declined' as const, changed: false }; + } + + if (latestQuote.status !== 'offered') { + if (latestQuote.status === 'expired') { + throw quoteError('QUOTE_EXPIRED', 'Quote has expired.'); + } + throw quoteError('QUOTE_NOT_OFFERED', 'No offered quote to decline.'); + } + + if (!isOrderQuoteStatusTransitionAllowed(latestQuote.status, 'declined')) { + throw quoteError( + 'QUOTE_NOT_OFFERED', + `Invalid quote transition from ${latestQuote.status} to declined.`, + { statusFrom: latestQuote.status, statusTo: 'declined' } + ); + } + + const dedupeKey = makeQuoteEventDedupeKey({ + action: 'quote_declined', + orderId: order.id, + version, + eventRef: args.requestId, + }); + + const res = await db.execute(sql` + with updated_quote as ( + update shipping_quotes q + set status = 'declined', + declined_at = ${now}, + updated_at = ${now} + where q.order_id = ${order.id}::uuid + and q.version = ${version} + and q.status = 'offered' + returning q.order_id + ), + updated_order as ( + update orders o + set quote_status = 'declined', + quote_accepted_at = null, + quote_payment_deadline_at = null, + updated_at = ${now} + where o.id in (select order_id from updated_quote) + and o.fulfillment_mode = 'intl' + and o.quote_version = ${version} + and ${orderQuoteTransitionWhereSql({ + column: sql`o.quote_status`, + to: 'declined', + })} + returning o.id + ), + inserted_event as ( + insert into shipping_events ( + order_id, + shipment_id, + provider, + event_name, + event_source, + event_ref, + status_from, + status_to, + tracking_number, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + null, + 'intl_quote', + 'quote_declined', + 'quote_decline_route', + ${args.requestId}, + 'offered', + 'declined', + null, + ${JSON.stringify({ + version, + actorUserId: args.actorUserId, + })}::jsonb, + ${dedupeKey}, + ${now}, + ${now} + from updated_order uo + where ${canonicalFlag()} = true + on conflict (dedupe_key) do nothing + returning id + ) + select (select count(*)::int from updated_quote) as updated_quote_count + `); + + if (Number((res as any)?.rows?.[0]?.updated_quote_count ?? 0) === 0) { + throw quoteError('QUOTE_NOT_OFFERED', 'Quote is not in offered state.'); + } + + return { + orderId: order.id, + quoteStatus: 'declined' as const, + changed: true, + version, + }; +} + +export async function assertIntlPaymentInitAllowed(args: { + orderId: string; + provider: 'stripe' | 'monobank'; + now?: Date; +}) { + const now = args.now ?? new Date(); + const order = await loadOrderQuote(args.orderId); + + if (order.fulfillmentMode !== 'intl') { + return { order }; + } + + if (args.provider !== 'stripe') { + throw quoteError( + 'PAYMENT_PROVIDER_NOT_ALLOWED_FOR_INTL', + 'Only Stripe is allowed for international quote payments.' + ); + } + + if (order.quoteStatus !== 'accepted') { + throw quoteError( + 'QUOTE_NOT_ACCEPTED', + 'Quote must be accepted before payment initialization.' + ); + } + + if ( + !order.quotePaymentDeadlineAt || + order.quotePaymentDeadlineAt.getTime() <= now.getTime() + ) { + throw quoteError( + 'QUOTE_PAYMENT_WINDOW_EXPIRED', + 'Quote payment deadline has expired.' + ); + } + + if (order.inventoryStatus !== 'reserved') { + throw quoteError( + 'QUOTE_INVENTORY_NOT_RESERVED', + 'Inventory must be reserved before payment initialization.' + ); + } + + if (!order.quoteVersion) { + throw quoteError( + 'QUOTE_VERSION_CONFLICT', + 'Order quote version is missing for accepted quote.' + ); + } + + const quote = await loadQuoteByVersion(order.id, order.quoteVersion); + if (!quote || quote.status !== 'accepted') { + throw quoteError( + 'QUOTE_NOT_ACCEPTED', + 'Latest quote must be accepted before payment initialization.' + ); + } + + if (quote.currency !== order.currency) { + throw quoteError( + 'QUOTE_CURRENCY_MISMATCH', + 'Quote currency must match order currency.' + ); + } + + return { order }; +} + +export async function sweepExpiredOfferedIntlQuotes(options?: { + batchSize?: number; + now?: Date; +}): Promise { + const batchSize = Math.max(1, Math.min(100, Math.floor(options?.batchSize ?? 50))); + const now = options?.now ?? new Date(); + + const candidates = await db + .select({ + orderId: shippingQuotes.orderId, + version: shippingQuotes.version, + }) + .from(shippingQuotes) + .where( + and( + eq(shippingQuotes.status, 'offered'), + lte(shippingQuotes.expiresAt, now) + ) + ) + .orderBy(asc(shippingQuotes.expiresAt)) + .limit(batchSize); + + let processed = 0; + for (const candidate of candidates) { + const ok = await atomicExpireQuote({ + orderId: candidate.orderId, + version: candidate.version, + now, + eventSource: 'intl_quote_sweep', + eventRef: `sweep-expire:${candidate.orderId}:${candidate.version}:${now.toISOString()}`, + reason: 'offer_expired', + }); + if (ok) processed += 1; + } + + return processed; +} + +export async function sweepAcceptedIntlQuotePaymentTimeouts(options?: { + batchSize?: number; + now?: Date; +}): Promise { + const batchSize = Math.max(1, Math.min(100, Math.floor(options?.batchSize ?? 50))); + const now = options?.now ?? new Date(); + + const candidates = await db + .select({ + orderId: orders.id, + quoteVersion: orders.quoteVersion, + inventoryStatus: orders.inventoryStatus, + }) + .from(orders) + .where( + and( + eq(orders.fulfillmentMode, 'intl'), + eq(orders.quoteStatus, 'accepted'), + lte(orders.quotePaymentDeadlineAt, now) + ) + ) + .orderBy(asc(orders.quotePaymentDeadlineAt)) + .limit(batchSize); + + let processed = 0; + + for (const candidate of candidates) { + if (!candidate.quoteVersion) continue; + + await restockOrder(candidate.orderId, { + reason: 'stale', + workerId: 'intl_quote_timeout_sweep', + }); + + const [postRestock] = await db + .select({ + inventoryStatus: orders.inventoryStatus, + }) + .from(orders) + .where(eq(orders.id, candidate.orderId)) + .limit(1); + + if (!postRestock || postRestock.inventoryStatus !== 'released') { + continue; + } + + const dedupeKey = makeQuoteEventDedupeKey({ + action: 'quote_timeout_requires_requote', + orderId: candidate.orderId, + version: candidate.quoteVersion, + }); + + const updateRes = await db.execute(sql` + with updated_quote as ( + update shipping_quotes q + set status = 'requires_requote', + updated_at = ${now} + where q.order_id = ${candidate.orderId}::uuid + and q.version = ${candidate.quoteVersion} + and q.status = 'accepted' + returning q.order_id + ), + updated_order as ( + update orders o + set quote_status = 'requires_requote', + quote_payment_deadline_at = null, + quote_accepted_at = null, + updated_at = ${now} + where o.id in (select order_id from updated_quote) + and o.fulfillment_mode = 'intl' + and ${orderQuoteTransitionWhereSql({ + column: sql`o.quote_status`, + to: 'requires_requote', + })} + returning o.id + ), + inserted_event as ( + insert into shipping_events ( + order_id, + shipment_id, + provider, + event_name, + event_source, + event_ref, + status_from, + status_to, + tracking_number, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + null, + 'intl_quote', + 'quote_timeout_requires_requote', + 'intl_quote_sweep', + ${`sweep-timeout:${candidate.orderId}:${candidate.quoteVersion}`}, + 'accepted', + 'requires_requote', + null, + ${JSON.stringify({ + version: candidate.quoteVersion, + })}::jsonb, + ${dedupeKey}, + ${now}, + ${now} + from updated_order uo + where ${canonicalFlag()} = true + on conflict (dedupe_key) do nothing + returning id + ) + select (select count(*)::int from updated_order) as updated_order_count + `); + + if (Number((updateRes as any)?.rows?.[0]?.updated_order_count ?? 0) > 0) { + processed += 1; + } + } + + return processed; +} diff --git a/frontend/lib/services/shop/returns.ts b/frontend/lib/services/shop/returns.ts new file mode 100644 index 00000000..2fea1b74 --- /dev/null +++ b/frontend/lib/services/shop/returns.ts @@ -0,0 +1,1048 @@ +import 'server-only'; + +import { and, asc, eq, inArray, sql } from 'drizzle-orm'; + +import { db } from '@/db'; +import { + orderItems, + orders, + returnItems, + returnRequests, +} from '@/db/schema'; +import { createRefund } from '@/lib/psp/stripe'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { buildAdminAuditDedupeKey } from '@/lib/services/shop/events/dedupe-key'; +import { buildShippingEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; +import { + isReturnStatusTransitionAllowed, + type ReturnStatus, +} from '@/lib/services/shop/transitions/return-state'; + +type ReturnRequestRow = { + id: string; + orderId: string; + userId: string | null; + status: ReturnStatus; + reason: string | null; + policyRestock: boolean; + refundAmountMinor: number; + currency: 'USD' | 'UAH'; + idempotencyKey: string; + approvedAt: Date | null; + approvedBy: string | null; + rejectedAt: Date | null; + rejectedBy: string | null; + receivedAt: Date | null; + receivedBy: string | null; + refundedAt: Date | null; + refundedBy: string | null; + refundProviderRef: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type ReturnItemRow = { + id: string; + returnRequestId: string; + orderId: string; + orderItemId: string | null; + productId: string | null; + quantity: number; + unitPriceMinor: number; + lineTotalMinor: number; + currency: 'USD' | 'UAH'; + idempotencyKey: string; + createdAt: Date; +}; + +type OrderForReturnRow = { + id: string; + userId: string | null; + paymentProvider: string; + paymentStatus: string; + paymentIntentId: string | null; + pspChargeId: string | null; + currency: 'USD' | 'UAH'; + totalAmountMinor: number; +}; + +export type ReturnRequestWithItems = ReturnRequestRow & { + items: ReturnItemRow[]; +}; + +function readRows(res: unknown): T[] { + if (Array.isArray(res)) return res as T[]; + const maybe = res as { rows?: unknown }; + if (Array.isArray(maybe.rows)) return maybe.rows as T[]; + return []; +} + +function returnError( + code: + | 'RETURN_NOT_FOUND' + | 'RETURN_ALREADY_EXISTS' + | 'RETURN_ORDER_ITEMS_EMPTY' + | 'RETURN_TRANSITION_INVALID' + | 'RETURN_REFUND_STATE_INVALID' + | 'RETURN_REFUND_PROVIDER_UNSUPPORTED' + | 'RETURN_REFUND_PAYMENT_STATUS_INVALID' + | 'RETURN_REFUND_MISSING_PSP_TARGET' + | 'RETURN_REFUND_AMOUNT_INVALID' + | 'RETURN_RESTOCK_NO_RESERVE' + | 'RETURN_ITEMS_MISSING', + message: string, + details?: Record +): InvalidPayloadError { + return new InvalidPayloadError(message, { code, details }); +} + +function buildReturnShippingDedupe(args: { + returnRequestId: string; + orderId: string; + action: string; + requestId: string; + statusFrom: string | null; + statusTo: string; +}) { + return buildShippingEventDedupeKey({ + domain: 'returns', + returnRequestId: args.returnRequestId, + orderId: args.orderId, + action: args.action, + requestId: args.requestId, + statusFrom: args.statusFrom, + statusTo: args.statusTo, + }); +} + +function buildReturnAdminAuditDedupe(args: { + returnRequestId: string; + orderId: string; + action: string; + requestId: string; + actorUserId: string | null; + statusFrom: string | null; + statusTo: string; +}) { + return buildAdminAuditDedupeKey({ + domain: 'returns', + returnRequestId: args.returnRequestId, + orderId: args.orderId, + action: args.action, + requestId: args.requestId, + actorUserId: args.actorUserId, + statusFrom: args.statusFrom, + statusTo: args.statusTo, + }); +} + +async function loadOrder(orderId: string): Promise { + const [row] = await db + .select({ + id: orders.id, + userId: orders.userId, + paymentProvider: orders.paymentProvider, + paymentStatus: orders.paymentStatus, + paymentIntentId: orders.paymentIntentId, + pspChargeId: orders.pspChargeId, + currency: orders.currency, + totalAmountMinor: orders.totalAmountMinor, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + if (!row) return null; + return { + id: row.id, + userId: row.userId ?? null, + paymentProvider: String(row.paymentProvider), + paymentStatus: String(row.paymentStatus), + paymentIntentId: row.paymentIntentId ?? null, + pspChargeId: row.pspChargeId ?? null, + currency: row.currency as 'USD' | 'UAH', + totalAmountMinor: Number(row.totalAmountMinor ?? 0), + }; +} + +async function loadReturnById( + returnRequestId: string +): Promise { + const [row] = await db + .select({ + id: returnRequests.id, + orderId: returnRequests.orderId, + userId: returnRequests.userId, + status: returnRequests.status, + reason: returnRequests.reason, + policyRestock: returnRequests.policyRestock, + refundAmountMinor: returnRequests.refundAmountMinor, + currency: returnRequests.currency, + idempotencyKey: returnRequests.idempotencyKey, + approvedAt: returnRequests.approvedAt, + approvedBy: returnRequests.approvedBy, + rejectedAt: returnRequests.rejectedAt, + rejectedBy: returnRequests.rejectedBy, + receivedAt: returnRequests.receivedAt, + receivedBy: returnRequests.receivedBy, + refundedAt: returnRequests.refundedAt, + refundedBy: returnRequests.refundedBy, + refundProviderRef: returnRequests.refundProviderRef, + createdAt: returnRequests.createdAt, + updatedAt: returnRequests.updatedAt, + }) + .from(returnRequests) + .where(eq(returnRequests.id, returnRequestId)) + .limit(1); + + if (!row) return null; + return { + id: row.id, + orderId: row.orderId, + userId: row.userId ?? null, + status: row.status as ReturnStatus, + reason: row.reason ?? null, + policyRestock: !!row.policyRestock, + refundAmountMinor: Number(row.refundAmountMinor ?? 0), + currency: row.currency as 'USD' | 'UAH', + idempotencyKey: row.idempotencyKey, + approvedAt: row.approvedAt ?? null, + approvedBy: row.approvedBy ?? null, + rejectedAt: row.rejectedAt ?? null, + rejectedBy: row.rejectedBy ?? null, + receivedAt: row.receivedAt ?? null, + receivedBy: row.receivedBy ?? null, + refundedAt: row.refundedAt ?? null, + refundedBy: row.refundedBy ?? null, + refundProviderRef: row.refundProviderRef ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +async function loadReturnItemsByRequestId( + returnRequestId: string +): Promise { + const rows = await db + .select({ + id: returnItems.id, + returnRequestId: returnItems.returnRequestId, + orderId: returnItems.orderId, + orderItemId: returnItems.orderItemId, + productId: returnItems.productId, + quantity: returnItems.quantity, + unitPriceMinor: returnItems.unitPriceMinor, + lineTotalMinor: returnItems.lineTotalMinor, + currency: returnItems.currency, + idempotencyKey: returnItems.idempotencyKey, + createdAt: returnItems.createdAt, + }) + .from(returnItems) + .where(eq(returnItems.returnRequestId, returnRequestId)) + .orderBy(asc(returnItems.createdAt), asc(returnItems.id)); + + return rows.map(row => ({ + id: row.id, + returnRequestId: row.returnRequestId, + orderId: row.orderId, + orderItemId: row.orderItemId ?? null, + productId: row.productId ?? null, + quantity: Number(row.quantity), + unitPriceMinor: Number(row.unitPriceMinor), + lineTotalMinor: Number(row.lineTotalMinor), + currency: row.currency as 'USD' | 'UAH', + idempotencyKey: row.idempotencyKey, + createdAt: row.createdAt, + })); +} + +async function loadReturnByIdWithItems( + returnRequestId: string +): Promise { + const request = await loadReturnById(returnRequestId); + if (!request) return null; + const items = await loadReturnItemsByRequestId(returnRequestId); + return { ...request, items }; +} + +async function applyReturnRestockMove(args: { + returnRequestId: string; + orderId: string; + productId: string; + quantity: number; +}): Promise<'applied' | 'already' | 'already_released' | 'no_reserve' | 'noop'> { + const moveKey = `return_release:${args.returnRequestId}:${args.productId}`; + const res = await db.execute<{ status: string }>(sql` + with c as ( + select + ${args.orderId}::uuid as order_id, + ${args.productId}::uuid as product_id, + ${args.quantity}::int as qty, + ${moveKey}::varchar as move_key + ), + has_reserve as ( + select 1 + from inventory_moves m, c + where m.order_id = c.order_id + and m.product_id = c.product_id + and m.type = 'reserve' + limit 1 + ), + already_released as ( + select 1 + from inventory_moves m, c + where m.order_id = c.order_id + and m.product_id = c.product_id + and m.type = 'release' + limit 1 + ), + claimed as ( + insert into inventory_moves (move_key, order_id, product_id, type, quantity) + select c.move_key, c.order_id, c.product_id, 'release', c.qty + from c + where exists (select 1 from has_reserve) + and not exists (select 1 from already_released) + on conflict (move_key) do nothing + returning 1 + ), + upd as ( + update products p + set stock = p.stock + c.qty, + updated_at = now() + from c + where p.id = c.product_id + and exists (select 1 from claimed) + returning 1 + ) + select case + when exists (select 1 from upd) then 'applied' + when exists (select 1 from inventory_moves m where m.move_key = (select move_key from c)) then 'already' + when exists (select 1 from already_released) then 'already_released' + when not exists (select 1 from has_reserve) then 'no_reserve' + else 'noop' + end as status + `); + + const status = readRows<{ status: string }>(res)[0]?.status; + if ( + status === 'applied' || + status === 'already' || + status === 'already_released' || + status === 'no_reserve' || + status === 'noop' + ) { + return status; + } + + return 'noop'; +} + +async function restockReturnItems(returnRequestId: string, orderId: string) { + const grouped = await db.execute<{ product_id: string; quantity: number }>(sql` + select + ri.product_id::text as product_id, + sum(ri.quantity)::int as quantity + from return_items ri + where ri.return_request_id = ${returnRequestId}::uuid + and ri.order_id = ${orderId}::uuid + and ri.product_id is not null + group by ri.product_id + `); + const productsToRelease = readRows<{ product_id: string; quantity: number }>(grouped); + if (productsToRelease.length === 0) { + throw returnError( + 'RETURN_ITEMS_MISSING', + 'Return request has no restockable items.' + ); + } + + for (const item of productsToRelease) { + const status = await applyReturnRestockMove({ + returnRequestId, + orderId, + productId: item.product_id, + quantity: Number(item.quantity), + }); + if (status === 'no_reserve') { + throw returnError( + 'RETURN_RESTOCK_NO_RESERVE', + 'Cannot restock return without an existing reserve inventory move.', + { returnRequestId, orderId, productId: item.product_id } + ); + } + } +} + +export async function createReturnRequest(args: { + orderId: string; + actorUserId: string; + idempotencyKey: string; + reason?: string | null; + policyRestock?: boolean; + requestId: string; +}): Promise<{ created: boolean; request: ReturnRequestWithItems }> { + const order = await loadOrder(args.orderId); + if (!order) { + throw returnError('RETURN_NOT_FOUND', 'Order not found.'); + } + + const items = await db + .select({ + id: orderItems.id, + productId: orderItems.productId, + quantity: orderItems.quantity, + unitPriceMinor: orderItems.unitPriceMinor, + lineTotalMinor: orderItems.lineTotalMinor, + }) + .from(orderItems) + .where(eq(orderItems.orderId, args.orderId)); + + if (items.length === 0) { + throw returnError( + 'RETURN_ORDER_ITEMS_EMPTY', + 'Cannot create return request for an order with no items.' + ); + } + + const refundAmountMinor = items.reduce( + (sum, row) => sum + Number(row.lineTotalMinor ?? 0), + 0 + ); + const now = new Date(); + const eventDedupeKey = buildReturnShippingDedupe({ + returnRequestId: args.idempotencyKey, + orderId: args.orderId, + action: 'create', + requestId: args.requestId, + statusFrom: null, + statusTo: 'requested', + }); + const auditDedupeKey = buildReturnAdminAuditDedupe({ + returnRequestId: args.idempotencyKey, + orderId: args.orderId, + action: 'return.requested', + requestId: args.requestId, + actorUserId: args.actorUserId, + statusFrom: null, + statusTo: 'requested', + }); + + const insertRes = await db.execute<{ return_request_id: string }>(sql` + with inserted_request as ( + insert into return_requests ( + order_id, + user_id, + status, + reason, + policy_restock, + refund_amount_minor, + currency, + idempotency_key, + created_at, + updated_at + ) + values ( + ${args.orderId}::uuid, + ${args.actorUserId}, + 'requested', + ${args.reason ?? null}, + ${args.policyRestock ?? true}, + ${refundAmountMinor}, + ${order.currency}, + ${args.idempotencyKey}, + ${now}, + ${now} + ) + on conflict (idempotency_key) do nothing + returning id, order_id, refund_amount_minor, currency + ), + inserted_items as ( + insert into return_items ( + return_request_id, + order_id, + order_item_id, + product_id, + quantity, + unit_price_minor, + line_total_minor, + currency, + idempotency_key, + created_at + ) + select + ir.id, + oi.order_id, + oi.id, + oi.product_id, + oi.quantity, + oi.unit_price_minor, + oi.line_total_minor, + ir.currency, + ('return_item:' || ir.id::text || ':' || oi.id::text), + ${now} + from inserted_request ir + join order_items oi + on oi.order_id = ir.order_id + on conflict (idempotency_key) do nothing + returning id + ), + inserted_event as ( + insert into shipping_events ( + order_id, + shipment_id, + provider, + event_name, + event_source, + event_ref, + status_from, + status_to, + tracking_number, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + ir.order_id, + null, + 'returns', + 'return_requested', + 'returns_customer_route', + ${args.requestId}, + null, + 'requested', + null, + ${JSON.stringify({ + actorUserId: args.actorUserId, + idempotencyKey: args.idempotencyKey, + refundAmountMinor, + currency: order.currency, + })}::jsonb, + ${eventDedupeKey}, + ${now}, + ${now} + from inserted_request ir + on conflict (dedupe_key) do nothing + returning id + ), + inserted_audit as ( + insert into admin_audit_log ( + order_id, + actor_user_id, + action, + target_type, + target_id, + request_id, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + ir.order_id, + ${args.actorUserId}, + 'return.requested', + 'return_request', + ir.id::text, + ${args.requestId}, + ${JSON.stringify({ + actorUserId: args.actorUserId, + idempotencyKey: args.idempotencyKey, + refundAmountMinor, + currency: order.currency, + })}::jsonb, + ${auditDedupeKey}, + ${now}, + ${now} + from inserted_request ir + on conflict (dedupe_key) do nothing + returning id + ) + select ir.id::text as return_request_id + from inserted_request ir + `); + + const insertedReturnId = readRows<{ return_request_id: string }>(insertRes)[0] + ?.return_request_id; + + if (insertedReturnId) { + const created = await loadReturnByIdWithItems(insertedReturnId); + if (!created) { + throw returnError('RETURN_NOT_FOUND', 'Return request not found after create.'); + } + return { created: true, request: created }; + } + + const [existingByIdempotency] = await db + .select({ id: returnRequests.id }) + .from(returnRequests) + .where(eq(returnRequests.idempotencyKey, args.idempotencyKey)) + .limit(1); + + if (existingByIdempotency) { + const existing = await loadReturnByIdWithItems(existingByIdempotency.id); + if (!existing) { + throw returnError('RETURN_NOT_FOUND', 'Return request not found.'); + } + return { created: false, request: existing }; + } + + const [existingOrderReturn] = await db + .select({ id: returnRequests.id }) + .from(returnRequests) + .where(eq(returnRequests.orderId, args.orderId)) + .limit(1); + + if (existingOrderReturn) { + throw returnError( + 'RETURN_ALREADY_EXISTS', + 'A return request already exists for this order.', + { returnRequestId: existingOrderReturn.id } + ); + } + + throw returnError('RETURN_NOT_FOUND', 'Unable to create return request.'); +} + +export async function listOrderReturns(orderId: string): Promise { + const requests = await db + .select({ + id: returnRequests.id, + orderId: returnRequests.orderId, + userId: returnRequests.userId, + status: returnRequests.status, + reason: returnRequests.reason, + policyRestock: returnRequests.policyRestock, + refundAmountMinor: returnRequests.refundAmountMinor, + currency: returnRequests.currency, + idempotencyKey: returnRequests.idempotencyKey, + approvedAt: returnRequests.approvedAt, + approvedBy: returnRequests.approvedBy, + rejectedAt: returnRequests.rejectedAt, + rejectedBy: returnRequests.rejectedBy, + receivedAt: returnRequests.receivedAt, + receivedBy: returnRequests.receivedBy, + refundedAt: returnRequests.refundedAt, + refundedBy: returnRequests.refundedBy, + refundProviderRef: returnRequests.refundProviderRef, + createdAt: returnRequests.createdAt, + updatedAt: returnRequests.updatedAt, + }) + .from(returnRequests) + .where(eq(returnRequests.orderId, orderId)) + .orderBy(asc(returnRequests.createdAt), asc(returnRequests.id)); + + if (requests.length === 0) return []; + const returnRequestIds = requests.map(row => row.id); + const allItems = await db + .select({ + id: returnItems.id, + returnRequestId: returnItems.returnRequestId, + orderId: returnItems.orderId, + orderItemId: returnItems.orderItemId, + productId: returnItems.productId, + quantity: returnItems.quantity, + unitPriceMinor: returnItems.unitPriceMinor, + lineTotalMinor: returnItems.lineTotalMinor, + currency: returnItems.currency, + idempotencyKey: returnItems.idempotencyKey, + createdAt: returnItems.createdAt, + }) + .from(returnItems) + .where(inArray(returnItems.returnRequestId, returnRequestIds)) + .orderBy(asc(returnItems.createdAt), asc(returnItems.id)); + + const itemsByRequest = new Map(); + for (const row of allItems) { + const mapped: ReturnItemRow = { + id: row.id, + returnRequestId: row.returnRequestId, + orderId: row.orderId, + orderItemId: row.orderItemId ?? null, + productId: row.productId ?? null, + quantity: Number(row.quantity), + unitPriceMinor: Number(row.unitPriceMinor), + lineTotalMinor: Number(row.lineTotalMinor), + currency: row.currency as 'USD' | 'UAH', + idempotencyKey: row.idempotencyKey, + createdAt: row.createdAt, + }; + const list = itemsByRequest.get(mapped.returnRequestId); + if (list) list.push(mapped); + else itemsByRequest.set(mapped.returnRequestId, [mapped]); + } + + return requests.map(row => ({ + id: row.id, + orderId: row.orderId, + userId: row.userId ?? null, + status: row.status as ReturnStatus, + reason: row.reason ?? null, + policyRestock: !!row.policyRestock, + refundAmountMinor: Number(row.refundAmountMinor ?? 0), + currency: row.currency as 'USD' | 'UAH', + idempotencyKey: row.idempotencyKey, + approvedAt: row.approvedAt ?? null, + approvedBy: row.approvedBy ?? null, + rejectedAt: row.rejectedAt ?? null, + rejectedBy: row.rejectedBy ?? null, + receivedAt: row.receivedAt ?? null, + receivedBy: row.receivedBy ?? null, + refundedAt: row.refundedAt ?? null, + refundedBy: row.refundedBy ?? null, + refundProviderRef: row.refundProviderRef ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + items: itemsByRequest.get(row.id) ?? [], + })); +} + +async function applyTransition(args: { + returnRequestId: string; + actorUserId: string | null; + requestId: string; + expectedFrom: ReturnStatus; + statusTo: ReturnStatus; + action: string; + eventName: string; + setClause: ReturnType; + payload: Record; +}): Promise<{ changed: boolean; row: ReturnRequestRow }> { + const current = await loadReturnById(args.returnRequestId); + if (!current) { + throw returnError('RETURN_NOT_FOUND', 'Return request not found.'); + } + if (current.status === args.statusTo) { + return { changed: false, row: current }; + } + if (!isReturnStatusTransitionAllowed(current.status, args.statusTo)) { + throw returnError( + 'RETURN_TRANSITION_INVALID', + `Invalid return transition from ${current.status} to ${args.statusTo}.`, + { + returnRequestId: current.id, + statusFrom: current.status, + statusTo: args.statusTo, + } + ); + } + if (current.status !== args.expectedFrom) { + throw returnError( + 'RETURN_TRANSITION_INVALID', + `Invalid return transition from ${current.status} to ${args.statusTo}.`, + { + returnRequestId: current.id, + statusFrom: current.status, + statusTo: args.statusTo, + } + ); + } + + const now = new Date(); + const eventDedupeKey = buildReturnShippingDedupe({ + returnRequestId: current.id, + orderId: current.orderId, + action: args.action, + requestId: args.requestId, + statusFrom: current.status, + statusTo: args.statusTo, + }); + const auditDedupeKey = buildReturnAdminAuditDedupe({ + returnRequestId: current.id, + orderId: current.orderId, + action: args.action, + requestId: args.requestId, + actorUserId: args.actorUserId, + statusFrom: current.status, + statusTo: args.statusTo, + }); + + const result = await db.execute<{ id: string }>(sql` + with updated_request as ( + update return_requests r + set status = ${args.statusTo}, + ${args.setClause}, + updated_at = ${now} + where r.id = ${current.id}::uuid + and r.status = ${args.expectedFrom} + returning r.id, r.order_id + ), + inserted_event as ( + insert into shipping_events ( + order_id, + shipment_id, + provider, + event_name, + event_source, + event_ref, + status_from, + status_to, + tracking_number, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + ur.order_id, + null, + 'returns', + ${args.eventName}, + 'returns_admin_route', + ${args.requestId}, + ${args.expectedFrom}, + ${args.statusTo}, + null, + ${JSON.stringify(args.payload)}::jsonb, + ${eventDedupeKey}, + ${now}, + ${now} + from updated_request ur + on conflict (dedupe_key) do nothing + returning id + ), + inserted_audit as ( + insert into admin_audit_log ( + order_id, + actor_user_id, + action, + target_type, + target_id, + request_id, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + ur.order_id, + ${args.actorUserId}, + ${args.action}, + 'return_request', + ur.id::text, + ${args.requestId}, + ${JSON.stringify(args.payload)}::jsonb, + ${auditDedupeKey}, + ${now}, + ${now} + from updated_request ur + on conflict (dedupe_key) do nothing + returning id + ) + select ur.id::text as id + from updated_request ur + `); + + if (readRows<{ id: string }>(result).length === 0) { + const latest = await loadReturnById(args.returnRequestId); + if (!latest) { + throw returnError('RETURN_NOT_FOUND', 'Return request not found.'); + } + if (latest.status === args.statusTo) { + return { changed: false, row: latest }; + } + throw returnError( + 'RETURN_TRANSITION_INVALID', + `Invalid return transition from ${latest.status} to ${args.statusTo}.`, + { + returnRequestId: latest.id, + statusFrom: latest.status, + statusTo: args.statusTo, + } + ); + } + + const updated = await loadReturnById(args.returnRequestId); + if (!updated) { + throw returnError('RETURN_NOT_FOUND', 'Return request not found.'); + } + return { changed: true, row: updated }; +} + +export async function approveReturnRequest(args: { + returnRequestId: string; + actorUserId: string | null; + requestId: string; +}) { + return applyTransition({ + returnRequestId: args.returnRequestId, + actorUserId: args.actorUserId, + requestId: args.requestId, + expectedFrom: 'requested', + statusTo: 'approved', + action: 'return.approve', + eventName: 'return_approved', + setClause: sql`approved_at = ${new Date()}, approved_by = ${args.actorUserId}`, + payload: { + returnRequestId: args.returnRequestId, + actorUserId: args.actorUserId, + }, + }); +} + +export async function rejectReturnRequest(args: { + returnRequestId: string; + actorUserId: string | null; + requestId: string; +}) { + return applyTransition({ + returnRequestId: args.returnRequestId, + actorUserId: args.actorUserId, + requestId: args.requestId, + expectedFrom: 'requested', + statusTo: 'rejected', + action: 'return.reject', + eventName: 'return_rejected', + setClause: sql`rejected_at = ${new Date()}, rejected_by = ${args.actorUserId}`, + payload: { + returnRequestId: args.returnRequestId, + actorUserId: args.actorUserId, + }, + }); +} + +export async function receiveReturnRequest(args: { + returnRequestId: string; + actorUserId: string | null; + requestId: string; +}) { + const current = await loadReturnById(args.returnRequestId); + if (!current) { + throw returnError('RETURN_NOT_FOUND', 'Return request not found.'); + } + if (current.status === 'received') { + return { changed: false, row: current }; + } + if (!isReturnStatusTransitionAllowed(current.status, 'received')) { + throw returnError( + 'RETURN_TRANSITION_INVALID', + `Invalid return transition from ${current.status} to received.`, + { + returnRequestId: current.id, + statusFrom: current.status, + statusTo: 'received', + } + ); + } + + if (current.policyRestock) { + await restockReturnItems(current.id, current.orderId); + } + + return applyTransition({ + returnRequestId: args.returnRequestId, + actorUserId: args.actorUserId, + requestId: args.requestId, + expectedFrom: 'approved', + statusTo: 'received', + action: 'return.receive', + eventName: 'return_received', + setClause: sql`received_at = ${new Date()}, received_by = ${args.actorUserId}`, + payload: { + returnRequestId: args.returnRequestId, + actorUserId: args.actorUserId, + restocked: current.policyRestock, + }, + }); +} + +export async function refundReturnRequest(args: { + returnRequestId: string; + actorUserId: string | null; + requestId: string; +}) { + const current = await loadReturnById(args.returnRequestId); + if (!current) { + throw returnError('RETURN_NOT_FOUND', 'Return request not found.'); + } + if (current.status === 'refunded') { + return { changed: false, row: current }; + } + if (!isReturnStatusTransitionAllowed(current.status, 'refunded')) { + throw returnError( + 'RETURN_REFUND_STATE_INVALID', + 'Refund is allowed only after return is received.', + { returnRequestId: current.id, status: current.status } + ); + } + if (!Number.isInteger(current.refundAmountMinor) || current.refundAmountMinor <= 0) { + throw returnError( + 'RETURN_REFUND_AMOUNT_INVALID', + 'Refund amount is invalid.', + { returnRequestId: current.id, refundAmountMinor: current.refundAmountMinor } + ); + } + + const order = await loadOrder(current.orderId); + if (!order) { + throw returnError('RETURN_NOT_FOUND', 'Order not found for return request.'); + } + + if (order.paymentProvider !== 'stripe') { + throw returnError( + 'RETURN_REFUND_PROVIDER_UNSUPPORTED', + 'Return refund is supported only for Stripe orders.' + ); + } + if (order.paymentStatus !== 'paid') { + throw returnError( + 'RETURN_REFUND_PAYMENT_STATUS_INVALID', + 'Order payment status is not refundable.', + { paymentStatus: order.paymentStatus } + ); + } + if (!order.paymentIntentId && !order.pspChargeId) { + throw returnError( + 'RETURN_REFUND_MISSING_PSP_TARGET', + 'Missing Stripe identifiers for refund.' + ); + } + + const refundIdempotencyKey = + `return_refund:${current.id}:${current.refundAmountMinor}:${current.currency}`.slice( + 0, + 128 + ); + + let refundResult: { refundId: string; status: string | null }; + try { + refundResult = await createRefund({ + orderId: order.id, + paymentIntentId: order.paymentIntentId, + chargeId: order.pspChargeId, + amountMinor: current.refundAmountMinor, + idempotencyKey: refundIdempotencyKey, + }); + } catch (error) { + throw new InvalidPayloadError('Payment provider unavailable.', { + code: 'PSP_UNAVAILABLE', + details: { + returnRequestId: current.id, + reason: error instanceof Error ? error.message : String(error), + }, + }); + } + + return applyTransition({ + returnRequestId: args.returnRequestId, + actorUserId: args.actorUserId, + requestId: args.requestId, + expectedFrom: 'received', + statusTo: 'refunded', + action: 'return.refund', + eventName: 'return_refunded', + setClause: sql` + refunded_at = ${new Date()}, + refunded_by = ${args.actorUserId}, + refund_provider_ref = ${refundResult.refundId} + `, + payload: { + returnRequestId: args.returnRequestId, + actorUserId: args.actorUserId, + refundId: refundResult.refundId, + refundStatus: refundResult.status, + refundAmountMinor: current.refundAmountMinor, + currency: current.currency, + }, + }); +} diff --git a/frontend/lib/services/shop/shipping/admin-actions.ts b/frontend/lib/services/shop/shipping/admin-actions.ts index 0db8cc78..e403a1e3 100644 --- a/frontend/lib/services/shop/shipping/admin-actions.ts +++ b/frontend/lib/services/shop/shipping/admin-actions.ts @@ -3,6 +3,12 @@ import 'server-only'; import { sql } from 'drizzle-orm'; import { db } from '@/db'; +import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; +import { buildAdminAuditDedupeKey } from '@/lib/services/shop/events/dedupe-key'; +import { + isShippingStatusTransitionAllowed, + shippingStatusTransitionWhereSql, +} from '@/lib/services/shop/transitions/shipping-state'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; export type ShippingAdminAction = @@ -41,6 +47,12 @@ type ShippingAuditEntry = { at: string; }; +type CanonicalAdminAuditArgs = { + enabled: boolean; + dedupeKey: string; + occurredAt: Date; +}; + export type ApplyShippingAdminActionResult = { orderId: string; shippingStatus: string | null; @@ -138,26 +150,116 @@ async function loadShippingState( async function appendAuditEntry(args: { orderId: string; entry: ShippingAuditEntry; + canonical: CanonicalAdminAuditArgs; }) { - const entryJson = JSON.stringify([args.entry]); + const entryArrayJson = JSON.stringify([args.entry]); + if (!args.canonical.enabled) { + await db.execute(sql` + update orders o + set psp_metadata = jsonb_set( + coalesce(o.psp_metadata, '{}'::jsonb), + '{shippingAdminAudit}', + coalesce(o.psp_metadata -> 'shippingAdminAudit', '[]'::jsonb) || ${entryArrayJson}::jsonb, + true + ), + updated_at = now() + where o.id = ${args.orderId}::uuid + `); + return; + } + + const entryObjectJson = JSON.stringify(args.entry); await db.execute(sql` - update orders o - set psp_metadata = jsonb_set( - coalesce(o.psp_metadata, '{}'::jsonb), - '{shippingAdminAudit}', - coalesce(o.psp_metadata -> 'shippingAdminAudit', '[]'::jsonb) || ${entryJson}::jsonb, - true + with updated_order as ( + update orders o + set psp_metadata = jsonb_set( + coalesce(o.psp_metadata, '{}'::jsonb), + '{shippingAdminAudit}', + coalesce(o.psp_metadata -> 'shippingAdminAudit', '[]'::jsonb) || ${entryArrayJson}::jsonb, + true + ), + updated_at = now() + where o.id = ${args.orderId}::uuid + returning o.id ), - updated_at = now() - where o.id = ${args.orderId}::uuid + inserted_admin_audit as ( + insert into admin_audit_log ( + order_id, + actor_user_id, + action, + target_type, + target_id, + request_id, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + ${args.entry.actorUserId}, + ${`shipping_admin_action.${args.entry.action}`}, + 'order', + uo.id::text, + ${args.entry.requestId}, + ${entryObjectJson}::jsonb, + ${args.canonical.dedupeKey}, + ${args.canonical.occurredAt}, + now() + from updated_order uo + where ${args.canonical.enabled} = true + on conflict (dedupe_key) do nothing + returning id + ) + select id from updated_order `); } async function requeueShipment(args: { orderId: string; auditEntry: ShippingAuditEntry; + canonical: CanonicalAdminAuditArgs; }): Promise { - const entryJson = JSON.stringify([args.auditEntry]); + const entryArrayJson = JSON.stringify([args.auditEntry]); + if (!args.canonical.enabled) { + const res = await db.execute(sql` + with updated_shipment as ( + update shipping_shipments s + set status = 'queued', + next_attempt_at = now(), + last_error_code = null, + last_error_message = null, + lease_owner = null, + lease_expires_at = null, + updated_at = now() + where s.order_id = ${args.orderId}::uuid + and s.status in ('failed', 'needs_attention') + returning s.order_id + ), + updated_order as ( + update orders o + set shipping_status = 'queued', + psp_metadata = jsonb_set( + coalesce(o.psp_metadata, '{}'::jsonb), + '{shippingAdminAudit}', + coalesce(o.psp_metadata -> 'shippingAdminAudit', '[]'::jsonb) || ${entryArrayJson}::jsonb, + true + ), + updated_at = now() + where o.id in (select order_id from updated_shipment) + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: 'queued', + allowNullFrom: true, + })} + returning o.id, o.shipping_status, o.tracking_number + ) + select * from updated_order + `); + return first(res); + } + + const entryObjectJson = JSON.stringify(args.auditEntry); const res = await db.execute(sql` with updated_shipment as ( @@ -179,12 +281,46 @@ async function requeueShipment(args: { psp_metadata = jsonb_set( coalesce(o.psp_metadata, '{}'::jsonb), '{shippingAdminAudit}', - coalesce(o.psp_metadata -> 'shippingAdminAudit', '[]'::jsonb) || ${entryJson}::jsonb, + coalesce(o.psp_metadata -> 'shippingAdminAudit', '[]'::jsonb) || ${entryArrayJson}::jsonb, true ), updated_at = now() where o.id in (select order_id from updated_shipment) + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: 'queued', + allowNullFrom: true, + })} returning o.id, o.shipping_status, o.tracking_number + ), + inserted_admin_audit as ( + insert into admin_audit_log ( + order_id, + actor_user_id, + action, + target_type, + target_id, + request_id, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + ${args.auditEntry.actorUserId}, + ${`shipping_admin_action.${args.auditEntry.action}`}, + 'order', + uo.id::text, + ${args.auditEntry.requestId}, + ${entryObjectJson}::jsonb, + ${args.canonical.dedupeKey}, + ${args.canonical.occurredAt}, + now() + from updated_order uo + where ${args.canonical.enabled} = true + on conflict (dedupe_key) do nothing + returning id ) select * from updated_order `); @@ -197,21 +333,81 @@ async function updateOrderShippingStatus(args: { expectedStatus: 'label_created' | 'shipped'; nextStatus: 'shipped' | 'delivered'; auditEntry: ShippingAuditEntry; + canonical: CanonicalAdminAuditArgs; }): Promise { - const entryJson = JSON.stringify([args.auditEntry]); + const entryArrayJson = JSON.stringify([args.auditEntry]); + if (!args.canonical.enabled) { + const res = await db.execute(sql` + update orders o + set shipping_status = ${args.nextStatus}, + psp_metadata = jsonb_set( + coalesce(o.psp_metadata, '{}'::jsonb), + '{shippingAdminAudit}', + coalesce(o.psp_metadata -> 'shippingAdminAudit', '[]'::jsonb) || ${entryArrayJson}::jsonb, + true + ), + updated_at = now() + where o.id = ${args.orderId}::uuid + and o.shipping_status = ${args.expectedStatus} + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: args.nextStatus, + })} + returning o.id, o.shipping_status, o.tracking_number + `); + return first(res); + } + + const entryObjectJson = JSON.stringify(args.auditEntry); const res = await db.execute(sql` - update orders o - set shipping_status = ${args.nextStatus}, - psp_metadata = jsonb_set( - coalesce(o.psp_metadata, '{}'::jsonb), - '{shippingAdminAudit}', - coalesce(o.psp_metadata -> 'shippingAdminAudit', '[]'::jsonb) || ${entryJson}::jsonb, - true - ), - updated_at = now() - where o.id = ${args.orderId}::uuid - and o.shipping_status = ${args.expectedStatus} - returning o.id, o.shipping_status, o.tracking_number + with updated_order as ( + update orders o + set shipping_status = ${args.nextStatus}, + psp_metadata = jsonb_set( + coalesce(o.psp_metadata, '{}'::jsonb), + '{shippingAdminAudit}', + coalesce(o.psp_metadata -> 'shippingAdminAudit', '[]'::jsonb) || ${entryArrayJson}::jsonb, + true + ), + updated_at = now() + where o.id = ${args.orderId}::uuid + and o.shipping_status = ${args.expectedStatus} + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: args.nextStatus, + })} + returning o.id, o.shipping_status, o.tracking_number + ), + inserted_admin_audit as ( + insert into admin_audit_log ( + order_id, + actor_user_id, + action, + target_type, + target_id, + request_id, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + ${args.auditEntry.actorUserId}, + ${`shipping_admin_action.${args.auditEntry.action}`}, + 'order', + uo.id::text, + ${args.auditEntry.requestId}, + ${entryObjectJson}::jsonb, + ${args.canonical.dedupeKey}, + ${args.canonical.occurredAt}, + now() + from updated_order uo + where ${args.canonical.enabled} = true + on conflict (dedupe_key) do nothing + returning id + ) + select * from updated_order `); return first(res); } @@ -227,6 +423,25 @@ async function loadShipmentStatus(orderId: string): Promise { return first(res)?.status ?? null; } +function buildShippingAdminAuditDedupe(args: { + orderId: string; + requestId: string; + action: ShippingAdminAction; + fromShippingStatus: string | null; + toShippingStatus: string | null; + fromShipmentStatus: string | null; +}) { + return buildAdminAuditDedupeKey({ + domain: 'shipping_admin_action', + orderId: args.orderId, + requestId: args.requestId, + action: args.action, + fromShippingStatus: args.fromShippingStatus, + toShippingStatus: args.toShippingStatus, + fromShipmentStatus: args.fromShipmentStatus, + }); +} + export async function applyShippingAdminAction(args: { orderId: string; action: ShippingAdminAction; @@ -236,7 +451,9 @@ export async function applyShippingAdminAction(args: { const state = await loadShippingState(args.orderId); assertOrderIsShippable(state); - const nowIso = new Date().toISOString(); + const canonicalDualWriteEnabled = isCanonicalEventsDualWriteEnabled(); + const now = new Date(); + const nowIso = now.toISOString(); if (args.action === 'retry_label_creation') { if (!state.shipment_id) { @@ -258,6 +475,18 @@ export async function applyShippingAdminAction(args: { ); } + if ( + !isShippingStatusTransitionAllowed(state.shipping_status, 'queued', { + allowNullFrom: true, + }) + ) { + throw new ShippingAdminActionError( + 'INVALID_SHIPPING_TRANSITION', + `retry_label_creation is not allowed from ${state.shipping_status ?? 'null'}.`, + 409 + ); + } + const updated = await requeueShipment({ orderId: args.orderId, auditEntry: { @@ -269,6 +498,18 @@ export async function applyShippingAdminAction(args: { fromShipmentStatus: state.shipment_status, at: nowIso, }, + canonical: { + enabled: canonicalDualWriteEnabled, + dedupeKey: buildShippingAdminAuditDedupe({ + orderId: args.orderId, + requestId: args.requestId, + action: args.action, + fromShippingStatus: state.shipping_status, + toShippingStatus: 'queued', + fromShipmentStatus: state.shipment_status, + }), + occurredAt: now, + }, }); if (!updated) { @@ -309,6 +550,18 @@ export async function applyShippingAdminAction(args: { fromShipmentStatus: state.shipment_status, at: nowIso, }, + canonical: { + enabled: canonicalDualWriteEnabled, + dedupeKey: buildShippingAdminAuditDedupe({ + orderId: args.orderId, + requestId: args.requestId, + action: args.action, + fromShippingStatus: state.shipping_status, + toShippingStatus: 'shipped', + fromShipmentStatus: state.shipment_status, + }), + occurredAt: now, + }, }); return { @@ -321,7 +574,9 @@ export async function applyShippingAdminAction(args: { }; } - if (state.shipping_status !== 'label_created') { + if ( + !isShippingStatusTransitionAllowed(state.shipping_status, 'shipped') + ) { throw new ShippingAdminActionError( 'INVALID_SHIPPING_TRANSITION', 'mark_shipped is allowed only from label_created.', @@ -342,6 +597,18 @@ export async function applyShippingAdminAction(args: { fromShipmentStatus: state.shipment_status, at: nowIso, }, + canonical: { + enabled: canonicalDualWriteEnabled, + dedupeKey: buildShippingAdminAuditDedupe({ + orderId: args.orderId, + requestId: args.requestId, + action: args.action, + fromShippingStatus: state.shipping_status, + toShippingStatus: 'shipped', + fromShipmentStatus: state.shipment_status, + }), + occurredAt: now, + }, }); if (!updated) { @@ -382,6 +649,18 @@ export async function applyShippingAdminAction(args: { fromShipmentStatus: state.shipment_status, at: nowIso, }, + canonical: { + enabled: canonicalDualWriteEnabled, + dedupeKey: buildShippingAdminAuditDedupe({ + orderId: args.orderId, + requestId: args.requestId, + action: args.action, + fromShippingStatus: state.shipping_status, + toShippingStatus: 'delivered', + fromShipmentStatus: state.shipment_status, + }), + occurredAt: now, + }, }); return { @@ -394,7 +673,7 @@ export async function applyShippingAdminAction(args: { }; } - if (state.shipping_status !== 'shipped') { + if (!isShippingStatusTransitionAllowed(state.shipping_status, 'delivered')) { throw new ShippingAdminActionError( 'INVALID_SHIPPING_TRANSITION', 'mark_delivered is allowed only from shipped.', @@ -415,6 +694,18 @@ export async function applyShippingAdminAction(args: { fromShipmentStatus: state.shipment_status, at: nowIso, }, + canonical: { + enabled: canonicalDualWriteEnabled, + dedupeKey: buildShippingAdminAuditDedupe({ + orderId: args.orderId, + requestId: args.requestId, + action: args.action, + fromShippingStatus: state.shipping_status, + toShippingStatus: 'delivered', + fromShipmentStatus: state.shipment_status, + }), + occurredAt: now, + }, }); if (!updated) { diff --git a/frontend/lib/services/shop/shipping/shipments-worker.ts b/frontend/lib/services/shop/shipping/shipments-worker.ts index 440b3499..4ef1574b 100644 --- a/frontend/lib/services/shop/shipping/shipments-worker.ts +++ b/frontend/lib/services/shop/shipping/shipments-worker.ts @@ -8,6 +8,8 @@ import { NovaPoshtaConfigError, } from '@/lib/env/nova-poshta'; import { logInfo, logWarn } from '@/lib/logging'; +import { buildShippingEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; +import { writeShippingEvent } from '@/lib/services/shop/events/write-shipping-event'; import { sanitizeShippingErrorMessage } from '@/lib/services/shop/shipping/log-sanitizer'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; import { @@ -15,6 +17,7 @@ import { NovaPoshtaApiError, type NovaPoshtaCreateTtnInput, } from '@/lib/services/shop/shipping/nova-poshta-client'; +import { shippingStatusTransitionWhereSql } from '@/lib/services/shop/transitions/shipping-state'; type ClaimedShipmentRow = { id: string; @@ -50,6 +53,12 @@ type ShipmentError = { transient: boolean; }; +type WorkerShippingEventName = + | 'creating_label' + | 'label_created' + | 'label_creation_retry_scheduled' + | 'label_creation_needs_attention'; + export type RunShippingShipmentsWorkerArgs = { runId: string; leaseSeconds: number; @@ -279,6 +288,71 @@ function computeBackoffSeconds( return Math.min(backoff, 6 * 60 * 60); } +function nextAttemptNumber(attemptCount: number): number { + return Math.max(1, Math.max(0, Math.trunc(attemptCount)) + 1); +} + +function buildWorkerEventDedupeKey(args: { + orderId: string; + shipmentId: string; + eventName: WorkerShippingEventName; + statusTo: string; + attemptNumber: number; + errorCode?: string | null; +}): string { + return buildShippingEventDedupeKey({ + domain: 'shipments_worker', + orderId: args.orderId, + shipmentId: args.shipmentId, + eventName: args.eventName, + statusTo: args.statusTo, + attemptNumber: args.attemptNumber, + errorCode: args.errorCode ?? null, + }); +} + +async function emitWorkerShippingEvent(args: { + orderId: string; + shipmentId: string; + provider: string; + eventName: WorkerShippingEventName; + statusFrom: string | null; + statusTo: string; + attemptNumber: number; + runId: string; + payload?: Record; + eventRef?: string | null; + trackingNumber?: string | null; + errorCode?: string | null; +}) { + const dedupeKey = buildWorkerEventDedupeKey({ + orderId: args.orderId, + shipmentId: args.shipmentId, + eventName: args.eventName, + statusTo: args.statusTo, + attemptNumber: args.attemptNumber, + errorCode: args.errorCode ?? null, + }); + + await writeShippingEvent({ + orderId: args.orderId, + shipmentId: args.shipmentId, + provider: args.provider, + eventName: args.eventName, + eventSource: 'shipments_worker', + eventRef: args.eventRef ?? args.runId, + statusFrom: args.statusFrom, + statusTo: args.statusTo, + trackingNumber: args.trackingNumber ?? null, + payload: { + ...(args.payload ?? {}), + runId: args.runId, + attemptNumber: args.attemptNumber, + }, + dedupeKey, + }); +} + function toNpPayload(args: { order: OrderShippingDetailsRow; snapshot: ParsedShipmentSnapshot; @@ -335,7 +409,9 @@ export async function claimQueuedShipmentsForProcessing(args: { }): Promise { const res = await db.execute(sql` with candidates as ( - select s.id + select + s.id, + s.order_id from shipping_shipments s where ( ( @@ -355,7 +431,15 @@ export async function claimQueuedShipmentsForProcessing(args: { lease_owner = ${args.runId}, lease_expires_at = now() + make_interval(secs => ${args.leaseSeconds}), updated_at = now() - where s.id in (select id from candidates) + from candidates c + join orders o on o.id = c.order_id + where s.id = c.id + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: 'creating_label', + allowNullFrom: true, + includeSame: true, + })} returning s.id, s.order_id, @@ -368,16 +452,69 @@ export async function claimQueuedShipmentsForProcessing(args: { set shipping_status = 'creating_label', updated_at = now() where o.id in (select order_id from claimed) - and ( - o.shipping_status is null - or o.shipping_status in ('pending', 'queued', 'creating_label') - ) - returning o.id + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: 'creating_label', + allowNullFrom: true, + includeSame: true, + })} + returning o.id as order_id + ), + released_blocked as ( + update shipping_shipments s + set status = 'queued', + lease_owner = null, + lease_expires_at = null, + updated_at = now() + from claimed c + left join mark_orders mo on mo.order_id = c.order_id + where s.id = c.id + and mo.order_id is null + returning s.id ) - select * from claimed + select + c.id, + c.order_id, + c.provider, + c.status, + c.attempt_count + from claimed c +join mark_orders mo on mo.order_id = c.order_id `); - return readRows(res); + const claimed = readRows(res); + + for (const row of claimed) { + try { + await emitWorkerShippingEvent({ + orderId: row.order_id, + shipmentId: row.id, + provider: row.provider, + eventName: 'creating_label', + statusFrom: null, + statusTo: 'creating_label', + attemptNumber: nextAttemptNumber(row.attempt_count), + runId: args.runId, + payload: { + shipmentStatusTo: 'processing', + }, + }); + } catch (error) { + logWarn('shipping_shipments_worker_claim_event_write_failed', { + runId: args.runId, + orderId: row.order_id, + shipmentId: row.id, + provider: row.provider, + eventName: 'creating_label', + code: 'SHIPPING_EVENT_WRITE_FAILED', + ...(error instanceof Error && error.message + ? { errorMessage: error.message } + : {}), + }); + } + } + + return claimed; } async function loadOrderShippingDetails( @@ -407,7 +544,11 @@ async function markSucceeded(args: { providerRef: string; trackingNumber: string; }) { - const res = await db.execute<{ order_id: string }>(sql` + const res = await db.execute<{ + shipment_updated: boolean; + order_updated: boolean; + order_id: string | null; + }>(sql` with updated_shipment as ( update shipping_shipments s set status = 'succeeded', @@ -431,12 +572,24 @@ async function markSucceeded(args: { shipping_provider_ref = ${args.providerRef}, updated_at = now() where o.id in (select order_id from updated_shipment) + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: 'label_created', + allowNullFrom: true, + })} returning o.id ) - select order_id from updated_shipment + select + exists (select 1 from updated_shipment) as shipment_updated, + exists (select 1 from updated_order) as order_updated, + (select us.order_id from updated_shipment us limit 1) as order_id `); - return readRows<{ order_id: string }>(res)[0] ?? null; + return readRows<{ + shipment_updated: boolean; + order_updated: boolean; + order_id: string | null; + }>(res)[0] ?? null; } async function markFailed(args: { @@ -446,13 +599,16 @@ async function markFailed(args: { error: ShipmentError; nextAttemptAt: Date | null; terminalNeedsAttention: boolean; -}) { +}): Promise<{ shipment_updated: boolean; order_updated: boolean } | null> { const safeErrorMessage = sanitizeShippingErrorMessage( args.error.message, 'Shipment processing failed.' ); - await db.execute(sql` + const res = await db.execute<{ + shipment_updated: boolean; + order_updated: boolean; + }>(sql` with updated_shipment as ( update shipping_shipments s set status = ${args.terminalNeedsAttention ? 'needs_attention' : 'failed'}, @@ -473,10 +629,23 @@ async function markFailed(args: { updated_at = now() where o.id = ${args.orderId}::uuid and exists (select 1 from updated_shipment) + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: args.terminalNeedsAttention ? 'needs_attention' : 'queued', + allowNullFrom: true, + includeSame: true, + })} returning o.id ) - select 1 + select + exists (select 1 from updated_shipment) as shipment_updated, + exists (select 1 from updated_order) as order_updated `); + + return readRows<{ + shipment_updated: boolean; + order_updated: boolean; + }>(res)[0] ?? null; } async function processClaimedShipment(args: { @@ -547,7 +716,7 @@ async function processClaimedShipment(args: { trackingNumber: created.trackingNumber, }); - if (!marked) { + if (!marked?.shipment_updated) { logWarn('shipping_shipments_worker_lease_lost', { runId: args.runId, shipmentId: args.claim.id, @@ -556,14 +725,59 @@ async function processClaimedShipment(args: { }); return 'retried'; } + if (!marked.order_updated) { + logWarn('shipping_shipments_worker_order_transition_blocked', { + runId: args.runId, + shipmentId: args.claim.id, + orderId: args.claim.order_id, + code: 'ORDER_TRANSITION_BLOCKED', + statusTo: 'label_created', + }); + return 'retried'; + } - recordShippingMetric({ - name: 'succeeded', - source: 'shipments_worker', - runId: args.runId, - orderId: args.claim.order_id, - shipmentId: args.claim.id, - }); + try { + await emitWorkerShippingEvent({ + orderId: args.claim.order_id, + shipmentId: args.claim.id, + provider: args.claim.provider, + eventName: 'label_created', + statusFrom: 'creating_label', + statusTo: 'label_created', + attemptNumber: nextAttemptNumber(args.claim.attempt_count), + runId: args.runId, + eventRef: created.providerRef, + trackingNumber: created.trackingNumber, + payload: { + providerRef: created.providerRef, + shipmentStatusTo: 'succeeded', + }, + }); + } catch { + logWarn('shipping_shipments_worker_post_success_event_write_failed', { + runId: args.runId, + shipmentId: args.claim.id, + orderId: args.claim.order_id, + code: 'SHIPPING_EVENT_WRITE_FAILED', + }); + } + + try { + recordShippingMetric({ + name: 'succeeded', + source: 'shipments_worker', + runId: args.runId, + orderId: args.claim.order_id, + shipmentId: args.claim.id, + }); + } catch { + logWarn('shipping_shipments_worker_post_success_metric_write_failed', { + runId: args.runId, + shipmentId: args.claim.id, + orderId: args.claim.order_id, + code: 'SHIPPING_METRIC_WRITE_FAILED', + }); + } return 'succeeded'; } catch (error) { @@ -584,7 +798,7 @@ async function processClaimedShipment(args: { 1000 ); - await markFailed({ + const updated = await markFailed({ shipmentId: args.claim.id, runId: args.runId, orderId: args.claim.order_id, @@ -593,34 +807,119 @@ async function processClaimedShipment(args: { terminalNeedsAttention, }); - if (terminalNeedsAttention) { - recordShippingMetric({ - name: 'needs_attention', - source: 'shipments_worker', + if (!updated?.shipment_updated) { + logWarn('shipping_shipments_worker_lease_lost', { runId: args.runId, - orderId: args.claim.order_id, shipmentId: args.claim.id, - code: classified.code, + orderId: args.claim.order_id, + code: 'SHIPMENT_LEASE_LOST', }); - } else { - recordShippingMetric({ - name: 'failed', - source: 'shipments_worker', + return 'retried'; + } + if (!updated.order_updated) { + logWarn('shipping_shipments_worker_order_transition_blocked', { runId: args.runId, + shipmentId: args.claim.id, + orderId: args.claim.order_id, + code: 'ORDER_TRANSITION_BLOCKED', + statusTo: terminalNeedsAttention ? 'needs_attention' : 'queued', + }); + return 'retried'; + } + + const failureEventName = terminalNeedsAttention + ? 'label_creation_needs_attention' + : 'label_creation_retry_scheduled'; + try { + await emitWorkerShippingEvent({ orderId: args.claim.order_id, shipmentId: args.claim.id, - code: classified.code, + provider: args.claim.provider, + eventName: failureEventName, + statusFrom: 'creating_label', + statusTo: terminalNeedsAttention ? 'needs_attention' : 'queued', + attemptNumber: nextAttemptNumber(args.claim.attempt_count), + runId: args.runId, + eventRef: classified.code, + errorCode: classified.code, + payload: { + errorCode: classified.code, + errorMessage: classified.message, + transient: classified.transient, + nextAttemptAt: nextAttemptAt ? nextAttemptAt.toISOString() : null, + shipmentStatusTo: terminalNeedsAttention ? 'needs_attention' : 'failed', + }, }); - recordShippingMetric({ - name: 'retries', - source: 'shipments_worker', + } catch { + logWarn('shipping_shipments_worker_failure_event_write_failed', { runId: args.runId, - orderId: args.claim.order_id, shipmentId: args.claim.id, - code: classified.code, + orderId: args.claim.order_id, + provider: args.claim.provider, + code: 'SHIPPING_EVENT_WRITE_FAILED', + eventName: failureEventName, }); } + if (terminalNeedsAttention) { + try { + recordShippingMetric({ + name: 'needs_attention', + source: 'shipments_worker', + runId: args.runId, + orderId: args.claim.order_id, + shipmentId: args.claim.id, + code: classified.code, + }); + } catch { + logWarn('shipping_shipments_worker_terminal_metric_write_failed', { + runId: args.runId, + orderId: args.claim.order_id, + shipmentId: args.claim.id, + errorCode: classified.code, + code: 'SHIPPING_METRIC_WRITE_FAILED', + }); + } + } else { + try { + recordShippingMetric({ + name: 'failed', + source: 'shipments_worker', + runId: args.runId, + orderId: args.claim.order_id, + shipmentId: args.claim.id, + code: classified.code, + }); + } catch { + logWarn('shipping_shipments_worker_terminal_metric_write_failed', { + runId: args.runId, + orderId: args.claim.order_id, + shipmentId: args.claim.id, + errorCode: classified.code, + code: 'SHIPPING_METRIC_WRITE_FAILED', + }); + } + + try { + recordShippingMetric({ + name: 'retries', + source: 'shipments_worker', + runId: args.runId, + orderId: args.claim.order_id, + shipmentId: args.claim.id, + code: classified.code, + }); + } catch { + logWarn('shipping_shipments_worker_terminal_metric_write_failed', { + runId: args.runId, + orderId: args.claim.order_id, + shipmentId: args.claim.id, + errorCode: classified.code, + code: 'SHIPPING_METRIC_WRITE_FAILED', + }); + } + } + logWarn('shipping_shipments_worker_item_failed', { runId: args.runId, shipmentId: args.claim.id, diff --git a/frontend/lib/services/shop/transitions/order-state.ts b/frontend/lib/services/shop/transitions/order-state.ts new file mode 100644 index 00000000..814064df --- /dev/null +++ b/frontend/lib/services/shop/transitions/order-state.ts @@ -0,0 +1,114 @@ +import { sql, type SQL } from 'drizzle-orm'; + +export const ORDER_NON_PAYMENT_STATUSES = [ + 'CREATED', + 'INVENTORY_RESERVED', + 'INVENTORY_FAILED', + 'PAID', + 'CANCELED', +] as const; + +export type OrderNonPaymentStatus = (typeof ORDER_NON_PAYMENT_STATUSES)[number]; + +const ORDER_NON_PAYMENT_ALLOWED_FROM: Record< + OrderNonPaymentStatus, + readonly OrderNonPaymentStatus[] +> = { + CREATED: [], + INVENTORY_RESERVED: ['CREATED'], + INVENTORY_FAILED: ['CREATED', 'INVENTORY_RESERVED', 'PAID', 'INVENTORY_FAILED'], + PAID: [], + CANCELED: ['CREATED', 'INVENTORY_RESERVED', 'INVENTORY_FAILED', 'PAID', 'CANCELED'], +}; + +export function allowedFromOrderNonPaymentStatus( + to: OrderNonPaymentStatus, + options?: { includeSame?: boolean } +): readonly OrderNonPaymentStatus[] { + const from = ORDER_NON_PAYMENT_ALLOWED_FROM[to]; + if (!options?.includeSame) return from; + return Array.from(new Set([...from, to])); +} + +export function isOrderNonPaymentStatusTransitionAllowed( + from: string | null | undefined, + to: OrderNonPaymentStatus, + options?: { includeSame?: boolean } +): boolean { + if (!from) return false; + const allowed = allowedFromOrderNonPaymentStatus(to, options); + return allowed.includes(from as OrderNonPaymentStatus); +} + +export function orderNonPaymentTransitionWhereSql(args: { + column: SQL; + to: OrderNonPaymentStatus; + includeSame?: boolean; +}): SQL { + const from = allowedFromOrderNonPaymentStatus(args.to, { + includeSame: args.includeSame, + }); + if (from.length === 0) return sql`false`; + return sql`${args.column} in (${sql.join(from.map(v => sql`${v}`), sql`, `)})`; +} + +export const ORDER_QUOTE_STATUSES = [ + 'none', + 'requested', + 'offered', + 'accepted', + 'declined', + 'expired', + 'requires_requote', +] as const; + +export type OrderQuoteStatus = (typeof ORDER_QUOTE_STATUSES)[number]; + +const ORDER_QUOTE_ALLOWED_FROM: Record< + OrderQuoteStatus, + readonly OrderQuoteStatus[] +> = { + none: [], + requested: ['none', 'declined', 'expired', 'requires_requote'], + offered: ['none', 'requested', 'declined', 'expired', 'requires_requote'], + accepted: ['offered'], + declined: ['offered'], + expired: ['offered'], + requires_requote: ['offered', 'accepted'], +}; + +export function allowedFromOrderQuoteStatus( + to: OrderQuoteStatus, + options?: { includeSame?: boolean } +): readonly OrderQuoteStatus[] { + const from = ORDER_QUOTE_ALLOWED_FROM[to]; + if (!options?.includeSame) return from; + return Array.from(new Set([...from, to])); +} + +export function isOrderQuoteStatusTransitionAllowed( + from: string | null | undefined, + to: OrderQuoteStatus, + options?: { includeSame?: boolean } +): boolean { + if (!from) return false; + const allowed = allowedFromOrderQuoteStatus(to, options); + return allowed.includes(from as OrderQuoteStatus); +} + +export function orderQuoteTransitionWhereSql(args: { + column: SQL; + to: OrderQuoteStatus; + includeSame?: boolean; +}): SQL { + const from = allowedFromOrderQuoteStatus(args.to, { + includeSame: args.includeSame, + }); + if (from.length === 0) return sql`false`; + return sql`${args.column} in (${sql.join(from.map(v => sql`${v}`), sql`, `)})`; +} + +export const __orderTransitionMatrix = { + nonPayment: ORDER_NON_PAYMENT_ALLOWED_FROM, + quote: ORDER_QUOTE_ALLOWED_FROM, +}; diff --git a/frontend/lib/services/shop/transitions/return-state.ts b/frontend/lib/services/shop/transitions/return-state.ts new file mode 100644 index 00000000..21ac76e2 --- /dev/null +++ b/frontend/lib/services/shop/transitions/return-state.ts @@ -0,0 +1,43 @@ +export const RETURN_STATUSES = [ + 'requested', + 'approved', + 'rejected', + 'received', + 'refunded', +] as const; + +export type ReturnStatus = (typeof RETURN_STATUSES)[number]; + +const RETURN_ALLOWED_FROM: Record = { + requested: [], + approved: ['requested'], + rejected: ['requested'], + received: ['approved'], + refunded: ['received'], +}; +Object.values(RETURN_ALLOWED_FROM).forEach(arr => { + Object.freeze(arr); +}); +Object.freeze(RETURN_ALLOWED_FROM); + +export function allowedFromReturnStatus( + to: ReturnStatus, + options?: { includeSame?: boolean } +): readonly ReturnStatus[] { + const from = RETURN_ALLOWED_FROM[to]; + if (!options?.includeSame) return from; + return Array.from(new Set([...from, to])); +} + +export function isReturnStatusTransitionAllowed( + from: string | null | undefined, + to: ReturnStatus, + options?: { includeSame?: boolean } +): boolean { + if (!from) return false; + const allowed = allowedFromReturnStatus(to, options); + return allowed.includes(from as ReturnStatus); +} + +export const __returnTransitionMatrix = RETURN_ALLOWED_FROM; + diff --git a/frontend/lib/services/shop/transitions/shipping-state.ts b/frontend/lib/services/shop/transitions/shipping-state.ts new file mode 100644 index 00000000..ca7567dd --- /dev/null +++ b/frontend/lib/services/shop/transitions/shipping-state.ts @@ -0,0 +1,71 @@ +import { type SQL,sql } from 'drizzle-orm'; + +export const SHIPPING_STATUSES = [ + 'pending', + 'queued', + 'creating_label', + 'label_created', + 'shipped', + 'delivered', + 'cancelled', + 'needs_attention', +] as const; + +export type ShippingStatus = (typeof SHIPPING_STATUSES)[number]; + +const SHIPPING_ALLOWED_FROM: Record = { + pending: [], + queued: ['pending', 'queued', 'creating_label', 'needs_attention'], + creating_label: ['pending', 'queued', 'creating_label'], + label_created: ['pending', 'queued', 'creating_label'], + shipped: ['label_created'], + delivered: ['shipped'], + cancelled: ['pending', 'queued', 'creating_label', 'label_created', 'shipped'], + needs_attention: ['pending', 'queued', 'creating_label', 'needs_attention'], +}; +Object.values(SHIPPING_ALLOWED_FROM).forEach(arr => { + Object.freeze(arr); +}); +Object.freeze(SHIPPING_ALLOWED_FROM); + +export function allowedFromShippingStatus( + to: ShippingStatus, + options?: { includeSame?: boolean } +): readonly ShippingStatus[] { + const from = SHIPPING_ALLOWED_FROM[to]; + if (!options?.includeSame) return from; + return Array.from(new Set([...from, to])); +} + +export function isShippingStatusTransitionAllowed( + from: string | null | undefined, + to: ShippingStatus, + options?: { allowNullFrom?: boolean; includeSame?: boolean } +): boolean { + if (from == null) return options?.allowNullFrom === true; + const allowed = allowedFromShippingStatus(to, options); + return allowed.includes(from as ShippingStatus); +} + +export function shippingStatusTransitionWhereSql(args: { + column: SQL; + to: ShippingStatus; + allowNullFrom?: boolean; + includeSame?: boolean; +}): SQL { + const from = allowedFromShippingStatus(args.to, { + includeSame: args.includeSame, + }); + const inAllowed = + from.length > 0 + ? sql`${args.column} in (${sql.join(from.map(v => sql`${v}`), sql`, `)})` + : sql`false`; + + if (args.allowNullFrom) { + return sql`(${args.column} is null or ${inAllowed})`; + } + return inAllowed; +} + +export const __shippingTransitionMatrix = SHIPPING_ALLOWED_FROM; + diff --git a/frontend/lib/shop/status-token.ts b/frontend/lib/shop/status-token.ts index 0e263578..4fcf6968 100644 --- a/frontend/lib/shop/status-token.ts +++ b/frontend/lib/shop/status-token.ts @@ -1,11 +1,27 @@ import crypto from 'node:crypto'; +export const STATUS_TOKEN_SCOPES = [ + 'status_lite', + 'order_payment_init', + 'order_quote_request', + 'order_quote_accept', + 'order_quote_decline', +] as const; + +export type StatusTokenScope = (typeof STATUS_TOKEN_SCOPES)[number]; + +const STATUS_TOKEN_SCOPE_SET = new Set(STATUS_TOKEN_SCOPES); +const DEFAULT_STATUS_TOKEN_SCOPES: readonly StatusTokenScope[] = [ + 'status_lite', +]; + type TokenPayload = { v: 1; orderId: string; iat: number; exp: number; nonce: string; + scp: StatusTokenScope[]; }; const DEFAULT_TTL_SECONDS = 45 * 60; @@ -45,16 +61,40 @@ function safeEqual(a: string, b: string): boolean { return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } +function normalizeScopes(raw: unknown): StatusTokenScope[] { + if (!Array.isArray(raw)) return []; + + const seen = new Set(); + const out: StatusTokenScope[] = []; + + for (const item of raw) { + if (typeof item !== 'string') continue; + const scope = item.trim() as StatusTokenScope; + if (!STATUS_TOKEN_SCOPE_SET.has(scope)) continue; + if (seen.has(scope)) continue; + seen.add(scope); + out.push(scope); + } + + return out; +} + export function createStatusToken(args: { orderId: string; ttlSeconds?: number; nowMs?: number; + scopes?: StatusTokenScope[]; }): string { const secret = getSecret(); const nowMs = args.nowMs ?? Date.now(); const iat = Math.floor(nowMs / 1000); const ttl = args.ttlSeconds ?? DEFAULT_TTL_SECONDS; const exp = iat + ttl; + const explicitScopes = normalizeScopes(args.scopes); + const resolvedScopes = + explicitScopes.length > 0 + ? explicitScopes + : [...DEFAULT_STATUS_TOKEN_SCOPES]; const payload: TokenPayload = { v: 1, @@ -62,6 +102,7 @@ export function createStatusToken(args: { iat, exp, nonce: crypto.randomUUID(), + scp: resolvedScopes, }; return signPayload(payload, secret); @@ -96,9 +137,27 @@ export function verifyStatusToken(args: { let payload: TokenPayload; try { - payload = JSON.parse( - base64UrlDecode(body).toString('utf-8') - ) as TokenPayload; + const rawPayload = JSON.parse(base64UrlDecode(body).toString('utf-8')) as { + v?: unknown; + orderId?: unknown; + iat?: unknown; + exp?: unknown; + nonce?: unknown; + scp?: unknown; + }; + + const rawScopes = normalizeScopes(rawPayload.scp); + const scopes = + rawScopes.length > 0 ? rawScopes : [...DEFAULT_STATUS_TOKEN_SCOPES]; + + payload = { + v: rawPayload.v as TokenPayload['v'], + orderId: rawPayload.orderId as TokenPayload['orderId'], + iat: rawPayload.iat as TokenPayload['iat'], + exp: rawPayload.exp as TokenPayload['exp'], + nonce: rawPayload.nonce as TokenPayload['nonce'], + scp: scopes, + }; } catch { return { ok: false, reason: 'invalid_payload' }; } @@ -120,3 +179,10 @@ export function verifyStatusToken(args: { return { ok: true, payload }; } + +export function hasStatusTokenScope( + payload: Pick, + scope: StatusTokenScope +): boolean { + return Array.isArray(payload.scp) && payload.scp.includes(scope); +} diff --git a/frontend/lib/shop/url.ts b/frontend/lib/shop/url.ts index 43a1ef67..baddc62d 100644 --- a/frontend/lib/shop/url.ts +++ b/frontend/lib/shop/url.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { getRuntimeEnv, getServerEnv } from '@/lib/env'; +import { getRuntimeEnv } from '@/lib/env'; function toUrl(value: string, label: string): URL { try { @@ -11,9 +11,11 @@ function toUrl(value: string, label: string): URL { } export function resolveShopBaseUrl(): URL { - const env = getServerEnv(); const raw = - env.SHOP_BASE_URL ?? env.APP_ORIGIN ?? env.NEXT_PUBLIC_SITE_URL ?? ''; + process.env.SHOP_BASE_URL ?? + process.env.APP_ORIGIN ?? + process.env.NEXT_PUBLIC_SITE_URL ?? + ''; if (!raw) { throw new Error( diff --git a/frontend/lib/tests/helpers/db-safety.ts b/frontend/lib/tests/helpers/db-safety.ts index 3202516d..e3c79c6c 100644 --- a/frontend/lib/tests/helpers/db-safety.ts +++ b/frontend/lib/tests/helpers/db-safety.ts @@ -6,6 +6,8 @@ export function assertNotProductionDb(): void { const appEnv = (process.env.APP_ENV ?? 'local').toLowerCase(); const databaseUrl = process.env.DATABASE_URL ?? ''; const databaseUrlLocal = process.env.DATABASE_URL_LOCAL ?? ''; + const strictLocal = process.env.SHOP_STRICT_LOCAL_DB === '1'; + const requiredLocal = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL ?? ''; const reasons: string[] = []; @@ -24,6 +26,22 @@ export function assertNotProductionDb(): void { reasons.push('DATABASE_URL_LOCAL looks production-like'); } + if (strictLocal && databaseUrl.trim()) { + reasons.push('DATABASE_URL must be unset when SHOP_STRICT_LOCAL_DB=1'); + } + + if (strictLocal && !databaseUrlLocal.trim()) { + reasons.push( + 'DATABASE_URL_LOCAL must be set when SHOP_STRICT_LOCAL_DB=1' + ); + } + + if (strictLocal && requiredLocal && databaseUrlLocal !== requiredLocal) { + reasons.push( + 'DATABASE_URL_LOCAL must match SHOP_REQUIRED_DATABASE_URL_LOCAL exactly' + ); + } + if (reasons.length > 0) { throw new Error( `[db-safety] Refusing DB-mutating tests against production-like DB config. Reasons: ${reasons.join( diff --git a/frontend/lib/tests/shop/admin-product-audit-dedupe-phase5.test.ts b/frontend/lib/tests/shop/admin-product-audit-dedupe-phase5.test.ts new file mode 100644 index 00000000..f520b509 --- /dev/null +++ b/frontend/lib/tests/shop/admin-product-audit-dedupe-phase5.test.ts @@ -0,0 +1,61 @@ +import { eq } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db'; +import { adminAuditLog } from '@/db/schema'; +import { writeAdminAudit } from '@/lib/services/shop/events/write-admin-audit'; + +describe.sequential('admin product audit dedupe phase 5', () => { + it('inserts one admin_audit_log row for the same dedupe seed', async () => { + const seed = { + domain: 'product_admin_action', + action: 'update', + requestId: 'req_dedupe_product_update_1', + productId: '55555555-5555-4555-8555-555555555555', + toIsActive: true, + toStock: 10, + }; + + const first = await writeAdminAudit({ + actorUserId: null, + action: 'product_admin_action.update', + targetType: 'product', + targetId: seed.productId, + requestId: seed.requestId, + payload: { test: true }, + dedupeSeed: seed, + }); + + const second = await writeAdminAudit({ + actorUserId: null, + action: 'product_admin_action.update', + targetType: 'product', + targetId: seed.productId, + requestId: seed.requestId, + payload: { test: true }, + dedupeSeed: seed, + }); + + expect(first.inserted).toBe(true); + expect(second.inserted).toBe(false); + expect(second.dedupeKey).toBe(first.dedupeKey); + + const rows = await db + .select({ + id: adminAuditLog.id, + dedupeKey: adminAuditLog.dedupeKey, + action: adminAuditLog.action, + targetId: adminAuditLog.targetId, + }) + .from(adminAuditLog) + .where(eq(adminAuditLog.dedupeKey, first.dedupeKey)); + + expect(rows.length).toBe(1); + expect(rows[0]?.action).toBe('product_admin_action.update'); + expect(rows[0]?.targetId).toBe(seed.productId); + + await db + .delete(adminAuditLog) + .where(eq(adminAuditLog.dedupeKey, first.dedupeKey)); + }); +}); diff --git a/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts b/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts new file mode 100644 index 00000000..81d2b591 --- /dev/null +++ b/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts @@ -0,0 +1,283 @@ +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const adminUser = { + id: 'admin_user_1', + role: 'admin', + email: 'admin@example.com', +}; + +const mocks = vi.hoisted(() => ({ + requireAdminApi: vi.fn(async () => adminUser), + requireAdminCsrf: vi.fn(() => null), + parseAdminProductForm: vi.fn(), + createProduct: vi.fn(), + updateProduct: vi.fn(), + deleteProduct: vi.fn(), + toggleProductStatus: vi.fn(), + getAdminProductByIdWithPrices: vi.fn(), + writeAdminAudit: vi.fn(async () => ({ + inserted: true, + dedupeKey: 'admin_audit:v1:test', + id: 'audit_row_1', + })), +})); + +vi.mock('@/lib/auth/admin', () => { + class AdminApiDisabledError extends Error { + code = 'ADMIN_API_DISABLED' as const; + } + class AdminUnauthorizedError extends Error { + code = 'UNAUTHORIZED' as const; + } + class AdminForbiddenError extends Error { + code = 'FORBIDDEN' as const; + } + return { + AdminApiDisabledError, + AdminUnauthorizedError, + AdminForbiddenError, + requireAdminApi: mocks.requireAdminApi, + }; +}); + +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: mocks.requireAdminCsrf, +})); + +vi.mock('@/lib/admin/parseAdminProductForm', () => ({ + parseAdminProductForm: mocks.parseAdminProductForm, +})); + +vi.mock('@/lib/services/products', () => ({ + createProduct: mocks.createProduct, + updateProduct: mocks.updateProduct, + deleteProduct: mocks.deleteProduct, + toggleProductStatus: mocks.toggleProductStatus, + getAdminProductByIdWithPrices: mocks.getAdminProductByIdWithPrices, +})); + +vi.mock('@/lib/services/shop/events/write-admin-audit', () => ({ + writeAdminAudit: mocks.writeAdminAudit, +})); + +function makeProduct(id: string, isActive = true) { + const now = new Date('2026-01-01T00:00:00.000Z'); + return { + id, + slug: `slug-${id.slice(0, 8)}`, + title: `Title ${id.slice(0, 8)}`, + description: 'desc', + imageUrl: 'https://example.com/product.png', + imagePublicId: 'products/p1', + price: 10, + originalPrice: undefined, + currency: 'USD', + category: undefined, + type: undefined, + colors: [], + sizes: [], + badge: 'NONE', + isActive, + isFeatured: false, + stock: 3, + sku: undefined, + createdAt: now, + updatedAt: now, + }; +} + +function makeFormDataWithImage(): FormData { + const fd = new FormData(); + fd.append( + 'image', + new File([new Uint8Array([1, 2, 3])], 'test.png', { type: 'image/png' }) + ); + return fd; +} + +describe('admin product canonical audit phase 5', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('ENABLE_ADMIN_API', 'true'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('POST create writes canonical admin_audit entry with dedupe seed', async () => { + const requestId = 'req_product_create_1'; + mocks.parseAdminProductForm.mockReturnValue({ + ok: true, + data: { + title: 'New product', + badge: 'NONE', + prices: [{ currency: 'USD', priceMinor: 1000, originalPriceMinor: null }], + }, + }); + + const createdId = '11111111-1111-4111-8111-111111111111'; + mocks.createProduct.mockResolvedValue(makeProduct(createdId, true)); + + const { POST } = await import('@/app/api/shop/admin/products/route'); + const req = new NextRequest( + new Request('http://localhost/api/shop/admin/products', { + method: 'POST', + headers: { + origin: 'http://localhost:3000', + 'x-request-id': requestId, + }, + body: makeFormDataWithImage(), + }) + ); + + const res = await POST(req); + expect(res.status).toBe(201); + + expect(mocks.writeAdminAudit).toHaveBeenCalledTimes(1); + const call = mocks.writeAdminAudit.mock.calls[0]?.[0]; + expect(call).toMatchObject({ + actorUserId: adminUser.id, + action: 'product_admin_action.create', + targetType: 'product', + targetId: createdId, + requestId, + dedupeSeed: { + domain: 'product_admin_action', + action: 'create', + requestId, + productId: createdId, + }, + }); + }); + + it('PATCH update writes canonical admin_audit entry with dedupe seed', async () => { + const requestId = 'req_product_update_1'; + const productId = '22222222-2222-4222-8222-222222222222'; + + mocks.parseAdminProductForm.mockReturnValue({ + ok: true, + data: { + title: 'Updated title', + badge: 'NONE', + prices: [{ currency: 'USD', priceMinor: 2000, originalPriceMinor: null }], + }, + }); + mocks.updateProduct.mockResolvedValue(makeProduct(productId, true)); + + const { PATCH } = await import('@/app/api/shop/admin/products/[id]/route'); + const req = new NextRequest( + new Request(`http://localhost/api/shop/admin/products/${productId}`, { + method: 'PATCH', + headers: { + origin: 'http://localhost:3000', + 'x-request-id': requestId, + }, + body: makeFormDataWithImage(), + }) + ); + + const res = await PATCH(req, { + params: Promise.resolve({ id: productId }), + }); + expect(res.status).toBe(200); + + expect(mocks.writeAdminAudit).toHaveBeenCalledTimes(1); + const call = mocks.writeAdminAudit.mock.calls[0]?.[0]; + expect(call).toMatchObject({ + actorUserId: adminUser.id, + action: 'product_admin_action.update', + targetType: 'product', + targetId: productId, + requestId, + dedupeSeed: { + domain: 'product_admin_action', + action: 'update', + requestId, + productId, + }, + }); + }); + + it('DELETE writes canonical admin_audit entry with dedupe seed', async () => { + const requestId = 'req_product_delete_1'; + const productId = '33333333-3333-4333-8333-333333333333'; + + mocks.deleteProduct.mockResolvedValue(undefined); + + const { DELETE } = await import('@/app/api/shop/admin/products/[id]/route'); + const req = new NextRequest( + new Request(`http://localhost/api/shop/admin/products/${productId}`, { + method: 'DELETE', + headers: { + origin: 'http://localhost:3000', + 'x-request-id': requestId, + }, + }) + ); + + const res = await DELETE(req, { + params: Promise.resolve({ id: productId }), + }); + expect(res.status).toBe(200); + + expect(mocks.writeAdminAudit).toHaveBeenCalledTimes(1); + const call = mocks.writeAdminAudit.mock.calls[0]?.[0]; + expect(call).toMatchObject({ + actorUserId: adminUser.id, + action: 'product_admin_action.delete', + targetType: 'product', + targetId: productId, + requestId, + dedupeSeed: { + domain: 'product_admin_action', + action: 'delete', + requestId, + productId, + }, + }); + }); + + it('status toggle writes canonical admin_audit entry with dedupe seed', async () => { + const requestId = 'req_product_toggle_1'; + const productId = '44444444-4444-4444-8444-444444444444'; + + mocks.toggleProductStatus.mockResolvedValue(makeProduct(productId, false)); + + const { PATCH } = await import( + '@/app/api/shop/admin/products/[id]/status/route' + ); + const req = new NextRequest( + new Request(`http://localhost/api/shop/admin/products/${productId}/status`, { + method: 'PATCH', + headers: { + origin: 'http://localhost:3000', + 'x-request-id': requestId, + }, + }) + ); + + const res = await PATCH(req, { + params: Promise.resolve({ id: productId }), + }); + expect(res.status).toBe(200); + + expect(mocks.writeAdminAudit).toHaveBeenCalledTimes(1); + const call = mocks.writeAdminAudit.mock.calls[0]?.[0]; + expect(call).toMatchObject({ + actorUserId: adminUser.id, + action: 'product_admin_action.toggle_status', + targetType: 'product', + targetId: productId, + requestId, + dedupeSeed: { + domain: 'product_admin_action', + action: 'toggle_status', + requestId, + productId, + toIsActive: false, + }, + }); + }); +}); diff --git a/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts b/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts new file mode 100644 index 00000000..8663a1b1 --- /dev/null +++ b/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts @@ -0,0 +1,227 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { productPrices, products } from '@/db/schema'; + +const adminUser = { + id: 'admin_user_atomic_1', + role: 'admin', + email: 'admin.atomic@example.com', +}; + +const mocks = vi.hoisted(() => ({ + requireAdminApi: vi.fn(async () => adminUser), + requireAdminCsrf: vi.fn(() => null), + parseAdminProductForm: vi.fn(), + writeAdminAudit: vi.fn(async () => { + throw new Error('audit-fail'); + }), +})); + +vi.mock('@/lib/auth/admin', () => { + class AdminApiDisabledError extends Error { + code = 'ADMIN_API_DISABLED' as const; + } + class AdminUnauthorizedError extends Error { + code = 'UNAUTHORIZED' as const; + } + class AdminForbiddenError extends Error { + code = 'FORBIDDEN' as const; + } + + return { + AdminApiDisabledError, + AdminUnauthorizedError, + AdminForbiddenError, + requireAdminApi: mocks.requireAdminApi, + }; +}); + +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: mocks.requireAdminCsrf, +})); + +vi.mock('@/lib/admin/parseAdminProductForm', () => ({ + parseAdminProductForm: mocks.parseAdminProductForm, +})); + +vi.mock('@/lib/services/shop/events/write-admin-audit', () => ({ + writeAdminAudit: mocks.writeAdminAudit, +})); + +vi.mock('@/lib/cloudinary', () => ({ + uploadProductImageFromFile: vi.fn(async () => ({ + secureUrl: 'https://example.com/atomic-create-test.png', + publicId: 'products/atomic-create-test', + })), + destroyProductImage: vi.fn(async () => {}), +})); + +async function cleanupBySlug(slug: string) { + const existing = await db + .select({ id: products.id }) + .from(products) + .where(eq(products.slug, slug)) + .limit(1); + + const productId = existing[0]?.id; + if (!productId) return; + + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + await db.delete(products).where(eq(products.id, productId)); +} + +function makeFormData(): FormData { + const fd = new FormData(); + fd.append( + 'image', + new File([new Uint8Array([1, 2, 3, 4])], 'atomic.png', { + type: 'image/png', + }) + ); + return fd; +} + +describe.sequential('admin products create atomicity (phase C)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('ENABLE_ADMIN_API', 'true'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('does not persist product when admin audit write fails', async () => { + const slug = `atomic-create-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; + + mocks.parseAdminProductForm.mockReturnValue({ + ok: true, + data: { + slug, + title: 'Atomic create product', + badge: 'NONE', + prices: [{ currency: 'USD', priceMinor: 1999, originalPriceMinor: null }], + stock: 2, + isActive: true, + isFeatured: false, + }, + }); + + await cleanupBySlug(slug); + + try { + const { POST } = await import('@/app/api/shop/admin/products/route'); + + const req = new NextRequest( + new Request('http://localhost/api/shop/admin/products', { + method: 'POST', + headers: { + origin: 'http://localhost:3000', + 'x-request-id': `req_${crypto.randomUUID()}`, + }, + body: makeFormData(), + }) + ); + + const res = await POST(req); + expect(res.status).toBe(500); + + const json = await res.json(); + expect(json.code).toBe('INTERNAL_ERROR'); + expect(mocks.writeAdminAudit).toHaveBeenCalled(); + expect(mocks.writeAdminAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'product_admin_action.create', + targetType: 'product', + }) + ); + + const existing = await db + .select({ id: products.id }) + .from(products) + .where(eq(products.slug, slug)) + .limit(1); + + expect(existing).toHaveLength(0); + } finally { + await cleanupBySlug(slug); + } + }); + + it('does not destroy Cloudinary image when rollback product delete fails', async () => { + const slug = `atomic-create-rollback-${Date.now()}-${crypto + .randomUUID() + .slice(0, 8)}`; + + mocks.parseAdminProductForm.mockReturnValue({ + ok: true, + data: { + slug, + title: 'Atomic create rollback guard', + badge: 'NONE', + prices: [{ currency: 'USD', priceMinor: 2099, originalPriceMinor: null }], + stock: 2, + isActive: true, + isFeatured: false, + }, + }); + + await cleanupBySlug(slug); + + const productServices = await import('@/lib/services/products'); + const deleteSpy = vi + .spyOn(productServices, 'deleteProduct') + .mockRejectedValueOnce(new Error('rollback-delete-fail')); + + const cloudinary = await import('@/lib/cloudinary'); + const destroyProductImageMock = vi.mocked(cloudinary.destroyProductImage); + const uploadProductImageFromFileMock = vi.mocked( + cloudinary.uploadProductImageFromFile + ); + + try { + const { POST } = await import('@/app/api/shop/admin/products/route'); + + const req = new NextRequest( + new Request('http://localhost/api/shop/admin/products', { + method: 'POST', + headers: { + origin: 'http://localhost:3000', + 'x-request-id': `req_${crypto.randomUUID()}`, + }, + body: makeFormData(), + }) + ); + + const res = await POST(req); + expect(res.status).toBe(500); + expect(mocks.writeAdminAudit).toHaveBeenCalled(); + expect(mocks.writeAdminAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'product_admin_action.create', + targetType: 'product', + }) + ); + expect(deleteSpy).toHaveBeenCalled(); + expect(uploadProductImageFromFileMock).toHaveBeenCalledTimes(1); + expect(destroyProductImageMock).not.toHaveBeenCalled(); + + const existing = await db + .select({ id: products.id, imagePublicId: products.imagePublicId }) + .from(products) + .where(eq(products.slug, slug)) + .limit(1); + + expect(existing).toHaveLength(1); + expect(existing[0]?.imagePublicId).toBeTruthy(); + } finally { + deleteSpy.mockRestore(); + await cleanupBySlug(slug); + } + }); +}); diff --git a/frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts b/frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts new file mode 100644 index 00000000..2bc6940e --- /dev/null +++ b/frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts @@ -0,0 +1,68 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db'; +import { adminAuditLog, orders } from '@/db/schema'; +import { applyShippingAdminAction } from '@/lib/services/shop/shipping/admin-actions'; +import { toDbMoney } from '@/lib/shop/money'; + +async function cleanup(orderId: string) { + await db.delete(adminAuditLog).where(eq(adminAuditLog.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +describe.sequential('admin shipping action canonical audit', () => { + it('mark_shipped inserts admin_audit_log row by default', async () => { + const orderId = crypto.randomUUID(); + const requestId = `req_${crypto.randomUUID()}`; + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'reserved', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingAmountMinor: null, + shippingStatus: 'label_created', + idempotencyKey: crypto.randomUUID(), + } as any); + + try { + const result = await applyShippingAdminAction({ + orderId, + action: 'mark_shipped', + actorUserId: null, + requestId, + }); + + expect(result.changed).toBe(true); + expect(result.shippingStatus).toBe('shipped'); + + const logs = await db + .select({ + id: adminAuditLog.id, + action: adminAuditLog.action, + requestId: adminAuditLog.requestId, + orderId: adminAuditLog.orderId, + }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, orderId)); + + expect(logs.length).toBe(1); + expect(logs[0]?.action).toBe('shipping_admin_action.mark_shipped'); + expect(logs[0]?.requestId).toBe(requestId); + expect(logs[0]?.orderId).toBe(orderId); + } finally { + await cleanup(orderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/canonical-events-env.test.ts b/frontend/lib/tests/shop/canonical-events-env.test.ts new file mode 100644 index 00000000..78e483ef --- /dev/null +++ b/frontend/lib/tests/shop/canonical-events-env.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; + +const ENV_KEYS = [ + 'SHOP_CANONICAL_EVENTS_DUAL_WRITE', + 'APP_ENV', + 'NODE_ENV', +] as const; + +const previousEnv: Record = {}; + +beforeEach(() => { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + } + process.env.APP_ENV = 'local'; + process.env.NODE_ENV = 'test'; + delete process.env.SHOP_CANONICAL_EVENTS_DUAL_WRITE; +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const prev = previousEnv[key]; + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } +}); + +describe('shop canonical events env policy', () => { + it('defaults to enabled when env is unset', () => { + expect(isCanonicalEventsDualWriteEnabled()).toBe(true); + }); + + it('allows explicit enable values', () => { + process.env.SHOP_CANONICAL_EVENTS_DUAL_WRITE = 'true'; + expect(isCanonicalEventsDualWriteEnabled()).toBe(true); + }); + + it('allows explicit disable only in non-production runtime', () => { + process.env.APP_ENV = 'local'; + process.env.NODE_ENV = 'test'; + process.env.SHOP_CANONICAL_EVENTS_DUAL_WRITE = 'off'; + + expect(isCanonicalEventsDualWriteEnabled()).toBe(false); + }); + + it('throws in production runtime when explicit disable is set', () => { + process.env.APP_ENV = 'local'; + process.env.NODE_ENV = 'production'; + process.env.SHOP_CANONICAL_EVENTS_DUAL_WRITE = 'false'; + + expect(() => isCanonicalEventsDualWriteEnabled()).toThrow( + 'cannot be disabled in production' + ); + }); + + it('throws on invalid value', () => { + process.env.SHOP_CANONICAL_EVENTS_DUAL_WRITE = 'sometimes'; + + expect(() => isCanonicalEventsDualWriteEnabled()).toThrow( + 'Invalid SHOP_CANONICAL_EVENTS_DUAL_WRITE value' + ); + }); +}); diff --git a/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts b/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts new file mode 100644 index 00000000..759ac7d2 --- /dev/null +++ b/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts @@ -0,0 +1,320 @@ +import crypto from 'crypto'; +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { + orderLegalConsents, + orders, + productPrices, + products, +} from '@/db/schema/shop'; +import { + IdempotencyConflictError, +} from '@/lib/services/errors'; +import { createOrderWithItems } from '@/lib/services/orders'; +import { toDbMoney } from '@/lib/shop/money'; + +type SeedProduct = { + productId: string; +}; + +async function seedProduct(): Promise { + const productId = crypto.randomUUID(); + const now = new Date(); + + await db.insert(products).values({ + id: productId, + slug: `checkout-legal-${productId.slice(0, 8)}`, + title: 'Checkout Legal Consent Test Product', + imageUrl: 'https://example.com/legal-consent.png', + price: '10.00', + currency: 'USD', + isActive: true, + stock: 10, + sizes: [], + colors: [], + createdAt: now, + updatedAt: now, + } as any); + + await db.insert(productPrices).values([ + { + id: crypto.randomUUID(), + productId, + currency: 'USD', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + createdAt: now, + updatedAt: now, + }, + { + id: crypto.randomUUID(), + productId, + currency: 'UAH', + priceMinor: 4200, + originalPriceMinor: null, + price: toDbMoney(4200), + originalPrice: null, + createdAt: now, + updatedAt: now, + }, + ] as any); + + return { productId }; +} + +async function cleanupProduct(productId: string) { + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + await db.delete(products).where(eq(products.id, productId)); +} + +async function cleanupOrder(orderId: string) { + await db.delete(orders).where(eq(orders.id, orderId)); +} + +describe('checkout legal consent phase 4', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('persists legal consent artifact for new order', async () => { + const { productId } = await seedProduct(); + let orderId: string | null = null; + const before = Date.now(); + + try { + const result = await createOrderWithItems({ + idempotencyKey: crypto.randomUUID(), + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: { + termsAccepted: true, + privacyAccepted: true, + termsVersion: 'terms-2026-02-27', + privacyVersion: 'privacy-2026-02-27', + }, + }); + + orderId = result.order.id; + + const [row] = await db + .select({ + orderId: orderLegalConsents.orderId, + termsAccepted: orderLegalConsents.termsAccepted, + privacyAccepted: orderLegalConsents.privacyAccepted, + termsVersion: orderLegalConsents.termsVersion, + privacyVersion: orderLegalConsents.privacyVersion, + source: orderLegalConsents.source, + locale: orderLegalConsents.locale, + country: orderLegalConsents.country, + consentedAt: orderLegalConsents.consentedAt, + }) + .from(orderLegalConsents) + .where(eq(orderLegalConsents.orderId, orderId)) + .limit(1); + const after = Date.now(); + + expect(row).toBeTruthy(); + expect(row?.termsAccepted).toBe(true); + expect(row?.privacyAccepted).toBe(true); + expect(row?.termsVersion).toBe('terms-2026-02-27'); + expect(row?.privacyVersion).toBe('privacy-2026-02-27'); + expect(row?.source).toBe('checkout_explicit'); + expect(row?.locale).toBe('en-us'); + expect(row?.country).toBe('US'); + expect(row?.consentedAt).toBeInstanceOf(Date); + expect(row?.consentedAt.getTime()).toBeGreaterThanOrEqual(before - 1000); + expect(row?.consentedAt.getTime()).toBeLessThanOrEqual(after + 1000); + } finally { + if (orderId) await cleanupOrder(orderId); + await cleanupProduct(productId); + } + }, 30_000); + + it('idempotency conflicts if legal consent versions change for same key', async () => { + const { productId } = await seedProduct(); + let orderId: string | null = null; + const idempotencyKey = crypto.randomUUID(); + let baselineConsentedAtMs: number | null = null; + let baselineSource: string | null = null; + + try { + const first = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: { + termsAccepted: true, + privacyAccepted: true, + termsVersion: 'terms-2026-02-27', + privacyVersion: 'privacy-2026-02-27', + }, + }); + + orderId = first.order.id; + const [baseline] = await db + .select({ + consentedAt: orderLegalConsents.consentedAt, + source: orderLegalConsents.source, + }) + .from(orderLegalConsents) + .where(eq(orderLegalConsents.orderId, orderId)) + .limit(1); + + baselineConsentedAtMs = baseline?.consentedAt.getTime() ?? null; + baselineSource = baseline?.source ?? null; + + await expect( + createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: { + termsAccepted: true, + privacyAccepted: true, + termsVersion: 'terms-2026-03-01', + privacyVersion: 'privacy-2026-02-27', + }, + }) + ).rejects.toBeInstanceOf(IdempotencyConflictError); + + const [afterConflict] = await db + .select({ + consentedAt: orderLegalConsents.consentedAt, + source: orderLegalConsents.source, + termsVersion: orderLegalConsents.termsVersion, + privacyVersion: orderLegalConsents.privacyVersion, + }) + .from(orderLegalConsents) + .where(eq(orderLegalConsents.orderId, orderId)) + .limit(1); + + expect(afterConflict).toBeTruthy(); + expect(afterConflict?.consentedAt.getTime()).toBe(baselineConsentedAtMs); + expect(afterConflict?.source).toBe(baselineSource); + expect(afterConflict?.termsVersion).toBe('terms-2026-02-27'); + expect(afterConflict?.privacyVersion).toBe('privacy-2026-02-27'); + } finally { + if (orderId) await cleanupOrder(orderId); + await cleanupProduct(productId); + } + }, 30_000); + + it('fails closed when idempotent replay finds missing legal consent row', async () => { + const { productId } = await seedProduct(); + let orderId: string | null = null; + const idempotencyKey = crypto.randomUUID(); + + try { + const first = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: { + termsAccepted: true, + privacyAccepted: true, + termsVersion: 'terms-2026-02-27', + privacyVersion: 'privacy-2026-02-27', + }, + }); + + orderId = first.order.id; + + await db + .delete(orderLegalConsents) + .where(eq(orderLegalConsents.orderId, orderId)); + + await expect( + createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: { + termsAccepted: true, + privacyAccepted: true, + termsVersion: 'terms-2026-02-27', + privacyVersion: 'privacy-2026-02-27', + }, + }) + ).rejects.toMatchObject({ + code: 'IDEMPOTENCY_CONFLICT', + details: { + orderId, + reason: 'LEGAL_CONSENT_MISSING', + }, + }); + + const [missing] = await db + .select({ orderId: orderLegalConsents.orderId }) + .from(orderLegalConsents) + .where(eq(orderLegalConsents.orderId, orderId)) + .limit(1); + + expect(missing).toBeUndefined(); + } finally { + if (orderId) await cleanupOrder(orderId); + await cleanupProduct(productId); + } + }, 30_000); + + it('rejects checkout when terms or privacy are explicitly not accepted', async () => { + const { productId } = await seedProduct(); + + try { + await expect( + createOrderWithItems({ + idempotencyKey: crypto.randomUUID(), + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: { + termsAccepted: false, + privacyAccepted: true, + termsVersion: 'terms-2026-02-27', + privacyVersion: 'privacy-2026-02-27', + }, + }) + ).rejects.toMatchObject({ + code: 'TERMS_NOT_ACCEPTED', + }); + + await expect( + createOrderWithItems({ + idempotencyKey: crypto.randomUUID(), + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: { + termsAccepted: true, + privacyAccepted: false, + termsVersion: 'terms-2026-02-27', + privacyVersion: 'privacy-2026-02-27', + }, + }) + ).rejects.toMatchObject({ + code: 'PRIVACY_NOT_ACCEPTED', + }); + } finally { + await cleanupProduct(productId); + } + }, 30_000); +}); 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 6ca57110..25f1651c 100644 --- a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts @@ -59,7 +59,6 @@ const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; const __prevAppOrigin = process.env.APP_ORIGIN; const __prevShopBaseUrl = process.env.SHOP_BASE_URL; const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; -const __prevDatabaseUrl = process.env.DATABASE_URL; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; @@ -69,9 +68,6 @@ beforeAll(() => { process.env.SHOP_BASE_URL = 'http://localhost:3000'; process.env.SHOP_STATUS_TOKEN_SECRET = 'test_status_token_secret_test_status_token_secret'; - if (__prevDatabaseUrl !== undefined) { - process.env.DATABASE_URL = __prevDatabaseUrl; - } resetEnvCache(); }); @@ -97,9 +93,6 @@ afterAll(() => { delete process.env.SHOP_STATUS_TOKEN_SECRET; else process.env.SHOP_STATUS_TOKEN_SECRET = __prevStatusSecret; - if (__prevDatabaseUrl === undefined) delete process.env.DATABASE_URL; - else process.env.DATABASE_URL = __prevDatabaseUrl; - resetEnvCache(); }); diff --git a/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts b/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts index ce1cfa54..63eeb626 100644 --- a/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts +++ b/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts @@ -122,7 +122,6 @@ async function cleanupSeedData(data: SeedData, orderIds: string[]) { describe('checkout shipping phase 3', () => { beforeEach(() => { vi.unstubAllEnvs(); - vi.stubEnv('DATABASE_URL', 'https://example.com/db'); vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); vi.stubEnv('SHOP_SHIPPING_SYNC_ENABLED', 'true'); diff --git a/frontend/lib/tests/shop/intl-quote-domain-phase2.test.ts b/frontend/lib/tests/shop/intl-quote-domain-phase2.test.ts new file mode 100644 index 00000000..eae4496c --- /dev/null +++ b/frontend/lib/tests/shop/intl-quote-domain-phase2.test.ts @@ -0,0 +1,453 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db'; +import { + orderItems, + orders, + paymentAttempts, + products, + shippingEvents, + shippingQuotes, +} from '@/db/schema'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { + acceptIntlQuote, + declineIntlQuote, + offerIntlQuote, + requestIntlQuote, + sweepAcceptedIntlQuotePaymentTimeouts, + sweepExpiredOfferedIntlQuotes, +} from '@/lib/services/shop/quotes'; +import { toDbMoney } from '@/lib/shop/money'; + +type Seeded = { + orderId: string; + productId: string; +}; + +async function seedIntlOrder(args?: { + stock?: number; + quantity?: number; + totalAmountMinor?: number; +}) { + const orderId = crypto.randomUUID(); + const productId = crypto.randomUUID(); + const quantity = args?.quantity ?? 1; + const totalAmountMinor = args?.totalAmountMinor ?? 1000; + + await db.insert(products).values({ + id: productId, + slug: `phase2-intl-${crypto.randomUUID()}`, + title: 'Phase 2 INTL Product', + description: 'Test product', + imageUrl: 'https://example.com/p.png', + imagePublicId: null, + price: toDbMoney(1000), + originalPrice: null, + currency: 'USD', + stock: args?.stock ?? 10, + sku: null, + isActive: true, + isFeatured: false, + } as any); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor, + totalAmount: toDbMoney(totalAmountMinor), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'pending', + status: 'CREATED', + inventoryStatus: 'none', + idempotencyKey: `idem_${crypto.randomUUID()}`, + fulfillmentMode: 'intl', + quoteStatus: 'none', + itemsSubtotalMinor: totalAmountMinor, + } as any); + + await db.insert(orderItems).values({ + id: crypto.randomUUID(), + orderId, + productId, + selectedSize: '', + selectedColor: '', + quantity, + unitPriceMinor: 1000, + lineTotalMinor: 1000 * quantity, + unitPrice: toDbMoney(1000), + lineTotal: toDbMoney(1000 * quantity), + productTitle: 'Phase 2 INTL Product', + productSlug: 'phase2-intl', + productSku: null, + } as any); + + return { orderId, productId }; +} + +async function cleanupSeed(seed: Seeded) { + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, seed.orderId)); + await db.delete(orders).where(eq(orders.id, seed.orderId)); + await db.delete(products).where(eq(products.id, seed.productId)); +} + +describe.sequential('intl quote domain (phase 2)', () => { + it('quote offer rejects version conflict', async () => { + const seed = await seedIntlOrder(); + try { + await requestIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + }); + + await expect( + offerIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 2, + currency: 'USD', + shippingQuoteMinor: 500, + }) + ).rejects.toMatchObject({ + code: 'QUOTE_VERSION_CONFLICT', + }); + } finally { + await cleanupSeed(seed); + } + }); + + it('accept rejects stale quote version', async () => { + const seed = await seedIntlOrder(); + try { + await requestIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + }); + await offerIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + currency: 'USD', + shippingQuoteMinor: 500, + }); + await declineIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + }); + await offerIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 2, + currency: 'USD', + shippingQuoteMinor: 650, + }); + + await expect( + acceptIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + }) + ).rejects.toMatchObject({ + code: 'QUOTE_VERSION_CONFLICT', + }); + } finally { + await cleanupSeed(seed); + } + }); + + it('accept rejects expired quote and projects status to expired', async () => { + const seed = await seedIntlOrder(); + try { + await requestIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + }); + await offerIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + currency: 'USD', + shippingQuoteMinor: 500, + }); + + await db + .update(shippingQuotes) + .set({ + expiresAt: new Date(Date.now() - 60_000), + updatedAt: new Date(), + }) + .where( + eq(shippingQuotes.orderId, seed.orderId) + ); + + await expect( + acceptIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + }) + ).rejects.toMatchObject({ + code: 'QUOTE_EXPIRED', + }); + + const [orderRow] = await db + .select({ quoteStatus: orders.quoteStatus }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + const [quoteRow] = await db + .select({ status: shippingQuotes.status }) + .from(shippingQuotes) + .where(eq(shippingQuotes.orderId, seed.orderId)) + .limit(1); + + expect(orderRow?.quoteStatus).toBe('expired'); + expect(quoteRow?.status).toBe('expired'); + } finally { + await cleanupSeed(seed); + } + }); + + it('accept reserves inventory and sets accepted payment deadline', async () => { + const seed = await seedIntlOrder({ stock: 5, quantity: 2, totalAmountMinor: 2000 }); + try { + await requestIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + }); + await offerIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + currency: 'USD', + shippingQuoteMinor: 700, + }); + + const accepted = await acceptIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + }); + + expect(accepted.quoteStatus).toBe('accepted'); + expect(accepted.changed).toBe(true); + expect(accepted.totalAmountMinor).toBe(2700); + expect(accepted.paymentDeadlineAt).toBeInstanceOf(Date); + + const [orderRow] = await db + .select({ + quoteStatus: orders.quoteStatus, + inventoryStatus: orders.inventoryStatus, + quotePaymentDeadlineAt: orders.quotePaymentDeadlineAt, + totalAmountMinor: orders.totalAmountMinor, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + const [productRow] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, seed.productId)) + .limit(1); + + expect(orderRow?.quoteStatus).toBe('accepted'); + expect(orderRow?.inventoryStatus).toBe('reserved'); + expect(orderRow?.quotePaymentDeadlineAt).toBeTruthy(); + expect(orderRow?.totalAmountMinor).toBe(2700); + expect(productRow?.stock).toBe(3); + } finally { + await cleanupSeed(seed); + } + }); + + it('accept returns QUOTE_STOCK_UNAVAILABLE and sets requires_requote when reserve fails', async () => { + const seed = await seedIntlOrder({ stock: 0, quantity: 1, totalAmountMinor: 1000 }); + try { + await requestIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + }); + await offerIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + currency: 'USD', + shippingQuoteMinor: 500, + }); + + await expect( + acceptIntlQuote({ + orderId: seed.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + }) + ).rejects.toMatchObject>({ + code: 'QUOTE_STOCK_UNAVAILABLE', + }); + + const [orderRow] = await db + .select({ + quoteStatus: orders.quoteStatus, + inventoryStatus: orders.inventoryStatus, + failureCode: orders.failureCode, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + const [quoteRow] = await db + .select({ status: shippingQuotes.status }) + .from(shippingQuotes) + .where(eq(shippingQuotes.orderId, seed.orderId)) + .limit(1); + + expect(orderRow?.quoteStatus).toBe('requires_requote'); + expect(orderRow?.inventoryStatus).toBe('failed'); + expect(orderRow?.failureCode).toBe('QUOTE_STOCK_UNAVAILABLE'); + expect(quoteRow?.status).toBe('requires_requote'); + } finally { + await cleanupSeed(seed); + } + }); + + it('writes canonical quote transition events by default', async () => { + const orderA = await seedIntlOrder(); + const orderB = await seedIntlOrder(); + const orderC = await seedIntlOrder({ stock: 2, quantity: 1, totalAmountMinor: 1000 }); + + try { + await requestIntlQuote({ + orderId: orderA.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + }); + await offerIntlQuote({ + orderId: orderA.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + currency: 'USD', + shippingQuoteMinor: 300, + }); + await declineIntlQuote({ + orderId: orderA.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + }); + + await requestIntlQuote({ + orderId: orderB.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + }); + await offerIntlQuote({ + orderId: orderB.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + currency: 'USD', + shippingQuoteMinor: 450, + }); + await db + .update(shippingQuotes) + .set({ + expiresAt: new Date(Date.now() - 60_000), + updatedAt: new Date(), + }) + .where(eq(shippingQuotes.orderId, orderB.orderId)); + const expiredCount = await sweepExpiredOfferedIntlQuotes({ + batchSize: 10, + }); + expect(expiredCount).toBeGreaterThanOrEqual(1); + + await requestIntlQuote({ + orderId: orderC.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + }); + await offerIntlQuote({ + orderId: orderC.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + currency: 'USD', + shippingQuoteMinor: 550, + }); + await acceptIntlQuote({ + orderId: orderC.orderId, + requestId: `req_${crypto.randomUUID()}`, + actorUserId: null, + version: 1, + }); + await db + .update(orders) + .set({ + quotePaymentDeadlineAt: new Date(Date.now() - 60_000), + updatedAt: new Date(), + }) + .where(eq(orders.id, orderC.orderId)); + const timeoutCount = await sweepAcceptedIntlQuotePaymentTimeouts({ + batchSize: 10, + }); + expect(timeoutCount).toBeGreaterThanOrEqual(1); + + const eventsA = await db + .select({ eventName: shippingEvents.eventName }) + .from(shippingEvents) + .where(eq(shippingEvents.orderId, orderA.orderId)); + expect(eventsA.map(e => e.eventName)).toEqual( + expect.arrayContaining([ + 'quote_requested', + 'quote_offered', + 'quote_declined', + ]) + ); + + const eventsB = await db + .select({ eventName: shippingEvents.eventName }) + .from(shippingEvents) + .where(eq(shippingEvents.orderId, orderB.orderId)); + expect(eventsB.map(e => e.eventName)).toEqual( + expect.arrayContaining(['quote_expired']) + ); + + const eventsC = await db + .select({ eventName: shippingEvents.eventName }) + .from(shippingEvents) + .where(eq(shippingEvents.orderId, orderC.orderId)); + expect(eventsC.map(e => e.eventName)).toEqual( + expect.arrayContaining([ + 'quote_requested', + 'quote_offered', + 'quote_accepted', + 'quote_timeout_requires_requote', + ]) + ); + } finally { + await cleanupSeed(orderA); + await cleanupSeed(orderB); + await cleanupSeed(orderC); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-api-methods.test.ts b/frontend/lib/tests/shop/monobank-api-methods.test.ts index e9c27f7f..96a46490 100644 --- a/frontend/lib/tests/shop/monobank-api-methods.test.ts +++ b/frontend/lib/tests/shop/monobank-api-methods.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resetEnvCache } from '@/lib/env'; const ENV_KEYS = [ - 'DATABASE_URL', 'MONO_MERCHANT_TOKEN', 'PAYMENTS_ENABLED', 'MONO_PUBLIC_KEY', @@ -57,8 +56,6 @@ async function expectPspError( beforeEach(() => { rememberEnv(); - process.env.DATABASE_URL = - process.env.DATABASE_URL ?? 'postgres://user:pass@localhost:5432/dev'; process.env.MONO_MERCHANT_TOKEN = 'test_token'; process.env.PAYMENTS_ENABLED = 'true'; process.env.MONO_API_BASE = 'https://api.example.test'; diff --git a/frontend/lib/tests/shop/monobank-env.test.ts b/frontend/lib/tests/shop/monobank-env.test.ts index fc2bba1a..5e3e4d7d 100644 --- a/frontend/lib/tests/shop/monobank-env.test.ts +++ b/frontend/lib/tests/shop/monobank-env.test.ts @@ -4,7 +4,6 @@ import { resetEnvCache } from '@/lib/env'; import { getMonobankConfig, requireMonobankToken } from '@/lib/env/monobank'; const ENV_KEYS = [ - 'DATABASE_URL', 'MONO_MERCHANT_TOKEN', 'MONO_WEBHOOK_MODE', 'MONO_REFUND_ENABLED', @@ -25,8 +24,6 @@ beforeEach(() => { previousEnv[key] = process.env[key]; delete process.env[key]; } - - process.env.DATABASE_URL = 'https://db.example.test'; resetEnvCache(); }); diff --git a/frontend/lib/tests/shop/monobank-http-client.test.ts b/frontend/lib/tests/shop/monobank-http-client.test.ts index f4eba8de..d72d137c 100644 --- a/frontend/lib/tests/shop/monobank-http-client.test.ts +++ b/frontend/lib/tests/shop/monobank-http-client.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resetEnvCache } from '@/lib/env'; const ENV_KEYS = [ - 'DATABASE_URL', 'MONO_MERCHANT_TOKEN', 'PAYMENTS_ENABLED', 'MONO_PUBLIC_KEY', @@ -38,8 +37,6 @@ function makeResponse(status: number, body: string) { beforeEach(() => { rememberEnv(); - process.env.DATABASE_URL = - process.env.DATABASE_URL ?? 'postgres://user:pass@localhost:5432/dev'; process.env.MONO_MERCHANT_TOKEN = 'test_token'; process.env.PAYMENTS_ENABLED = 'true'; process.env.MONO_API_BASE = 'https://api.example.test'; diff --git a/frontend/lib/tests/shop/monobank-payments-disabled.test.ts b/frontend/lib/tests/shop/monobank-payments-disabled.test.ts index c5c06c8c..e1967601 100644 --- a/frontend/lib/tests/shop/monobank-payments-disabled.test.ts +++ b/frontend/lib/tests/shop/monobank-payments-disabled.test.ts @@ -29,7 +29,6 @@ const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; const __prevMonoMerchantToken = process.env.MONO_MERCHANT_TOKEN; const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; const __prevAppOrigin = process.env.APP_ORIGIN; -const __prevDatabaseUrl = process.env.DATABASE_URL; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; @@ -38,9 +37,6 @@ beforeAll(() => { process.env.APP_ORIGIN = 'http://localhost:3000'; process.env.SHOP_STATUS_TOKEN_SECRET = 'test_status_token_secret_test_status_token_secret'; - if (!process.env.DATABASE_URL && __prevDatabaseUrl) { - process.env.DATABASE_URL = __prevDatabaseUrl; - } resetEnvCache(); }); @@ -62,9 +58,6 @@ afterAll(() => { if (__prevStatusSecret === undefined) delete process.env.SHOP_STATUS_TOKEN_SECRET; else process.env.SHOP_STATUS_TOKEN_SECRET = __prevStatusSecret; - - if (__prevDatabaseUrl === undefined) delete process.env.DATABASE_URL; - else process.env.DATABASE_URL = __prevDatabaseUrl; resetEnvCache(); }); diff --git a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts index 3f1e8b6b..b1b496cb 100644 --- a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts +++ b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts @@ -7,6 +7,7 @@ import { db } from '@/db'; import { monobankEvents, orders, + paymentEvents, paymentAttempts, shippingShipments, } from '@/db/schema'; @@ -90,6 +91,13 @@ async function cleanup(orderId: string, invoiceId: string) { await db .delete(shippingShipments) .where(eq(shippingShipments.orderId, orderId)); + try { + await db.delete(paymentEvents).where(eq(paymentEvents.orderId, orderId)); + } catch (error) { + const code = (error as { code?: string } | null)?.code; + // 42P01 = undefined_table (migration not applied in local DB yet). + if (code !== '42P01') throw error; + } await db .delete(monobankEvents) .where(eq(monobankEvents.invoiceId, invoiceId)); @@ -144,7 +152,7 @@ describe.sequential('monobank webhook apply (persist-first)', () => { await cleanup(orderId, invoiceId); } }, 15000); - it('dedupes identical events and applies once', async () => { + it('dedupes identical events, applies once, and writes canonical payment event', async () => { const invoiceId = `inv_${crypto.randomUUID()}`; const { orderId } = await insertOrderAndAttempt({ invoiceId, @@ -182,6 +190,17 @@ describe.sequential('monobank webhook apply (persist-first)', () => { .from(monobankEvents) .where(eq(monobankEvents.invoiceId, invoiceId)); expect(events.length).toBe(1); + const canonical = await db + .select({ + id: paymentEvents.id, + eventName: paymentEvents.eventName, + eventRef: paymentEvents.eventRef, + }) + .from(paymentEvents) + .where(eq(paymentEvents.orderId, orderId)); + expect(canonical.length).toBe(1); + expect(canonical[0]?.eventName).toBe('paid_applied'); + expect(canonical[0]?.eventRef).toBe(events[0]?.id ?? null); const [order] = await db .select({ diff --git a/frontend/lib/tests/shop/monobank-webhook-crypto.test.ts b/frontend/lib/tests/shop/monobank-webhook-crypto.test.ts index 9380116f..f88a2e1c 100644 --- a/frontend/lib/tests/shop/monobank-webhook-crypto.test.ts +++ b/frontend/lib/tests/shop/monobank-webhook-crypto.test.ts @@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resetEnvCache } from '@/lib/env'; const ENV_KEYS = [ - 'DATABASE_URL', 'MONO_MERCHANT_TOKEN', 'PAYMENTS_ENABLED', 'MONO_PUBLIC_KEY', @@ -40,8 +39,6 @@ function makeResponse(body: string) { beforeEach(() => { rememberEnv(); - process.env.DATABASE_URL = - process.env.DATABASE_URL ?? 'postgres://user:pass@localhost:5432/dev'; process.env.MONO_MERCHANT_TOKEN = 'test_token'; process.env.PAYMENTS_ENABLED = 'true'; process.env.MONO_API_BASE = 'https://api.example.test'; diff --git a/frontend/lib/tests/shop/monobank-webhook-rate-limit-policy.test.ts b/frontend/lib/tests/shop/monobank-webhook-rate-limit-policy.test.ts index 203cea1a..d7c0d0b0 100644 --- a/frontend/lib/tests/shop/monobank-webhook-rate-limit-policy.test.ts +++ b/frontend/lib/tests/shop/monobank-webhook-rate-limit-policy.test.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InvalidPayloadError } from '@/lib/services/errors'; + const enforceRateLimitMock = vi.fn(async (..._args: any[]) => ({ ok: false, retryAfterSeconds: 12, @@ -141,4 +143,49 @@ describe('monobank webhook rate limit policy', () => { expect(verifyWebhookSignatureWithRefreshMock).not.toHaveBeenCalled(); expect(handleMonobankWebhookMock).not.toHaveBeenCalled(); }); + + it('returns 503 + Retry-After when signed apply fails with transient error', async () => { + verifyWebhookSignatureWithRefreshMock.mockResolvedValue(true); + handleMonobankWebhookMock.mockRejectedValueOnce(new Error('DB_TEMP_FAIL')); + + const req = makeReq( + JSON.stringify({ + invoiceId: 'inv_123', + status: 'success', + }), + true + ); + + const res = await POST(req); + const json: any = await res.json(); + + expect(res.status).toBe(503); + expect(res.headers.get('Retry-After')).toBe('10'); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(json.code).toBe('WEBHOOK_RETRYABLE'); + expect(json.retryAfterSeconds).toBe(10); + }); + + it('acknowledges with 200 when signed apply fails with invalid payload (non-retryable)', async () => { + verifyWebhookSignatureWithRefreshMock.mockResolvedValue(true); + handleMonobankWebhookMock.mockRejectedValueOnce( + new InvalidPayloadError('Invalid webhook payload', { + code: 'INVALID_PAYLOAD', + }) + ); + + const req = makeReq( + JSON.stringify({ + invoiceId: 'inv_123', + status: 'success', + }), + true + ); + + const res = await POST(req); + const json: any = await res.json(); + + expect(res.status).toBe(200); + expect(json.ok).toBe(true); + }); }); diff --git a/frontend/lib/tests/shop/monobank-webhook-retry-classifier.test.ts b/frontend/lib/tests/shop/monobank-webhook-retry-classifier.test.ts new file mode 100644 index 00000000..d07026b4 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-retry-classifier.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { InvalidPayloadError } from '@/lib/services/errors'; +import { + getMonobankApplyErrorCode, + isRetryableApplyError, +} from '@/lib/services/orders/monobank-retry'; + +describe('monobank webhook retry classifier', () => { + it('InvalidPayloadError is non-retryable', () => { + const err = new InvalidPayloadError('bad payload', { + code: 'INVALID_PAYLOAD', + }); + expect(isRetryableApplyError(err)).toBe(false); + }); + + it('ORDER_NOT_FOUND code is non-retryable', () => { + const err = { code: 'ORDER_NOT_FOUND' }; + expect(isRetryableApplyError(err)).toBe(false); + }); + + it('known code outside transient whitelist is non-retryable (fail-closed)', () => { + const err = { code: 'SOME_UNKNOWN_KNOWN_CODE' }; + expect(isRetryableApplyError(err)).toBe(false); + }); + + it('error without code is retryable', () => { + const err = new Error('temporary failure'); + expect(getMonobankApplyErrorCode(err)).toBeNull(); + expect(isRetryableApplyError(err)).toBe(true); + }); + + it('transient whitelist code is retryable', () => { + const err = { code: 'PSP_TIMEOUT' }; + expect(isRetryableApplyError(err)).toBe(true); + }); +}); + diff --git a/frontend/lib/tests/shop/monobank-webhook-signature-verify.test.ts b/frontend/lib/tests/shop/monobank-webhook-signature-verify.test.ts index e8965261..de11daa6 100644 --- a/frontend/lib/tests/shop/monobank-webhook-signature-verify.test.ts +++ b/frontend/lib/tests/shop/monobank-webhook-signature-verify.test.ts @@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resetEnvCache } from '@/lib/env'; const ENV_KEYS = [ - 'DATABASE_URL', 'MONO_MERCHANT_TOKEN', 'PAYMENTS_ENABLED', 'MONO_PUBLIC_KEY', @@ -39,8 +38,6 @@ function makeOkResponse(body: string) { beforeEach(() => { rememberEnv(); - process.env.DATABASE_URL = - process.env.DATABASE_URL ?? 'postgres://user:pass@localhost:5432/dev'; process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; process.env.PAYMENTS_ENABLED = 'true'; process.env.MONO_API_BASE = 'https://api.example.test'; diff --git a/frontend/lib/tests/shop/notifications-projector-phase3.test.ts b/frontend/lib/tests/shop/notifications-projector-phase3.test.ts new file mode 100644 index 00000000..6783f815 --- /dev/null +++ b/frontend/lib/tests/shop/notifications-projector-phase3.test.ts @@ -0,0 +1,171 @@ +import crypto from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db'; +import { + notificationOutbox, + orders, + paymentEvents, + shippingEvents, +} from '@/db/schema'; +import { runNotificationOutboxProjector } from '@/lib/services/shop/notifications/projector'; +import { toDbMoney } from '@/lib/shop/money'; + +async function seedOrder() { + const orderId = crypto.randomUUID(); + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 2000, + totalAmount: toDbMoney(2000), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'pending', + status: 'CREATED', + inventoryStatus: 'none', + idempotencyKey: `phase3-notify-${orderId}`, + } as any); + return orderId; +} + +async function cleanupOrder(orderId: string) { + await db.delete(orders).where(eq(orders.id, orderId)); +} + +function makeDedupe(prefix: string) { + return `${prefix}:${crypto.randomUUID()}`; +} + +describe.sequential('notifications projector phase 3', () => { + it('dedupes projected outbox rows for the same canonical event', async () => { + const orderId = await seedOrder(); + const canonicalEventId = crypto.randomUUID(); + try { + await db.insert(shippingEvents).values({ + id: canonicalEventId, + orderId, + provider: 'intl_quote', + eventName: 'quote_offered', + eventSource: 'test', + eventRef: `evt_${crypto.randomUUID()}`, + statusFrom: 'requested', + statusTo: 'offered', + trackingNumber: null, + payload: {}, + dedupeKey: makeDedupe('shipping'), + occurredAt: new Date(), + } as any); + + const first = await runNotificationOutboxProjector({ limit: 20 }); + const second = await runNotificationOutboxProjector({ limit: 20 }); + + expect(first.inserted).toBeGreaterThanOrEqual(1); + expect(second.inserted).toBe(0); + + const rows = await db + .select({ + id: notificationOutbox.id, + templateKey: notificationOutbox.templateKey, + sourceEventId: notificationOutbox.sourceEventId, + }) + .from(notificationOutbox) + .where( + and( + eq(notificationOutbox.orderId, orderId), + eq(notificationOutbox.sourceEventId, canonicalEventId) + ) + ); + + expect(rows.length).toBe(1); + expect(rows[0]?.templateKey).toBe('intl_quote_offered'); + } finally { + await cleanupOrder(orderId); + } + }); + + it('covers required INTL/payment/shipment/refund template mapping', async () => { + const orderId = await seedOrder(); + try { + const shippingEventNames = [ + 'quote_requested', + 'quote_offered', + 'quote_accepted', + 'quote_declined', + 'quote_expired', + 'shipment_created', + ]; + for (const eventName of shippingEventNames) { + await db.insert(shippingEvents).values({ + id: crypto.randomUUID(), + orderId, + provider: eventName.startsWith('quote_') ? 'intl_quote' : 'nova_poshta', + eventName, + eventSource: 'test_mapping', + eventRef: `evt_${crypto.randomUUID()}`, + statusFrom: null, + statusTo: null, + trackingNumber: null, + payload: {}, + dedupeKey: makeDedupe('shipping'), + occurredAt: new Date(), + } as any); + } + + await db.insert(paymentEvents).values([ + { + id: crypto.randomUUID(), + orderId, + provider: 'stripe', + eventName: 'paid_applied', + eventSource: 'test_mapping', + eventRef: `evt_${crypto.randomUUID()}`, + amountMinor: 2000, + currency: 'USD', + payload: {}, + dedupeKey: makeDedupe('payment'), + occurredAt: new Date(), + } as any, + { + id: crypto.randomUUID(), + orderId, + provider: 'stripe', + eventName: 'refund_applied', + eventSource: 'test_mapping', + eventRef: `evt_${crypto.randomUUID()}`, + amountMinor: 2000, + currency: 'USD', + payload: {}, + dedupeKey: makeDedupe('payment'), + occurredAt: new Date(), + } as any, + ]); + + const projected = await runNotificationOutboxProjector({ limit: 100 }); + expect(projected.inserted).toBeGreaterThanOrEqual(8); + + const rows = await db + .select({ + templateKey: notificationOutbox.templateKey, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)); + + const templateKeys = rows.map(row => row.templateKey); + expect(templateKeys).toEqual( + expect.arrayContaining([ + 'intl_quote_requested', + 'intl_quote_offered', + 'intl_quote_accepted', + 'intl_quote_declined', + 'intl_quote_expired', + 'payment_confirmed', + 'shipment_created', + 'refund_processed', + ]) + ); + } finally { + await cleanupOrder(orderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/notifications-worker-phase3.test.ts b/frontend/lib/tests/shop/notifications-worker-phase3.test.ts new file mode 100644 index 00000000..cad45f4f --- /dev/null +++ b/frontend/lib/tests/shop/notifications-worker-phase3.test.ts @@ -0,0 +1,243 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db'; +import { notificationOutbox, orders } from '@/db/schema'; +import { + claimNotificationOutboxBatch, + countRunnableNotificationOutboxRows, + runNotificationOutboxWorker, +} from '@/lib/services/shop/notifications/outbox-worker'; +import { toDbMoney } from '@/lib/shop/money'; + +async function seedOrder() { + const orderId = crypto.randomUUID(); + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1500, + totalAmount: toDbMoney(1500), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'pending', + status: 'CREATED', + inventoryStatus: 'none', + idempotencyKey: `phase3-notify-worker-${orderId}`, + } as any); + return orderId; +} + +async function cleanupOrder(orderId: string) { + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function insertOutboxRow(args: { + orderId: string; + payload?: Record; + maxAttempts?: number; + status?: 'pending' | 'failed' | 'processing'; + attemptCount?: number; + nextAttemptAt?: Date; + leaseOwner?: string | null; + leaseExpiresAt?: Date | null; +}) { + const id = crypto.randomUUID(); + await db.insert(notificationOutbox).values({ + id, + orderId: args.orderId, + channel: 'email', + templateKey: 'intl_quote_offered', + sourceDomain: 'shipping_event', + sourceEventId: crypto.randomUUID(), + payload: args.payload ?? {}, + status: args.status ?? 'pending', + attemptCount: args.attemptCount ?? 0, + maxAttempts: args.maxAttempts ?? 5, + nextAttemptAt: args.nextAttemptAt ?? new Date(), + leaseOwner: args.leaseOwner ?? null, + leaseExpiresAt: args.leaseExpiresAt ?? null, + dedupeKey: `outbox:${crypto.randomUUID()}`, + } as any); + return id; +} + +describe.sequential('notifications worker phase 3', () => { + it('lease contention: two claimers cannot claim the same row', async () => { + const orderId = await seedOrder(); + try { + await insertOutboxRow({ orderId }); + + const first = await claimNotificationOutboxBatch({ + runId: `notify-worker-a-${crypto.randomUUID()}`, + limit: 1, + leaseSeconds: 120, + }); + const second = await claimNotificationOutboxBatch({ + runId: `notify-worker-b-${crypto.randomUUID()}`, + limit: 1, + leaseSeconds: 120, + }); + + expect(first.length).toBe(1); + expect(second.length).toBe(0); + } finally { + await cleanupOrder(orderId); + } + }); + + it('retries transient failures with backoff, then dead-letters after max attempts', async () => { + const orderId = await seedOrder(); + try { + const outboxId = await insertOutboxRow({ + orderId, + payload: { + testMode: { + forceFail: true, + code: 'TEMP_SEND_FAIL', + transient: true, + message: 'temporary send failure', + }, + }, + maxAttempts: 2, + }); + + const runId1 = `notify-worker-${crypto.randomUUID()}`; + const first = await runNotificationOutboxWorker({ + runId: runId1, + limit: 10, + leaseSeconds: 120, + maxAttempts: 2, + baseBackoffSeconds: 5, + }); + + expect(first.claimed).toBe(1); + expect(first.retried).toBe(1); + expect(first.deadLettered).toBe(0); + + const [afterFirst] = await db + .select({ + status: notificationOutbox.status, + attemptCount: notificationOutbox.attemptCount, + nextAttemptAt: notificationOutbox.nextAttemptAt, + lastErrorCode: notificationOutbox.lastErrorCode, + deadLetteredAt: notificationOutbox.deadLetteredAt, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.id, outboxId)) + .limit(1); + + expect(afterFirst?.status).toBe('failed'); + expect(afterFirst?.attemptCount).toBe(1); + expect(afterFirst?.nextAttemptAt).toBeTruthy(); + expect(afterFirst?.lastErrorCode).toBe('TEMP_SEND_FAIL'); + expect(afterFirst?.deadLetteredAt).toBeNull(); + + await db + .update(notificationOutbox) + .set({ + nextAttemptAt: new Date(Date.now() - 60_000), + updatedAt: new Date(), + }) + .where(eq(notificationOutbox.id, outboxId)); + + const runId2 = `notify-worker-${crypto.randomUUID()}`; + const second = await runNotificationOutboxWorker({ + runId: runId2, + limit: 10, + leaseSeconds: 120, + maxAttempts: 2, + baseBackoffSeconds: 5, + }); + + expect(second.claimed).toBe(1); + expect(second.deadLettered).toBe(1); + + const [afterSecond] = await db + .select({ + status: notificationOutbox.status, + attemptCount: notificationOutbox.attemptCount, + nextAttemptAt: notificationOutbox.nextAttemptAt, + deadLetteredAt: notificationOutbox.deadLetteredAt, + leaseOwner: notificationOutbox.leaseOwner, + leaseExpiresAt: notificationOutbox.leaseExpiresAt, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.id, outboxId)) + .limit(1); + + expect(afterSecond?.status).toBe('dead_letter'); + expect(afterSecond?.attemptCount).toBe(2); + expect(afterSecond?.deadLetteredAt).toBeTruthy(); + expect(afterSecond?.leaseOwner).toBeNull(); + expect(afterSecond?.leaseExpiresAt).toBeNull(); + expect(afterSecond?.nextAttemptAt).toBeTruthy(); + } finally { + await cleanupOrder(orderId); + } + }); + + it('reclaims stuck processing rows with expired lease and processes them', async () => { + const orderId = await seedOrder(); + const expiredLeaseAt = new Date(Date.now() - 60_000); + try { + const outboxId = await insertOutboxRow({ + orderId, + status: 'processing', + attemptCount: 0, + nextAttemptAt: new Date(Date.now() - 60_000), + leaseOwner: 'old-worker', + leaseExpiresAt: expiredLeaseAt, + payload: { + testMode: { + forceFail: true, + code: 'TEMP_SEND_FAIL', + transient: true, + message: 'temporary send failure', + }, + }, + maxAttempts: 5, + }); + + const runnableBefore = await countRunnableNotificationOutboxRows(); + expect(runnableBefore).toBeGreaterThanOrEqual(1); + + const runId = `notify-worker-${crypto.randomUUID()}`; + const result = await runNotificationOutboxWorker({ + runId, + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + + expect(result.claimed).toBe(1); + expect(result.processed).toBe(1); + expect(result.retried).toBe(1); + + const [row] = await db + .select({ + status: notificationOutbox.status, + attemptCount: notificationOutbox.attemptCount, + leaseOwner: notificationOutbox.leaseOwner, + leaseExpiresAt: notificationOutbox.leaseExpiresAt, + lastErrorCode: notificationOutbox.lastErrorCode, + nextAttemptAt: notificationOutbox.nextAttemptAt, + updatedAt: notificationOutbox.updatedAt, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.id, outboxId)) + .limit(1); + + expect(row?.status).toBe('failed'); + expect(row?.attemptCount).toBe(1); + expect(row?.leaseOwner).toBeNull(); + expect(row?.leaseExpiresAt).toBeNull(); + expect(row?.lastErrorCode).toBe('TEMP_SEND_FAIL'); + expect(row?.updatedAt.getTime()).toBeGreaterThan(expiredLeaseAt.getTime()); + expect(row?.nextAttemptAt.getTime()).toBeGreaterThan(Date.now() - 1000); + } finally { + await cleanupOrder(orderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/notifications-worker-transport-phase3.test.ts b/frontend/lib/tests/shop/notifications-worker-transport-phase3.test.ts new file mode 100644 index 00000000..d18b74bf --- /dev/null +++ b/frontend/lib/tests/shop/notifications-worker-transport-phase3.test.ts @@ -0,0 +1,176 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendShopNotificationEmailMock = vi.fn(); + +vi.mock('@/lib/services/shop/notifications/transport', () => ({ + sendShopNotificationEmail: (...args: any[]) => + sendShopNotificationEmailMock(...args), + ShopNotificationTransportError: class ShopNotificationTransportError extends Error { + code: string; + transient: boolean; + + constructor(code: string, message: string, transient: boolean) { + super(message); + this.name = 'ShopNotificationTransportError'; + this.code = code; + this.transient = transient; + } + }, +})); + +import { db } from '@/db'; +import { notificationOutbox, orderShipping, orders } from '@/db/schema'; +import { runNotificationOutboxWorker } from '@/lib/services/shop/notifications/outbox-worker'; +import { toDbMoney } from '@/lib/shop/money'; + +async function seedOrder() { + const orderId = crypto.randomUUID(); + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1500, + totalAmount: toDbMoney(1500), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'pending', + status: 'CREATED', + inventoryStatus: 'none', + idempotencyKey: `phase3-notify-transport-${orderId}`, + } as any); + return orderId; +} + +async function attachRecipientEmail(orderId: string, email: string) { + await db.insert(orderShipping).values({ + orderId, + shippingAddress: { + recipient: { + fullName: 'Test Buyer', + email, + }, + }, + } as any); +} + +async function cleanupOrder(orderId: string) { + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function insertOutboxRow(orderId: string) { + const id = crypto.randomUUID(); + await db.insert(notificationOutbox).values({ + id, + orderId, + channel: 'email', + templateKey: 'intl_quote_offered', + sourceDomain: 'shipping_event', + sourceEventId: crypto.randomUUID(), + payload: { + canonicalEventName: 'quote_offered', + canonicalEventSource: 'unit_test', + }, + status: 'pending', + attemptCount: 0, + maxAttempts: 5, + nextAttemptAt: new Date(), + dedupeKey: `outbox:${crypto.randomUUID()}`, + } as any); + return id; +} + +describe.sequential('notifications worker transport phase 3', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('marks outbox row as sent only when transport succeeds', async () => { + sendShopNotificationEmailMock.mockResolvedValue({ messageId: 'msg-test-1' }); + + const orderId = await seedOrder(); + try { + await attachRecipientEmail(orderId, 'buyer@example.test'); + const outboxId = await insertOutboxRow(orderId); + + const result = await runNotificationOutboxWorker({ + runId: `notify-worker-${crypto.randomUUID()}`, + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + + expect(result.claimed).toBe(1); + expect(result.sent).toBe(1); + expect(result.retried).toBe(0); + expect(result.deadLettered).toBe(0); + + const [row] = await db + .select({ + status: notificationOutbox.status, + attemptCount: notificationOutbox.attemptCount, + sentAt: notificationOutbox.sentAt, + lastErrorCode: notificationOutbox.lastErrorCode, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.id, outboxId)) + .limit(1); + + expect(row?.status).toBe('sent'); + expect(row?.attemptCount).toBe(1); + expect(row?.sentAt).toBeTruthy(); + expect(row?.lastErrorCode).toBeNull(); + + expect(sendShopNotificationEmailMock).toHaveBeenCalledTimes(1); + expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'buyer@example.test', + }) + ); + } finally { + await cleanupOrder(orderId); + } + }); + + it('dead-letters immediately when recipient email is missing', async () => { + sendShopNotificationEmailMock.mockResolvedValue({ messageId: 'msg-test-2' }); + + const orderId = await seedOrder(); + try { + const outboxId = await insertOutboxRow(orderId); + + const result = await runNotificationOutboxWorker({ + runId: `notify-worker-${crypto.randomUUID()}`, + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + + expect(result.claimed).toBe(1); + expect(result.sent).toBe(0); + expect(result.retried).toBe(0); + expect(result.deadLettered).toBe(1); + + const [row] = await db + .select({ + status: notificationOutbox.status, + attemptCount: notificationOutbox.attemptCount, + deadLetteredAt: notificationOutbox.deadLetteredAt, + lastErrorCode: notificationOutbox.lastErrorCode, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.id, outboxId)) + .limit(1); + + expect(row?.status).toBe('dead_letter'); + expect(row?.attemptCount).toBe(1); + expect(row?.deadLetteredAt).toBeTruthy(); + expect(row?.lastErrorCode).toBe('NOTIFICATION_RECIPIENT_MISSING'); + expect(sendShopNotificationEmailMock).not.toHaveBeenCalled(); + } finally { + await cleanupOrder(orderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts b/frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts index 2fb311c2..38606c5d 100644 --- a/frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts +++ b/frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts @@ -8,7 +8,6 @@ import { } from '@/lib/services/shop/shipping/nova-poshta-client'; function stubRequiredNpEnv() { - vi.stubEnv('DATABASE_URL', 'https://example.com/db'); vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); vi.stubEnv('NP_API_BASE', 'https://np.invalid/v2.0/json/'); diff --git a/frontend/lib/tests/shop/order-payment-init-intl-gate-phase2.test.ts b/frontend/lib/tests/shop/order-payment-init-intl-gate-phase2.test.ts new file mode 100644 index 00000000..ad791dae --- /dev/null +++ b/frontend/lib/tests/shop/order-payment-init-intl-gate-phase2.test.ts @@ -0,0 +1,197 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders, paymentAttempts, shippingQuotes } from '@/db/schema'; +import { toDbMoney } from '@/lib/shop/money'; +import { orderPaymentInitPayloadSchema } from '@/lib/validation/shop'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue({ + id: `admin_${crypto.randomUUID()}`, + role: 'admin', + }), +})); + +async function cleanupOrder(orderId: string) { + await db.delete(shippingQuotes).where(eq(shippingQuotes.orderId, orderId)); + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function insertIntlOrderForInit(args: { + quoteStatus: 'requested' | 'accepted'; + inventoryStatus: + | 'none' + | 'reserving' + | 'reserved' + | 'release_pending' + | 'released' + | 'failed'; + quoteVersion?: number | null; + deadlineMinutesFromNow?: number | null; + withAcceptedQuoteRow?: boolean; +}) { + const orderId = crypto.randomUUID(); + const now = Date.now(); + const deadline = + args.deadlineMinutesFromNow == null + ? null + : new Date(now + args.deadlineMinutesFromNow * 60 * 1000); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: args.inventoryStatus, + idempotencyKey: `idem_${crypto.randomUUID()}`, + fulfillmentMode: 'intl', + quoteStatus: args.quoteStatus, + quoteVersion: args.quoteVersion ?? null, + itemsSubtotalMinor: 1000, + shippingQuoteMinor: args.quoteStatus === 'accepted' ? 200 : null, + quoteAcceptedAt: args.quoteStatus === 'accepted' ? new Date() : null, + quotePaymentDeadlineAt: deadline, + } as any); + + if (args.withAcceptedQuoteRow && args.quoteVersion) { + await db.insert(shippingQuotes).values({ + id: crypto.randomUUID(), + orderId, + version: args.quoteVersion, + status: 'accepted', + currency: 'USD', + shippingQuoteMinor: 200, + offeredBy: null, + offeredAt: new Date(Date.now() - 2 * 60 * 1000), + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + acceptedAt: new Date(), + declinedAt: null, + payload: {}, + createdAt: new Date(), + updatedAt: new Date(), + } as any); + } + + return orderId; +} + +function makeInitRequest(orderId: string, body: unknown = {}) { + return new NextRequest( + new Request(`http://localhost/api/shop/orders/${orderId}/payment/init`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'http://localhost:3000', + }, + body: JSON.stringify(body), + }) + ); +} + +describe.sequential('order payment init intl gate (phase 2)', () => { + it('blocks payment init when quote is not accepted', async () => { + const orderId = await insertIntlOrderForInit({ + quoteStatus: 'requested', + inventoryStatus: 'reserved', + quoteVersion: 1, + deadlineMinutesFromNow: 10, + withAcceptedQuoteRow: false, + }); + + try { + const { POST } = await import( + '@/app/api/shop/orders/[id]/payment/init/route' + ); + const res = await POST(makeInitRequest(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(409); + const json: any = await res.json(); + expect(json.code).toBe('QUOTE_NOT_ACCEPTED'); + } finally { + await cleanupOrder(orderId); + } + }); + + it('blocks payment init when accepted quote deadline has passed', async () => { + const orderId = await insertIntlOrderForInit({ + quoteStatus: 'accepted', + inventoryStatus: 'reserved', + quoteVersion: 1, + deadlineMinutesFromNow: -5, + withAcceptedQuoteRow: true, + }); + + try { + const { POST } = await import( + '@/app/api/shop/orders/[id]/payment/init/route' + ); + const res = await POST(makeInitRequest(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(410); + const json: any = await res.json(); + expect(json.code).toBe('QUOTE_PAYMENT_WINDOW_EXPIRED'); + } finally { + await cleanupOrder(orderId); + } + }); + + it('blocks payment init when inventory is not reserved', async () => { + const orderId = await insertIntlOrderForInit({ + quoteStatus: 'accepted', + inventoryStatus: 'none', + quoteVersion: 1, + deadlineMinutesFromNow: 10, + withAcceptedQuoteRow: true, + }); + + try { + const { POST } = await import( + '@/app/api/shop/orders/[id]/payment/init/route' + ); + const res = await POST(makeInitRequest(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(409); + const json: any = await res.json(); + expect(json.code).toBe('QUOTE_INVENTORY_NOT_RESERVED'); + } finally { + await cleanupOrder(orderId); + } + }); + + it('provider schema rejects monobank for payment init payload', async () => { + const parsed = orderPaymentInitPayloadSchema.safeParse({ + provider: 'monobank', + }); + expect(parsed.success).toBe(false); + }); + + it('database constraint rejects monobank provider on intl orders', async () => { + await expect( + db.insert(orders).values({ + id: crypto.randomUUID(), + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: 'monobank', + paymentStatus: 'pending', + status: 'CREATED', + inventoryStatus: 'none', + idempotencyKey: `idem_${crypto.randomUUID()}`, + fulfillmentMode: 'intl', + quoteStatus: 'none', + itemsSubtotalMinor: 1000, + } as any) + ).rejects.toThrow(/constraint|intl.*monobank|monobank.*intl/i); + }); +}); diff --git a/frontend/lib/tests/shop/order-payment-init-token-scope-phase7.test.ts b/frontend/lib/tests/shop/order-payment-init-token-scope-phase7.test.ts new file mode 100644 index 00000000..68c52735 --- /dev/null +++ b/frontend/lib/tests/shop/order-payment-init-token-scope-phase7.test.ts @@ -0,0 +1,138 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders } from '@/db/schema'; +import { toDbMoney } from '@/lib/shop/money'; +import { createStatusToken } from '@/lib/shop/status-token'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), + }; +}); + +const ensureStripePaymentIntentForOrderMock = vi.fn(async (..._args: unknown[]) => ({ + paymentIntentId: `pi_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`, + clientSecret: `cs_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`, + attemptId: crypto.randomUUID(), + attemptNumber: 1, +})); + +vi.mock('@/lib/services/orders/payment-attempts', () => ({ + PaymentAttemptsExhaustedError: class PaymentAttemptsExhaustedError extends Error { + code = 'PAYMENT_ATTEMPTS_EXHAUSTED' as const; + orderId: string | null = null; + provider: 'stripe' = 'stripe'; + }, + ensureStripePaymentIntentForOrder: (...args: unknown[]) => + ensureStripePaymentIntentForOrderMock(...args), +})); + +vi.mock('@/lib/services/shop/quotes', () => ({ + assertIntlPaymentInitAllowed: vi.fn(async () => undefined), +})); + +const previousStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; + +beforeAll(() => { + process.env.SHOP_STATUS_TOKEN_SECRET = + 'test_status_token_secret_test_status_token_secret'; +}); + +afterAll(() => { + if (previousStatusSecret === undefined) { + delete process.env.SHOP_STATUS_TOKEN_SECRET; + } else { + process.env.SHOP_STATUS_TOKEN_SECRET = previousStatusSecret; + } +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +async function insertOrder(orderId: string) { + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + fulfillmentMode: 'intl', + } as any); +} + +async function deleteOrder(orderId: string) { + await db.delete(orders).where(eq(orders.id, orderId)); +} + +function makeRequest(orderId: string, statusToken?: string) { + const base = `http://localhost/api/shop/orders/${orderId}/payment/init`; + const url = statusToken + ? `${base}?statusToken=${encodeURIComponent(statusToken)}` + : base; + + return new NextRequest(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'http://localhost:3000', + 'x-request-id': 'phase7-payment-init-scope', + }, + body: JSON.stringify({ provider: 'stripe' }), + }); +} + +describe.sequential('order payment init token scope (phase 7)', () => { + it('rejects token without order_payment_init scope and allows scoped token', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + + try { + const { POST } = await import('@/app/api/shop/orders/[id]/payment/init/route'); + + const unscopedToken = createStatusToken({ orderId }); + const unscopedRes = await POST(makeRequest(orderId, unscopedToken), { + params: Promise.resolve({ id: orderId }), + }); + expect(unscopedRes.status).toBe(403); + const unscopedJson: any = await unscopedRes.json(); + expect(unscopedJson.code).toBe('STATUS_TOKEN_SCOPE_FORBIDDEN'); + expect(ensureStripePaymentIntentForOrderMock).not.toHaveBeenCalled(); + + const scopedToken = createStatusToken({ + orderId, + scopes: ['order_payment_init'], + }); + const scopedRes = await POST(makeRequest(orderId, scopedToken), { + params: Promise.resolve({ id: orderId }), + }); + expect(scopedRes.status).toBe(200); + const scopedJson: any = await scopedRes.json(); + expect(scopedJson.success).toBe(true); + expect(scopedJson.orderId).toBe(orderId); + expect(scopedJson.provider).toBe('stripe'); + expect(typeof scopedJson.paymentIntentId).toBe('string'); + expect(typeof scopedJson.clientSecret).toBe('string'); + expect(ensureStripePaymentIntentForOrderMock).toHaveBeenCalledTimes(1); + } finally { + await deleteOrder(orderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/order-status-token.test.ts b/frontend/lib/tests/shop/order-status-token.test.ts index c90d5237..6ce9ede6 100644 --- a/frontend/lib/tests/shop/order-status-token.test.ts +++ b/frontend/lib/tests/shop/order-status-token.test.ts @@ -4,7 +4,7 @@ import { NextRequest } from 'next/server'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; -import { orders, paymentAttempts } from '@/db/schema'; +import { adminAuditLog, orders, paymentAttempts } from '@/db/schema'; import { toDbMoney } from '@/lib/shop/money'; import { createStatusToken } from '@/lib/shop/status-token'; @@ -40,6 +40,7 @@ async function insertOrder(orderId: string) { } async function deleteOrder(orderId: string) { + await db.delete(adminAuditLog).where(eq(adminAuditLog.orderId, orderId)); await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); await db.delete(orders).where(eq(orders.id, orderId)); } @@ -107,14 +108,13 @@ describe('order status token access control', () => { expect(res.status).toBe(200); const json: any = await res.json(); - expect(json.success).toBe(true); - expect(json.order.id).toBe(orderId); - expect(typeof json.order.currency).toBe('string'); - expect(json.order.totalAmountMinor).toBeDefined(); - expect(json.order.paymentProvider).toBeDefined(); - expect(json.order.paymentStatus).toBe('pending'); - expect(typeof json.order.createdAt).toBe('string'); - expect(json.attempt).toBeNull(); + expect(json.id).toBe(orderId); + expect(json.currency).toBe('UAH'); + expect(json.totalAmountMinor).toBe(1000); + expect(json.paymentStatus).toBe('pending'); + expect(typeof json.updatedAt).toBe('string'); + expect(json.order).toBeUndefined(); + expect(json.attempt).toBeUndefined(); const [row] = await db .select({ paymentStatus: orders.paymentStatus }) @@ -127,6 +127,56 @@ describe('order status token access control', () => { } }); + it('enforces status-only scope for token users even when view=full is requested', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + + try { + const token = createStatusToken({ orderId }); + const { GET } = await import('@/app/api/shop/orders/[id]/status/route'); + const req = new NextRequest( + `http://localhost/api/shop/orders/${orderId}/status?view=full&statusToken=${encodeURIComponent( + token + )}` + ); + const res = await GET(req, { params: Promise.resolve({ id: orderId }) }); + expect(res.status).toBe(200); + + const json: any = await res.json(); + expect(json.id).toBe(orderId); + expect(json.paymentStatus).toBe('pending'); + expect(json.order).toBeUndefined(); + expect(json.attempt).toBeUndefined(); + expect(json.success).toBeUndefined(); + } finally { + await deleteOrder(orderId); + } + }); + + it('rejects token without status_lite scope', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + + try { + const token = createStatusToken({ + orderId, + scopes: ['order_payment_init'], + }); + const { GET } = await import('@/app/api/shop/orders/[id]/status/route'); + const req = new NextRequest( + `http://localhost/api/shop/orders/${orderId}/status?statusToken=${encodeURIComponent( + token + )}` + ); + const res = await GET(req, { params: Promise.resolve({ id: orderId }) }); + expect(res.status).toBe(403); + const json: any = await res.json(); + expect(json.code).toBe('STATUS_TOKEN_SCOPE_FORBIDDEN'); + } finally { + await deleteOrder(orderId); + } + }); + it('rejects token for another order', async () => { const orderId = crypto.randomUUID(); const otherOrderId = crypto.randomUUID(); @@ -201,11 +251,9 @@ describe('order status token access control', () => { expect(res.status).toBe(200); const json: any = await res.json(); - expect(json.success).toBe(true); - expect(json.attempt).not.toBeNull(); - expect(json.attempt.status).toBe('active'); - expect(json.attempt.providerRef).toBe('inv_123'); - expect(json.attempt.checkoutUrl).toBe('https://pay.test/inv_123'); + expect(json.id).toBe(orderId); + expect(json.paymentStatus).toBe('pending'); + expect(json.attempt).toBeUndefined(); } finally { await deleteOrder(orderId); } @@ -245,10 +293,48 @@ describe('order status token access control', () => { expect(res.status).toBe(200); const json: any = await res.json(); - expect(json.attempt).not.toBeNull(); - expect(json.attempt.status).toBe('active'); - expect(json.attempt.providerRef).toBe('inv_active'); - expect(json.attempt.checkoutUrl).toBe('https://pay.test/inv_active'); + expect(json.id).toBe(orderId); + expect(json.paymentStatus).toBe('pending'); + expect(json.attempt).toBeUndefined(); + } finally { + await deleteOrder(orderId); + } + }); + + it('replay uses deduped token-use audit and records canonical audit entry', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + const token = createStatusToken({ orderId }); + + try { + const { GET } = await import('@/app/api/shop/orders/[id]/status/route'); + const req1 = new NextRequest( + `http://localhost/api/shop/orders/${orderId}/status?statusToken=${encodeURIComponent( + token + )}` + ); + const req2 = new NextRequest( + `http://localhost/api/shop/orders/${orderId}/status?statusToken=${encodeURIComponent( + token + )}` + ); + + const res1 = await GET(req1, { params: Promise.resolve({ id: orderId }) }); + const res2 = await GET(req2, { params: Promise.resolve({ id: orderId }) }); + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + + const rows = await db + .select({ + action: adminAuditLog.action, + orderId: adminAuditLog.orderId, + }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, orderId)); + + const tokenUseRows = rows.filter(r => r.action === 'guest_status_token.used'); + expect(tokenUseRows.length).toBe(1); + expect(tokenUseRows[0]?.orderId).toBe(orderId); } finally { await deleteOrder(orderId); } diff --git a/frontend/lib/tests/shop/orders-status-ownership.test.ts b/frontend/lib/tests/shop/orders-status-ownership.test.ts index d261ea4e..3896ac8a 100644 --- a/frontend/lib/tests/shop/orders-status-ownership.test.ts +++ b/frontend/lib/tests/shop/orders-status-ownership.test.ts @@ -391,22 +391,21 @@ describe.sequential('orders/[id]/status ownership (J)', () => { expect(res.status).toBe(200); expect(json).toBeTruthy(); - expect((json as any).success).toBe(true); - expect((json as any).order).toBeTruthy(); + expect((json as any).success).toBeUndefined(); + expect((json as any).order).toBeUndefined(); + expect((json as any).attempt).toBeUndefined(); + expect((json as any).paymentStatus).toBeTruthy(); + expect((json as any).totalAmountMinor).toBeGreaterThan(0); const returnedId = - (json as any).orderId ?? (json as any).id ?? (json as any).order?.id; + (json as any).orderId ?? (json as any).id; if (!returnedId) { const topKeys = json && typeof json === 'object' ? Object.keys(json) : []; - const orderKeys = - (json as any)?.order && typeof (json as any).order === 'object' - ? Object.keys((json as any).order) - : []; throw new Error( `[ownership-test] status 200 response missing order identifier. ` + - `topKeys=${JSON.stringify(topKeys)} orderKeys=${JSON.stringify(orderKeys)}` + `topKeys=${JSON.stringify(topKeys)}` ); } diff --git a/frontend/lib/tests/shop/product-prices-write-authority-phase8.test.ts b/frontend/lib/tests/shop/product-prices-write-authority-phase8.test.ts new file mode 100644 index 00000000..346d354e --- /dev/null +++ b/frontend/lib/tests/shop/product-prices-write-authority-phase8.test.ts @@ -0,0 +1,191 @@ +import crypto from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { productPrices, products } from '@/db/schema'; +import { updateProduct } from '@/lib/services/products'; +import { toDbMoney } from '@/lib/shop/money'; +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; + +vi.mock('@/lib/cloudinary', () => ({ + uploadProductImageFromFile: vi.fn(async () => ({ + secureUrl: 'https://example.com/test.png', + publicId: 'products/test', + })), + destroyProductImage: vi.fn(async () => {}), +})); + +function uniqueSlug(prefix = 'phase8-price-authority') { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +describe.sequential('product_prices write authority (phase 8)', () => { + const createdProductIds: string[] = []; + + beforeAll(() => { + assertNotProductionDb(); + }); + + afterEach(async () => { + for (const productId of createdProductIds.splice(0)) { + await db + .delete(productPrices) + .where(eq(productPrices.productId, productId)) + .catch(() => undefined); + await db + .delete(products) + .where(eq(products.id, productId)) + .catch(() => undefined); + } + }); + + it('updates USD row in product_prices without mutating legacy products.price fields', async () => { + const [product] = await db + .insert(products) + .values({ + slug: uniqueSlug(), + title: 'Phase8 USD update', + description: null, + imageUrl: 'https://example.com/p8-usd.png', + imagePublicId: null, + price: toDbMoney(1000), + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + sku: null, + }) + .returning(); + + createdProductIds.push(product.id); + + await db.insert(productPrices).values({ + productId: product.id, + currency: 'USD', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + }); + + await updateProduct(product.id, { + prices: [{ currency: 'USD', priceMinor: 2500, originalPriceMinor: null }], + } as any); + + const [legacy] = await db + .select({ + price: products.price, + originalPrice: products.originalPrice, + }) + .from(products) + .where(eq(products.id, product.id)) + .limit(1); + + const [usd] = await db + .select({ + priceMinor: productPrices.priceMinor, + originalPriceMinor: productPrices.originalPriceMinor, + }) + .from(productPrices) + .where( + and( + eq(productPrices.productId, product.id), + eq(productPrices.currency, 'USD') + ) + ) + .limit(1); + + expect(String(legacy.price)).toBe(String(toDbMoney(1000))); + expect(legacy.originalPrice).toBeNull(); + expect(usd.priceMinor).toBe(2500); + expect(usd.originalPriceMinor).toBeNull(); + }); + + it('upserts non-USD row in product_prices without mutating legacy products.price fields', async () => { + const [product] = await db + .insert(products) + .values({ + slug: uniqueSlug(), + title: 'Phase8 UAH upsert', + description: null, + imageUrl: 'https://example.com/p8-uah.png', + imagePublicId: null, + price: toDbMoney(1200), + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + sku: null, + }) + .returning(); + + createdProductIds.push(product.id); + + await db.insert(productPrices).values({ + productId: product.id, + currency: 'USD', + priceMinor: 1200, + originalPriceMinor: null, + price: toDbMoney(1200), + originalPrice: null, + }); + + await updateProduct(product.id, { + prices: [{ currency: 'UAH', priceMinor: 4700, originalPriceMinor: null }], + } as any); + + const [legacy] = await db + .select({ + price: products.price, + originalPrice: products.originalPrice, + }) + .from(products) + .where(eq(products.id, product.id)) + .limit(1); + + const [usd] = await db + .select({ + priceMinor: productPrices.priceMinor, + }) + .from(productPrices) + .where( + and( + eq(productPrices.productId, product.id), + eq(productPrices.currency, 'USD') + ) + ) + .limit(1); + + const [uah] = await db + .select({ + priceMinor: productPrices.priceMinor, + }) + .from(productPrices) + .where( + and( + eq(productPrices.productId, product.id), + eq(productPrices.currency, 'UAH') + ) + ) + .limit(1); + + expect(String(legacy.price)).toBe(String(toDbMoney(1200))); + expect(legacy.originalPrice).toBeNull(); + expect(usd.priceMinor).toBe(1200); + expect(uah.priceMinor).toBe(4700); + }); +}); diff --git a/frontend/lib/tests/shop/returns-composite-fk-phaseE.test.ts b/frontend/lib/tests/shop/returns-composite-fk-phaseE.test.ts new file mode 100644 index 00000000..f20d2b95 --- /dev/null +++ b/frontend/lib/tests/shop/returns-composite-fk-phaseE.test.ts @@ -0,0 +1,75 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db'; +import { orders, returnItems, returnRequests } from '@/db/schema'; +import { toDbMoney } from '@/lib/shop/money'; + +async function createOrder(id: string) { + await db.insert(orders).values({ + id, + totalAmountMinor: 0, + totalAmount: toDbMoney(0), + currency: 'USD', + paymentProvider: 'stripe', + idempotencyKey: `idem_${crypto.randomUUID()}`, + } as any); +} + +describe.sequential('returns composite fk phase E', () => { + it('rejects mismatched return_items.order_id for referenced return_request_id', async () => { + const orderA = crypto.randomUUID(); + const orderB = crypto.randomUUID(); + const returnRequestId = crypto.randomUUID(); + + await createOrder(orderA); + await createOrder(orderB); + + await db.insert(returnRequests).values({ + id: returnRequestId, + orderId: orderA, + currency: 'USD', + idempotencyKey: `rr_${crypto.randomUUID()}`, + } as any); + + try { + let thrown: unknown = null; + + try { + await db.insert(returnItems).values({ + id: crypto.randomUUID(), + returnRequestId, + orderId: orderB, + quantity: 1, + unitPriceMinor: 100, + lineTotalMinor: 100, + currency: 'USD', + idempotencyKey: `ri_${crypto.randomUUID()}`, + } as any); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeTruthy(); + + const rec = thrown as any; + const code = rec?.cause?.code ?? rec?.code; + const constraint = rec?.cause?.constraint ?? rec?.constraint; + + // 23503 = foreign_key_violation + expect(code).toBe('23503'); + expect(constraint).toBe('return_items_return_request_order_fk'); + } finally { + await db + .delete(returnItems) + .where(eq(returnItems.returnRequestId, returnRequestId)); + await db + .delete(returnRequests) + .where(eq(returnRequests.id, returnRequestId)); + await db.delete(orders).where(eq(orders.id, orderA)); + await db.delete(orders).where(eq(orders.id, orderB)); + } + }); +}); diff --git a/frontend/lib/tests/shop/returns-phase4.test.ts b/frontend/lib/tests/shop/returns-phase4.test.ts new file mode 100644 index 00000000..5f3a4171 --- /dev/null +++ b/frontend/lib/tests/shop/returns-phase4.test.ts @@ -0,0 +1,401 @@ +import crypto from 'node:crypto'; + +import { and, eq, sql } from 'drizzle-orm'; +import { describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { + adminAuditLog, + inventoryMoves, + orderItems, + orders, + products, + shippingEvents, + users, +} from '@/db/schema'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { + approveReturnRequest, + createReturnRequest, + receiveReturnRequest, + refundReturnRequest, + rejectReturnRequest, +} from '@/lib/services/shop/returns'; +import { toDbMoney } from '@/lib/shop/money'; + +const createRefundMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/psp/stripe', () => ({ + createRefund: createRefundMock, +})); + +type Seed = { + orderId: string; + productId: string; + userId: string; +}; +async function ensureAdmin(userId: string) { + await db + .insert(users) + .values({ + id: userId, + email: `${userId}@example.test`, + role: 'admin', + name: 'Test Admin', + } as any) + .onConflictDoNothing(); +} +async function ensureUser(userId: string) { + await db + .insert(users) + .values({ + id: userId, + email: `${userId}@example.test`, + role: 'user', + name: 'Test User', + } as any) + .onConflictDoNothing(); +} + +async function seedPaidReservedOrder(args?: { + stockAfterReserve?: number; + qty?: number; + unitPriceMinor?: number; +}) { + const orderId = crypto.randomUUID(); + const productId = crypto.randomUUID(); + const userId = `user_${crypto.randomUUID()}`; + const qty = args?.qty ?? 2; + const unitPriceMinor = args?.unitPriceMinor ?? 1000; + const lineTotalMinor = qty * unitPriceMinor; + const stockAfterReserve = args?.stockAfterReserve ?? 3; + await ensureUser(userId); + await db.insert(products).values({ + id: productId, + slug: `returns-phase4-${crypto.randomUUID()}`, + title: 'Returns test product', + imageUrl: 'https://example.com/p.png', + price: toDbMoney(unitPriceMinor), + currency: 'USD', + stock: stockAfterReserve, + isActive: true, + isFeatured: false, + } as any); + + await db.insert(orders).values({ + id: orderId, + userId, + totalAmountMinor: lineTotalMinor, + totalAmount: toDbMoney(lineTotalMinor), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + paymentIntentId: `pi_${crypto.randomUUID()}`, + pspChargeId: `ch_${crypto.randomUUID()}`, + status: 'PAID', + inventoryStatus: 'reserved', + idempotencyKey: `idem_${crypto.randomUUID()}`, + } as any); + + await db.insert(orderItems).values({ + id: crypto.randomUUID(), + orderId, + productId, + selectedSize: '', + selectedColor: '', + quantity: qty, + unitPriceMinor, + lineTotalMinor, + unitPrice: toDbMoney(unitPriceMinor), + lineTotal: toDbMoney(lineTotalMinor), + productTitle: 'Returns test product', + productSlug: 'returns-test-product', + } as any); + + await db.insert(inventoryMoves).values({ + moveKey: `reserve:${orderId}:${productId}`, + orderId, + productId, + type: 'reserve', + quantity: qty, + } as any); + + return { orderId, productId, userId } satisfies Seed; +} + +async function cleanupSeed(seed: Seed) { + await db.delete(adminAuditLog).where(eq(adminAuditLog.orderId, seed.orderId)); + await db + .delete(shippingEvents) + .where(eq(shippingEvents.orderId, seed.orderId)); + await db + .delete(inventoryMoves) + .where(eq(inventoryMoves.orderId, seed.orderId)); + await db.delete(orderItems).where(eq(orderItems.orderId, seed.orderId)); + await db.delete(orders).where(eq(orders.id, seed.orderId)); + await db.delete(products).where(eq(products.id, seed.productId)); +} + +describe.sequential('returns phase 4', () => { + it('transition matrix allows/forbids correctly', async () => { + const seed = await seedPaidReservedOrder(); + try { + const created = await createReturnRequest({ + orderId: seed.orderId, + actorUserId: seed.userId, + idempotencyKey: `ret_${crypto.randomUUID()}`, + reason: 'size mismatch', + policyRestock: true, + requestId: `req_${crypto.randomUUID()}`, + }); + expect(created.created).toBe(true); + expect(created.request.status).toBe('requested'); + + await ensureAdmin('admin_1'); + + const approved = await approveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_1', + requestId: `req_${crypto.randomUUID()}`, + }); + expect(approved.changed).toBe(true); + expect(approved.row.status).toBe('approved'); + + await expect( + rejectReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_1', + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + code: 'RETURN_TRANSITION_INVALID', + } satisfies Partial); + + const received = await receiveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_1', + requestId: `req_${crypto.randomUUID()}`, + }); + expect(received.changed).toBe(true); + expect(received.row.status).toBe('received'); + + await expect( + approveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_1', + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + code: 'RETURN_TRANSITION_INVALID', + } satisfies Partial); + } finally { + await cleanupSeed(seed); + } + }); + + it('refund is allowed only after receive state', async () => { + const seed = await seedPaidReservedOrder(); + try { + const created = await createReturnRequest({ + orderId: seed.orderId, + actorUserId: seed.userId, + idempotencyKey: `ret_${crypto.randomUUID()}`, + reason: 'wrong size', + policyRestock: true, + requestId: `req_${crypto.randomUUID()}`, + }); + await ensureAdmin('admin_2'); + + await approveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_2', + requestId: `req_${crypto.randomUUID()}`, + }); + + await expect( + refundReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_2', + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + code: 'RETURN_REFUND_STATE_INVALID', + } satisfies Partial); + + await receiveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_2', + requestId: `req_${crypto.randomUUID()}`, + }); + + createRefundMock.mockResolvedValueOnce({ + refundId: `re_${crypto.randomUUID()}`, + status: 'succeeded', + }); + + const refunded = await refundReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_2', + requestId: `req_${crypto.randomUUID()}`, + }); + expect(refunded.changed).toBe(true); + expect(refunded.row.status).toBe('refunded'); + expect(createRefundMock).toHaveBeenCalledTimes(1); + expect(createRefundMock).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: seed.orderId, + amountMinor: created.request.refundAmountMinor, + }) + ); + } finally { + await cleanupSeed(seed); + } + }); + + it('restock path is idempotent on repeated receive', async () => { + const seed = await seedPaidReservedOrder({ stockAfterReserve: 3, qty: 2 }); + try { + const created = await createReturnRequest({ + orderId: seed.orderId, + actorUserId: seed.userId, + idempotencyKey: `ret_${crypto.randomUUID()}`, + reason: 'damaged', + policyRestock: true, + requestId: `req_${crypto.randomUUID()}`, + }); + await ensureAdmin('admin_3'); + + await approveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_3', + requestId: `req_${crypto.randomUUID()}`, + }); + const firstReceive = await receiveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_3', + requestId: `req_${crypto.randomUUID()}`, + }); + expect(firstReceive.changed).toBe(true); + + const [stockAfterFirst] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, seed.productId)) + .limit(1); + expect(stockAfterFirst?.stock).toBe(5); + + const secondReceive = await receiveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_3', + requestId: `req_${crypto.randomUUID()}`, + }); + expect(secondReceive.changed).toBe(false); + + const [stockAfterSecond] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, seed.productId)) + .limit(1); + expect(stockAfterSecond?.stock).toBe(5); + + const releases = await db + .select({ moveKey: inventoryMoves.moveKey }) + .from(inventoryMoves) + .where( + and( + eq(inventoryMoves.orderId, seed.orderId), + eq(inventoryMoves.productId, seed.productId), + eq(inventoryMoves.type, 'release') + ) + ); + expect(releases.length).toBe(1); + } finally { + await cleanupSeed(seed); + } + }); + + it('emits canonical shipping events and admin audit entries for return transitions', async () => { + const seed = await seedPaidReservedOrder(); + try { + const created = await createReturnRequest({ + orderId: seed.orderId, + actorUserId: seed.userId, + idempotencyKey: `ret_${crypto.randomUUID()}`, + reason: 'quality issue', + policyRestock: true, + requestId: `req_${crypto.randomUUID()}`, + }); + await ensureAdmin('admin_4'); + + await approveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_4', + requestId: `req_${crypto.randomUUID()}`, + }); + await receiveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_4', + requestId: `req_${crypto.randomUUID()}`, + }); + + createRefundMock.mockResolvedValueOnce({ + refundId: `re_${crypto.randomUUID()}`, + status: 'succeeded', + }); + await refundReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_4', + requestId: `req_${crypto.randomUUID()}`, + }); + + const events = await db + .select({ + eventName: shippingEvents.eventName, + provider: shippingEvents.provider, + }) + .from(shippingEvents) + .where( + and( + eq(shippingEvents.orderId, seed.orderId), + eq(shippingEvents.provider, 'returns') + ) + ); + + expect(events.map(e => e.eventName)).toEqual( + expect.arrayContaining([ + 'return_requested', + 'return_approved', + 'return_received', + 'return_refunded', + ]) + ); + + const audits = await db + .select({ + action: adminAuditLog.action, + }) + .from(adminAuditLog) + .where( + and( + eq(adminAuditLog.orderId, seed.orderId), + sql`${adminAuditLog.action} like 'return.%'` + ) + ); + + expect(audits.map(a => a.action)).toEqual( + expect.arrayContaining([ + 'return.requested', + 'return.approve', + 'return.receive', + 'return.refund', + ]) + ); + } finally { + await cleanupSeed(seed); + await db.delete(users).where(eq(users.id, seed.userId)); + await db + .delete(users) + .where(sql`${users.id} in ('admin_1','admin_2','admin_3','admin_4')`); + } + }); +}); diff --git a/frontend/lib/tests/shop/returns-route-phase4.test.ts b/frontend/lib/tests/shop/returns-route-phase4.test.ts new file mode 100644 index 00000000..a8cb3772 --- /dev/null +++ b/frontend/lib/tests/shop/returns-route-phase4.test.ts @@ -0,0 +1,197 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orderItems, orders, products, returnRequests, users } from '@/db/schema'; +import { toDbMoney } from '@/lib/shop/money'; + +const getCurrentUserMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: getCurrentUserMock, +})); + +async function ensureUser(userId: string) { + await db + .insert(users) + .values({ + id: userId, + email: `${userId}@example.test`, + role: 'user', + name: 'Test User', + } as any) + .onConflictDoNothing(); +} + +async function seedOwnedOrder(args: { userId: string }) { + const orderId = crypto.randomUUID(); + const productId = crypto.randomUUID(); + await ensureUser(args.userId); + await db.insert(products).values({ + id: productId, + slug: `returns-route-${crypto.randomUUID()}`, + title: 'Returns route test product', + imageUrl: 'https://example.com/p.png', + price: toDbMoney(1200), + currency: 'USD', + stock: 10, + isActive: true, + isFeatured: false, + } as any); + + await db.insert(orders).values({ + id: orderId, + userId: args.userId, + totalAmountMinor: 2400, + totalAmount: toDbMoney(2400), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + paymentIntentId: `pi_${crypto.randomUUID()}`, + status: 'PAID', + inventoryStatus: 'reserved', + idempotencyKey: `idem_${crypto.randomUUID()}`, + } as any); + + await db.insert(orderItems).values([ + { + id: crypto.randomUUID(), + orderId, + productId, + selectedSize: '', + selectedColor: '', + quantity: 1, + unitPriceMinor: 1200, + lineTotalMinor: 1200, + unitPrice: toDbMoney(1200), + lineTotal: toDbMoney(1200), + productTitle: 'Returns route test product', + productSlug: 'returns-route-test-product', + }, + { + id: crypto.randomUUID(), + orderId, + productId, + selectedSize: 'm', + selectedColor: 'black', + quantity: 1, + unitPriceMinor: 1200, + lineTotalMinor: 1200, + unitPrice: toDbMoney(1200), + lineTotal: toDbMoney(1200), + productTitle: 'Returns route test product', + productSlug: 'returns-route-test-product', + }, + ] as any); + + return { orderId, productId }; +} + +async function cleanup(orderId: string, productId: string) { + await db.delete(orderItems).where(eq(orderItems.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); + await db.delete(products).where(eq(products.id, productId)); +} + +describe.sequential('returns customer route phase 4', () => { + beforeEach(() => { + vi.stubEnv('APP_ORIGIN', 'http://localhost:3000'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('create return contract: owner can create and refund amount is server-derived', async () => { + const userId = `user_${crypto.randomUUID()}`; + getCurrentUserMock.mockResolvedValue({ + id: userId, + role: 'user', + }); + + const seed = await seedOwnedOrder({ userId }); + + try { + const { POST } = await import('@/app/api/shop/orders/[id]/returns/route'); + const request = new NextRequest( + `http://localhost:3000/api/shop/orders/${seed.orderId}/returns`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + idempotencyKey: `ret_${crypto.randomUUID()}`, + reason: 'arrived damaged', + policyRestock: true, + }), + } + ); + + const response = await POST(request, { + params: Promise.resolve({ id: seed.orderId }), + }); + + expect(response.status).toBe(201); + const json = await response.json(); + expect(json.success).toBe(true); + expect(json.returnRequest.status).toBe('requested'); + expect(json.returnRequest.currency).toBe('USD'); + expect(json.returnRequest.refundAmountMinor).toBe(2400); + expect(Array.isArray(json.returnRequest.items)).toBe(true); + expect(json.returnRequest.items.length).toBe(2); + } finally { + await cleanup(seed.orderId, seed.productId); + } + }); + + it('rejects exchange intent with stable contract code', async () => { + const userId = `user_${crypto.randomUUID()}`; + getCurrentUserMock.mockResolvedValue({ + id: userId, + role: 'user', + }); + + const seed = await seedOwnedOrder({ userId }); + + try { + const { POST } = await import('@/app/api/shop/orders/[id]/returns/route'); + const request = new NextRequest( + `http://localhost:3000/api/shop/orders/${seed.orderId}/returns`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + idempotencyKey: `ret_${crypto.randomUUID()}`, + reason: 'need another size', + policyRestock: true, + resolution: 'exchange', + }), + } + ); + + const response = await POST(request, { + params: Promise.resolve({ id: seed.orderId }), + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe('EXCHANGES_NOT_SUPPORTED'); + + const existing = await db + .select({ id: returnRequests.id }) + .from(returnRequests) + .where(eq(returnRequests.orderId, seed.orderId)); + expect(existing).toHaveLength(0); + } finally { + await cleanup(seed.orderId, seed.productId); + } + }); +}); diff --git a/frontend/lib/tests/shop/setup.ts b/frontend/lib/tests/shop/setup.ts new file mode 100644 index 00000000..9efb1f31 --- /dev/null +++ b/frontend/lib/tests/shop/setup.ts @@ -0,0 +1,48 @@ +import { beforeAll, beforeEach } from 'vitest'; + +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; + +const REQUIRED_LOCAL_DB_URL = + 'postgresql://devlovers_local:Gfdtkk43@localhost:5432/devlovers_shop_local_clean?sslmode=disable'; + +function assertStrictShopLocalDb() { + const appEnv = (process.env.APP_ENV ?? '').trim().toLowerCase(); + const localDb = (process.env.DATABASE_URL_LOCAL ?? '').trim(); + const dbUrl = (process.env.DATABASE_URL ?? '').trim(); + + if (appEnv !== 'local') { + throw new Error( + `[shop-test-preflight] APP_ENV must be "local" (got "${appEnv || ''}").` + ); + } + + if (!localDb) { + throw new Error( + '[shop-test-preflight] DATABASE_URL_LOCAL must be set for shop tests.' + ); + } + + if (localDb !== REQUIRED_LOCAL_DB_URL) { + throw new Error( + '[shop-test-preflight] DATABASE_URL_LOCAL must match the required local shop DSN exactly.' + ); + } + + if (dbUrl) { + throw new Error( + '[shop-test-preflight] DATABASE_URL must be unset for shop tests.' + ); + } +} + +beforeAll(() => { + process.env.SHOP_STRICT_LOCAL_DB = '1'; + process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL = REQUIRED_LOCAL_DB_URL; + assertStrictShopLocalDb(); + assertNotProductionDb(); +}); + +beforeEach(() => { + assertStrictShopLocalDb(); + assertNotProductionDb(); +}); diff --git a/frontend/lib/tests/shop/shipping-internal-retention-route-phase7.test.ts b/frontend/lib/tests/shop/shipping-internal-retention-route-phase7.test.ts index 42823565..044308ca 100644 --- a/frontend/lib/tests/shop/shipping-internal-retention-route-phase7.test.ts +++ b/frontend/lib/tests/shop/shipping-internal-retention-route-phase7.test.ts @@ -23,7 +23,6 @@ describe('internal shipping retention route (phase 7)', () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); - vi.stubEnv('DATABASE_URL', 'https://example.com/db'); vi.stubEnv('INTERNAL_JANITOR_SECRET', 'test-secret'); vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); vi.stubEnv('SHOP_SHIPPING_RETENTION_ENABLED', 'true'); diff --git a/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts b/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts index 94b8b13d..08f388a3 100644 --- a/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts +++ b/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts @@ -35,7 +35,6 @@ describe('shop shipping methods route (phase 2)', () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); - vi.stubEnv('DATABASE_URL', 'https://example.com/db'); resetEnvCache(); enforceRateLimitMock.mockResolvedValue({ ok: true, remaining: 100 }); }); diff --git a/frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts b/frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts index e0053b57..d0353208 100644 --- a/frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts +++ b/frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts @@ -53,7 +53,6 @@ describe('shop shipping np cities route (phase 2)', () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); - vi.stubEnv('DATABASE_URL', 'https://example.com/db'); vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); resetEnvCache(); diff --git a/frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts b/frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts index 98e896dd..f2ddb878 100644 --- a/frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts +++ b/frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts @@ -54,7 +54,6 @@ describe('shop shipping np warehouses route (phase 2)', () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); - vi.stubEnv('DATABASE_URL', 'https://example.com/db'); vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); resetEnvCache(); diff --git a/frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts b/frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts index 5b9ed82f..cdf5cd0d 100644 --- a/frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts +++ b/frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts @@ -1,11 +1,12 @@ import crypto from 'node:crypto'; -import { eq } from 'drizzle-orm'; +import { asc, eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; -import { orderShipping, orders, shippingShipments } from '@/db/schema'; +import { orders, orderShipping, shippingEvents, shippingShipments } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; +import * as logging from '@/lib/logging'; import { claimQueuedShipmentsForProcessing, runShippingShipmentsWorker, @@ -22,6 +23,17 @@ vi.mock('@/lib/services/shop/shipping/nova-poshta-client', async () => { }; }); +vi.mock('@/lib/services/shop/events/write-shipping-event', async () => { + const actual = await vi.importActual( + '@/lib/services/shop/events/write-shipping-event' + ); + return { + ...actual, + writeShippingEvent: vi.fn(actual.writeShippingEvent), + }; +}); + +import { writeShippingEvent } from '@/lib/services/shop/events/write-shipping-event'; import { createInternetDocument, NovaPoshtaApiError, @@ -58,6 +70,15 @@ async function seedShipment(args?: { currency?: 'USD' | 'UAH'; attemptCount?: number; shipmentStatus?: 'queued' | 'failed' | 'processing' | 'needs_attention' | 'succeeded'; + orderShippingStatus?: + | 'pending' + | 'queued' + | 'creating_label' + | 'label_created' + | 'shipped' + | 'delivered' + | 'cancelled' + | 'needs_attention'; nextAttemptAt?: Date | null; }) { const orderId = crypto.randomUUID(); @@ -81,7 +102,7 @@ async function seedShipment(args?: { shippingProvider: 'nova_poshta', shippingMethodCode: 'NP_WAREHOUSE', shippingAmountMinor: null, - shippingStatus: 'queued', + shippingStatus: args?.orderShippingStatus ?? 'queued', } as any); await db.insert(orderShipping).values({ @@ -109,6 +130,21 @@ async function cleanupSeed(seed: Seeded) { await db.delete(orders).where(eq(orders.id, seed.orderId)); } +async function readOrderShippingEvents(orderId: string) { + return db + .select({ + eventName: shippingEvents.eventName, + statusFrom: shippingEvents.statusFrom, + statusTo: shippingEvents.statusTo, + eventSource: shippingEvents.eventSource, + shipmentId: shippingEvents.shipmentId, + eventRef: shippingEvents.eventRef, + }) + .from(shippingEvents) + .where(eq(shippingEvents.orderId, orderId)) + .orderBy(asc(shippingEvents.createdAt), asc(shippingEvents.id)); +} + describe.sequential('shipping shipments worker phase 5', () => { beforeEach(() => { vi.clearAllMocks(); @@ -191,6 +227,19 @@ describe.sequential('shipping shipments worker phase 5', () => { expect(order?.shippingStatus).toBe('label_created'); expect(order?.trackingNumber).toBe('20451234567890'); expect(order?.shippingProviderRef).toBe('np-provider-ref-1'); + + const events = await readOrderShippingEvents(seed.orderId); + expect(events.length).toBe(2); + expect(events.map(event => event.eventName)).toEqual( + expect.arrayContaining(['creating_label', 'label_created']) + ); + const creatingLabelEvents = events.filter( + event => event.eventName === 'creating_label' + ); + expect(creatingLabelEvents).toHaveLength(1); + expect(events.every(event => event.eventSource === 'shipments_worker')).toBe( + true + ); } finally { await cleanupSeed(seed); } @@ -246,11 +295,99 @@ describe.sequential('shipping shipments worker phase 5', () => { .where(eq(orders.id, seed.orderId)) .limit(1); expect(order?.shippingStatus).toBe('queued'); + + const events = await readOrderShippingEvents(seed.orderId); + expect(events.length).toBe(2); + expect(events.map(event => event.eventName)).toEqual( + expect.arrayContaining([ + 'creating_label', + 'label_creation_retry_scheduled', + ]) + ); } finally { await cleanupSeed(seed); } }); + it('keeps retry outcome when failure-path event write throws', async () => { + const seed = await seedShipment(); + + const originalWriteShippingEventImpl = + vi.mocked(writeShippingEvent).getMockImplementation(); + + try { + vi.mocked(createInternetDocument).mockRejectedValue( + new NovaPoshtaApiError('NP_HTTP_ERROR', 'temporary', 503) + ); + + vi.mocked(writeShippingEvent).mockImplementation(async (args: any) => { + if (args?.eventName === 'label_creation_retry_scheduled') { + throw new Error('failure-event-write-failed'); + } + if (originalWriteShippingEventImpl) { + return originalWriteShippingEventImpl(args); + } + return { inserted: false, dedupeKey: 'mock_noop', id: null }; + }); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 0, + retried: 1, + needsAttention: 0, + }); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + nextAttemptAt: shippingShipments.nextAttemptAt, + lastErrorCode: shippingShipments.lastErrorCode, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('failed'); + expect(shipment?.attemptCount).toBe(1); + expect(shipment?.nextAttemptAt).toBeTruthy(); + expect(shipment?.lastErrorCode).toBe('NP_HTTP_ERROR'); + + const [order] = await db + .select({ + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + expect(order?.shippingStatus).toBe('queued'); + + const events = await readOrderShippingEvents(seed.orderId); + expect(events.some(event => event.eventName === 'creating_label')).toBe(true); + expect( + events.some(event => event.eventName === 'label_creation_retry_scheduled') + ).toBe(false); + } finally { + if (originalWriteShippingEventImpl) { + vi.mocked(writeShippingEvent).mockImplementation( + originalWriteShippingEventImpl + ); + } else { + vi.mocked(writeShippingEvent).mockReset(); + } + await cleanupSeed(seed); + } + }); + it('max attempts -> needs_attention', async () => { const seed = await seedShipment({ attemptCount: 2, shipmentStatus: 'failed' }); @@ -299,6 +436,311 @@ describe.sequential('shipping shipments worker phase 5', () => { .where(eq(orders.id, seed.orderId)) .limit(1); expect(order?.shippingStatus).toBe('needs_attention'); + + const events = await readOrderShippingEvents(seed.orderId); + expect(events.length).toBe(2); + expect(events.map(event => event.eventName)).toEqual( + expect.arrayContaining([ + 'creating_label', + 'label_creation_needs_attention', + ]) + ); + } finally { + await cleanupSeed(seed); + } + }); + + it('keeps success outcome when post-success event write throws', async () => { + const seed = await seedShipment(); + + const originalWriteShippingEventImpl = + vi.mocked(writeShippingEvent).getMockImplementation(); + + try { + vi.mocked(createInternetDocument).mockResolvedValue({ + providerRef: 'np-provider-ref-event-fail', + trackingNumber: '20450000999999', + }); + + vi.mocked(writeShippingEvent).mockImplementation(async (args: any) => { + if (args?.eventName === 'label_created') { + throw new Error('event-write-failed'); + } + if (originalWriteShippingEventImpl) { + return originalWriteShippingEventImpl(args); + } + return { inserted: false, dedupeKey: 'mock_noop', id: null }; + }); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 1, + retried: 0, + needsAttention: 0, + }); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + nextAttemptAt: shippingShipments.nextAttemptAt, + lastErrorCode: shippingShipments.lastErrorCode, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('succeeded'); + expect(shipment?.attemptCount).toBe(1); + expect(shipment?.providerRef).toBe('np-provider-ref-event-fail'); + expect(shipment?.trackingNumber).toBe('20450000999999'); + expect(shipment?.nextAttemptAt).toBeNull(); + expect(shipment?.lastErrorCode).toBeNull(); + + const [order] = await db + .select({ + shippingStatus: orders.shippingStatus, + trackingNumber: orders.trackingNumber, + shippingProviderRef: orders.shippingProviderRef, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(order?.shippingStatus).toBe('label_created'); + expect(order?.trackingNumber).toBe('20450000999999'); + expect(order?.shippingProviderRef).toBe('np-provider-ref-event-fail'); + + const eventNames = vi + .mocked(writeShippingEvent) + .mock.calls.map(call => (call[0] as { eventName?: string })?.eventName); + expect(eventNames).toContain('creating_label'); + expect(eventNames).toContain('label_created'); + } finally { + if (originalWriteShippingEventImpl) { + vi.mocked(writeShippingEvent).mockImplementation( + originalWriteShippingEventImpl + ); + } else { + vi.mocked(writeShippingEvent).mockReset(); + } + await cleanupSeed(seed); + } + }); + + it('filters out blocked transitions before processing so no external label is created', async () => { + const seed = await seedShipment({ orderShippingStatus: 'shipped' }); + + try { + vi.mocked(createInternetDocument).mockResolvedValue({ + providerRef: 'np-provider-ref-blocked-success', + trackingNumber: '20450000111111', + }); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 0, + processed: 0, + succeeded: 0, + retried: 0, + needsAttention: 0, + }); + expect(createInternetDocument).not.toHaveBeenCalled(); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('queued'); + expect(shipment?.attemptCount).toBe(0); + expect(shipment?.providerRef).toBeNull(); + expect(shipment?.trackingNumber).toBeNull(); + + const [order] = await db + .select({ + shippingStatus: orders.shippingStatus, + trackingNumber: orders.trackingNumber, + shippingProviderRef: orders.shippingProviderRef, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(order?.shippingStatus).toBe('shipped'); + expect(order?.trackingNumber).toBeNull(); + expect(order?.shippingProviderRef).toBeNull(); + + const events = await readOrderShippingEvents(seed.orderId); + expect(events.some(event => event.eventName === 'creating_label')).toBe(false); + expect(events.some(event => event.eventName === 'label_created')).toBe(false); + } finally { + await cleanupSeed(seed); + } + }); + + it('classifies lease loss when shipment row is no longer owned by runId', async () => { + const seed = await seedShipment({ orderShippingStatus: 'queued' }); + const warnSpy = vi.spyOn(logging, 'logWarn'); + + try { + vi.mocked(createInternetDocument).mockImplementation(async () => { + await db + .update(shippingShipments) + .set({ + leaseOwner: `lease-stolen-${crypto.randomUUID()}`, + leaseExpiresAt: new Date(Date.now() + 60_000), + } as any) + .where(eq(shippingShipments.id, seed.shipmentId)); + + return { + providerRef: 'np-provider-ref-lease-lost', + trackingNumber: '20450000777777', + }; + }); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 0, + retried: 1, + needsAttention: 0, + }); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + leaseOwner: shippingShipments.leaseOwner, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('processing'); + expect(shipment?.attemptCount).toBe(0); + expect(shipment?.providerRef).toBeNull(); + expect(shipment?.trackingNumber).toBeNull(); + expect(shipment?.leaseOwner).toBeTruthy(); + + const [order] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(order?.shippingStatus).toBe('creating_label'); + + expect( + warnSpy.mock.calls.some( + ([name, meta]) => + name === 'shipping_shipments_worker_lease_lost' && + (meta as Record)?.code === 'SHIPMENT_LEASE_LOST' + ) + ).toBe(true); + expect( + warnSpy.mock.calls.some( + ([name, meta]) => + name === 'shipping_shipments_worker_order_transition_blocked' && + (meta as Record)?.code === 'ORDER_TRANSITION_BLOCKED' + ) + ).toBe(false); + } finally { + warnSpy.mockRestore(); + await cleanupSeed(seed); + } + }); + + it('does not emit retry/needs_attention transition events when order transition is blocked', async () => { + const seed = await seedShipment({ orderShippingStatus: 'shipped' }); + + try { + vi.mocked(createInternetDocument).mockRejectedValue( + new NovaPoshtaApiError('NP_HTTP_ERROR', 'temporary', 503) + ); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 0, + processed: 0, + succeeded: 0, + retried: 0, + needsAttention: 0, + }); + expect(createInternetDocument).not.toHaveBeenCalled(); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + lastErrorCode: shippingShipments.lastErrorCode, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('queued'); + expect(shipment?.attemptCount).toBe(0); + expect(shipment?.lastErrorCode).toBeNull(); + + const [order] = await db + .select({ + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(order?.shippingStatus).toBe('shipped'); + + const events = await readOrderShippingEvents(seed.orderId); + expect( + events.some(event => + event.eventName === 'label_creation_retry_scheduled' || + event.eventName === 'label_creation_needs_attention' + ) + ).toBe(false); } finally { await cleanupSeed(seed); } @@ -321,9 +763,47 @@ describe.sequential('shipping shipments worker phase 5', () => { expect(first.length).toBe(1); expect(second.length).toBe(0); + + const events = await readOrderShippingEvents(seed.orderId); + expect(events.length).toBe(1); + expect(events[0]?.eventName).toBe('creating_label'); } finally { await cleanupSeed(seed); } }); -}); + it('dedupes creating_label event on processing-claim replay for same attempt', async () => { + const seed = await seedShipment({ shipmentStatus: 'processing', attemptCount: 0 }); + + try { + const runA = `worker-a-${crypto.randomUUID()}`; + const first = await claimQueuedShipmentsForProcessing({ + runId: runA, + leaseSeconds: 120, + limit: 1, + }); + + expect(first.length).toBe(1); + + await db + .update(shippingShipments) + .set({ leaseExpiresAt: new Date(Date.now() - 5_000) } as any) + .where(eq(shippingShipments.id, seed.shipmentId)); + + const runB = `worker-b-${crypto.randomUUID()}`; + const second = await claimQueuedShipmentsForProcessing({ + runId: runB, + leaseSeconds: 120, + limit: 1, + }); + + expect(second.length).toBe(1); + + const events = await readOrderShippingEvents(seed.orderId); + const creating = events.filter(event => event.eventName === 'creating_label'); + expect(creating.length).toBe(1); + } finally { + await cleanupSeed(seed); + } + }); +}); diff --git a/frontend/lib/tests/shop/shop-url.test.ts b/frontend/lib/tests/shop/shop-url.test.ts index ad3986f6..bada2f2a 100644 --- a/frontend/lib/tests/shop/shop-url.test.ts +++ b/frontend/lib/tests/shop/shop-url.test.ts @@ -4,7 +4,6 @@ import { resetEnvCache } from '@/lib/env'; import { resolveShopBaseUrl, toAbsoluteUrl } from '@/lib/shop/url'; const ENV_KEYS = [ - 'DATABASE_URL', 'SHOP_BASE_URL', 'APP_ORIGIN', 'NEXT_PUBLIC_SITE_URL', @@ -18,8 +17,6 @@ beforeEach(() => { previousEnv[key] = process.env[key]; delete process.env[key]; } - - process.env.DATABASE_URL = 'https://db.example.test'; resetEnvCache(); }); diff --git a/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts index f78167ab..bc4cba95 100644 --- a/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts @@ -7,6 +7,7 @@ import { db } from '@/db'; import { orderItems, orders, + paymentEvents, productPrices, products, shippingShipments, @@ -51,6 +52,15 @@ async function cleanup(params: { }) { const { orderId, productId, eventId } = params; + try { + await db.delete(paymentEvents).where(eq(paymentEvents.orderId, orderId)); + } catch (e) { + logTestCleanupFailed( + { step: 'delete paymentEvents by orderId', orderId, eventId, productId }, + e + ); + } + try { await db.delete(stripeEvents).where(eq(stripeEvents.eventId, eventId)); } catch (e) { @@ -290,6 +300,17 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { .where(eq(stripeEvents.eventId, eventId)); expect(ev1.length).toBe(1); + const canonical1 = await db + .select({ + id: paymentEvents.id, + eventName: paymentEvents.eventName, + eventRef: paymentEvents.eventRef, + }) + .from(paymentEvents) + .where(eq(paymentEvents.orderId, orderId)); + expect(canonical1.length).toBe(1); + expect(canonical1[0]?.eventName).toBe('paid_applied'); + expect(canonical1[0]?.eventRef).toBe(eventId); const queued1 = await db .select({ id: shippingShipments.id }) @@ -308,6 +329,11 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { .where(eq(stripeEvents.eventId, eventId)); expect(ev2.length).toBe(1); + const canonical2 = await db + .select({ id: paymentEvents.id }) + .from(paymentEvents) + .where(eq(paymentEvents.orderId, orderId)); + expect(canonical2.length).toBe(1); const updated2 = await db .select({ diff --git a/frontend/lib/tests/shop/transition-matrix-phase6.test.ts b/frontend/lib/tests/shop/transition-matrix-phase6.test.ts new file mode 100644 index 00000000..14823ba4 --- /dev/null +++ b/frontend/lib/tests/shop/transition-matrix-phase6.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + isOrderNonPaymentStatusTransitionAllowed, + isOrderQuoteStatusTransitionAllowed, +} from '@/lib/services/shop/transitions/order-state'; +import { isReturnStatusTransitionAllowed } from '@/lib/services/shop/transitions/return-state'; +import { isShippingStatusTransitionAllowed } from '@/lib/services/shop/transitions/shipping-state'; + +describe('transition matrix phase 6', () => { + it('order non-payment matrix allows/forbids expected transitions', () => { + expect( + isOrderNonPaymentStatusTransitionAllowed('CREATED', 'INVENTORY_RESERVED') + ).toBe(true); + expect( + isOrderNonPaymentStatusTransitionAllowed( + 'INVENTORY_RESERVED', + 'INVENTORY_FAILED' + ) + ).toBe(true); + expect( + isOrderNonPaymentStatusTransitionAllowed('INVENTORY_FAILED', 'CANCELED') + ).toBe(true); + expect( + isOrderNonPaymentStatusTransitionAllowed('PAID', 'INVENTORY_FAILED') + ).toBe(true); + expect( + isOrderNonPaymentStatusTransitionAllowed('CANCELED', 'INVENTORY_RESERVED') + ).toBe(false); + }); + + it('order quote matrix allows/forbids expected transitions', () => { + expect(isOrderQuoteStatusTransitionAllowed('none', 'requested')).toBe(true); + expect(isOrderQuoteStatusTransitionAllowed('declined', 'requested')).toBe( + true + ); + expect(isOrderQuoteStatusTransitionAllowed('offered', 'requested')).toBe( + false + ); + + expect(isOrderQuoteStatusTransitionAllowed('requested', 'offered')).toBe( + true + ); + expect(isOrderQuoteStatusTransitionAllowed('offered', 'accepted')).toBe( + true + ); + expect(isOrderQuoteStatusTransitionAllowed('accepted', 'offered')).toBe( + false + ); + expect( + isOrderQuoteStatusTransitionAllowed('accepted', 'requires_requote') + ).toBe(true); + }); + + it('shipping matrix allows/forbids expected transitions', () => { + expect(isShippingStatusTransitionAllowed('pending', 'queued')).toBe(true); + expect( + isShippingStatusTransitionAllowed('creating_label', 'queued') + ).toBe(true); + expect( + isShippingStatusTransitionAllowed('needs_attention', 'queued') + ).toBe(true); + expect( + isShippingStatusTransitionAllowed(null, 'queued', { allowNullFrom: true }) + ).toBe(true); + expect(isShippingStatusTransitionAllowed('shipped', 'queued')).toBe(false); + expect( + isShippingStatusTransitionAllowed('label_created', 'shipped') + ).toBe(true); + expect( + isShippingStatusTransitionAllowed('creating_label', 'shipped') + ).toBe(false); + expect(isShippingStatusTransitionAllowed('shipped', 'delivered')).toBe( + true + ); + }); + + it('return matrix allows/forbids expected transitions', () => { + expect(isReturnStatusTransitionAllowed('requested', 'approved')).toBe(true); + expect(isReturnStatusTransitionAllowed('requested', 'rejected')).toBe(true); + expect(isReturnStatusTransitionAllowed('approved', 'received')).toBe(true); + expect(isReturnStatusTransitionAllowed('received', 'refunded')).toBe(true); + + expect(isReturnStatusTransitionAllowed('approved', 'rejected')).toBe(false); + expect(isReturnStatusTransitionAllowed('requested', 'refunded')).toBe( + false + ); + expect(isReturnStatusTransitionAllowed('refunded', 'approved')).toBe( + false + ); + }); +}); diff --git a/frontend/lib/types/shop.ts b/frontend/lib/types/shop.ts index fca3b5d1..d4347438 100644 --- a/frontend/lib/types/shop.ts +++ b/frontend/lib/types/shop.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { checkoutItemSchema, + checkoutLegalConsentSchema, checkoutShippingSchema, dbProductSchema, orderSummarySchema, @@ -24,6 +25,9 @@ export type DbProduct = z.infer; export type CheckoutItem = z.infer; export type CheckoutShippingInput = z.infer; +export type CheckoutLegalConsentInput = z.infer< + typeof checkoutLegalConsentSchema +>; export type OrderSummary = z.infer & { totalCents?: number; diff --git a/frontend/lib/validation/shop-notifications.ts b/frontend/lib/validation/shop-notifications.ts new file mode 100644 index 00000000..f2442be6 --- /dev/null +++ b/frontend/lib/validation/shop-notifications.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const internalNotificationsRunPayloadSchema = z + .object({ + dryRun: z.boolean().optional().default(false), + limit: z.coerce.number().int().min(1).max(200).optional().default(50), + leaseSeconds: z.coerce + .number() + .int() + .min(30) + .max(1800) + .optional() + .default(120), + maxAttempts: z.coerce.number().int().min(1).max(10).optional().default(5), + baseBackoffSeconds: z.coerce + .number() + .int() + .min(5) + .max(3600) + .optional() + .default(30), + projectorLimit: z.coerce + .number() + .int() + .min(1) + .max(500) + .optional() + .default(100), + }) + .strict(); + +export type InternalNotificationsRunPayload = z.infer< + typeof internalNotificationsRunPayloadSchema +>; diff --git a/frontend/lib/validation/shop-returns.ts b/frontend/lib/validation/shop-returns.ts new file mode 100644 index 00000000..41ce7268 --- /dev/null +++ b/frontend/lib/validation/shop-returns.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const returnRequestIdParamSchema = z.object({ + id: z.string().uuid(), +}); + +export const returnResolutionSchema = z.enum(['refund', 'exchange']); +export type ReturnResolution = z.infer; + +export const createReturnPayloadSchema = z + .object({ + idempotencyKey: z + .string() + .trim() + .min(16) + .max(128) + .regex(/^[A-Za-z0-9_.-]+$/), + reason: z.string().trim().max(500).optional(), + policyRestock: z.boolean().optional().default(true), + resolution: returnResolutionSchema.optional().default('refund'), + }) + .strict(); + +export type ReturnRequestIdParams = z.infer; +export type CreateReturnPayload = z.infer; diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index 7b71354d..d1840ebe 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -386,6 +386,17 @@ export const checkoutShippingSchema = z } }); +const checkoutLegalVersionSchema = z.string().trim().min(1).max(64); + +export const checkoutLegalConsentSchema = z + .object({ + termsAccepted: z.boolean(), + privacyAccepted: z.boolean(), + termsVersion: checkoutLegalVersionSchema, + privacyVersion: checkoutLegalVersionSchema, + }) + .strict(); + export const checkoutPayloadSchema = z .object({ items: z.array(checkoutItemSchema).min(1), @@ -397,6 +408,7 @@ export const checkoutPayloadSchema = z .transform(value => value.toUpperCase()) .optional(), shipping: checkoutShippingSchema.optional(), + legalConsent: checkoutLegalConsentSchema.optional(), }) .strict(); @@ -501,6 +513,34 @@ export const orderSummarySchema = z.object({ ), }); +export const intlQuoteOfferPayloadSchema = z + .object({ + version: z.coerce.number().int().min(1), + shippingQuoteMinor: z.coerce.number().int().min(0), + currency: currencySchema, + expiresAt: z.coerce.date().optional(), + payload: z.record(z.string(), z.unknown()).optional(), + }) + .strict(); + +export const intlQuoteAcceptPayloadSchema = z + .object({ + version: z.coerce.number().int().min(1), + }) + .strict(); + +export const intlQuoteDeclinePayloadSchema = z + .object({ + version: z.coerce.number().int().min(1).optional(), + }) + .strict(); + +export const orderPaymentInitPayloadSchema = z + .object({ + provider: z.enum(['stripe']).default('stripe'), + }) + .strict(); + export type CatalogQuery = z.infer; export type CatalogFilters = z.infer; export type DbProduct = z.infer; @@ -515,4 +555,15 @@ export type CartRehydrateResult = z.infer; export type CheckoutItemInput = z.infer; export type CheckoutPayload = z.infer; export type CheckoutShippingPayload = z.infer; +export type CheckoutLegalConsentPayload = z.infer< + typeof checkoutLegalConsentSchema +>; export type OrderIdParams = z.infer; +export type IntlQuoteOfferPayload = z.infer; +export type IntlQuoteAcceptPayload = z.infer; +export type IntlQuoteDeclinePayload = z.infer< + typeof intlQuoteDeclinePayloadSchema +>; +export type OrderPaymentInitPayload = z.infer< + typeof orderPaymentInitPayloadSchema +>; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b4d34ca..b956429c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.3", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.3", + "version": "1.0.5", "dependencies": { "@neondatabase/serverless": "^1.0.2", "@phosphor-icons/react": "^2.1.10", @@ -54,6 +54,7 @@ "zod": "^3.24.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4.1.18", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", @@ -3438,6 +3439,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@portabletext/react": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@portabletext/react/-/react-5.0.0.tgz", @@ -12495,6 +12512,53 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/po-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 16390a23..f629a340 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.0.5", + "version": "1.0.6", "private": true, "scripts": { "dev": "next dev", @@ -18,6 +18,7 @@ "test": "vitest", "test:ui": "vitest --ui", "test:run": "vitest run", + "test:e2e:shop": "playwright test --config=playwright.config.ts tests/e2e/shop-minimal-phase9.spec.ts --workers=1", "test:blog": "vitest run components/tests/blog/*.test.tsx lib/tests/blog/*.test.ts", "test:blog:coverage": "vitest run --coverage \"--coverage.include=components/blog/**\" \"--coverage.include=app/api/blog-*/**\" components/tests/blog/*.test.tsx lib/tests/blog/*.test.ts" }, @@ -68,6 +69,7 @@ "zod": "^3.24.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4.1.18", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..d7a8d6ac --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from '@playwright/test'; + +const LOCAL_DB_URL = + 'postgresql://devlovers_local:Gfdtkk43@localhost:5432/devlovers_shop_local_clean?sslmode=disable'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: /.*\.spec\.ts$/, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + workers: 1, + use: { + baseURL: 'http://127.0.0.1:3100', + trace: 'retain-on-failure', + }, + webServer: { + command: 'npm run dev -- -p 3100 -H 127.0.0.1', + port: 3100, + timeout: 120_000, + reuseExistingServer: true, + env: { + APP_ENV: 'local', + DATABASE_URL_LOCAL: LOCAL_DB_URL, + DATABASE_URL: '', + SHOP_STRICT_LOCAL_DB: '1', + SHOP_REQUIRED_DATABASE_URL_LOCAL: LOCAL_DB_URL, + SHOP_STATUS_TOKEN_SECRET: + 'test_status_token_secret_test_status_token_secret', + NODE_ENV: 'test', + NEXT_TELEMETRY_DISABLED: '1', + }, + }, +}); diff --git a/frontend/public/icons/aws.svg b/frontend/public/icons/aws.svg new file mode 100644 index 00000000..39f9762a --- /dev/null +++ b/frontend/public/icons/aws.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/public/icons/azure.svg b/frontend/public/icons/azure.svg new file mode 100644 index 00000000..6c0d5a7a --- /dev/null +++ b/frontend/public/icons/azure.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/icons/devops.svg b/frontend/public/icons/devops.svg new file mode 100644 index 00000000..bad576d6 --- /dev/null +++ b/frontend/public/icons/devops.svg @@ -0,0 +1 @@ +Artboard 39 \ No newline at end of file diff --git a/frontend/public/icons/django.svg b/frontend/public/icons/django.svg new file mode 100644 index 00000000..45417d36 --- /dev/null +++ b/frontend/public/icons/django.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/icons/docker.svg b/frontend/public/icons/docker.svg new file mode 100644 index 00000000..eba6cc41 --- /dev/null +++ b/frontend/public/icons/docker.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/public/icons/kubernetes.svg b/frontend/public/icons/kubernetes.svg new file mode 100644 index 00000000..1ceb39f9 --- /dev/null +++ b/frontend/public/icons/kubernetes.svg @@ -0,0 +1 @@ + diff --git a/frontend/tests/e2e/shop-minimal-phase9.spec.ts b/frontend/tests/e2e/shop-minimal-phase9.spec.ts new file mode 100644 index 00000000..5485e065 --- /dev/null +++ b/frontend/tests/e2e/shop-minimal-phase9.spec.ts @@ -0,0 +1,240 @@ +import crypto from 'node:crypto'; + +import { expect, test } from '@playwright/test'; +import { Pool } from 'pg'; + +import { createStatusToken } from '@/lib/shop/status-token'; + +const LOCAL_DB_URL = process.env.DATABASE_URL_LOCAL; +const STATUS_TOKEN_SECRET = process.env.SHOP_STATUS_TOKEN_SECRET; + +if (!LOCAL_DB_URL?.trim() || !STATUS_TOKEN_SECRET?.trim()) { + throw new Error( + 'E2E tests require DATABASE_URL_LOCAL and SHOP_STATUS_TOKEN_SECRET environment variables' + ); +} + +const ALLOWED_LOCAL_DB_HOSTS = new Set(['localhost', '127.0.0.1']); +const localDbUrlRaw = LOCAL_DB_URL.trim(); +let localDbUrlParsed: URL; +try { + localDbUrlParsed = new URL(localDbUrlRaw); +} catch { + throw new Error( + 'E2E DATABASE_URL_LOCAL must be a valid URL. Expected a postgresql:// URL string.' + ); +} + +if (!ALLOWED_LOCAL_DB_HOSTS.has(localDbUrlParsed.hostname)) { + throw new Error( + `Refusing to run E2E against non-local DB host: ${localDbUrlParsed.hostname}` + ); +} + +const pool = new Pool({ connectionString: localDbUrlRaw }); + +async function insertOrder(args: { + orderId: string; + currency?: 'USD' | 'UAH'; + totalAmountMinor?: number; + paymentProvider?: 'stripe' | 'monobank'; + paymentStatus?: + | 'pending' + | 'requires_payment' + | 'paid' + | 'failed' + | 'refunded' + | 'needs_review'; + status?: + | 'CREATED' + | 'INVENTORY_RESERVED' + | 'INVENTORY_FAILED' + | 'PAID' + | 'CANCELED'; + inventoryStatus?: + | 'none' + | 'reserving' + | 'reserved' + | 'release_pending' + | 'released' + | 'failed'; + fulfillmentMode?: 'ua_np' | 'intl'; + quoteStatus?: + | 'none' + | 'requested' + | 'offered' + | 'accepted' + | 'declined' + | 'expired' + | 'requires_requote'; +}) { + const totalAmountMinor = args.totalAmountMinor ?? 1000; + const currency = args.currency ?? 'UAH'; + const paymentProvider = args.paymentProvider ?? 'monobank'; + const paymentStatus = args.paymentStatus ?? 'pending'; + const status = args.status ?? 'INVENTORY_RESERVED'; + const inventoryStatus = args.inventoryStatus ?? 'reserved'; + const fulfillmentMode = args.fulfillmentMode ?? 'ua_np'; + const quoteStatus = args.quoteStatus ?? 'none'; + + await pool.query( + ` + insert into orders ( + id, + user_id, + idempotency_key, + currency, + total_amount, + total_amount_minor, + payment_provider, + payment_status, + status, + inventory_status, + fulfillment_mode, + quote_status, + items_subtotal_minor, + created_at, + updated_at + ) + values ( + $1::uuid, + null, + $2, + $3, + ($4::numeric / 100), + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $4, + now(), + now() + ) + `, + [ + args.orderId, + `e2e:${args.orderId}`, + currency, + totalAmountMinor, + paymentProvider, + paymentStatus, + status, + inventoryStatus, + fulfillmentMode, + quoteStatus, + ] + ); +} + +async function cleanupOrder(orderId: string) { + await pool.query('delete from admin_audit_log where order_id = $1::uuid', [ + orderId, + ]); + await pool.query('delete from payment_attempts where order_id = $1::uuid', [ + orderId, + ]); + await pool.query('delete from order_items where order_id = $1::uuid', [ + orderId, + ]); + await pool.query('delete from orders where id = $1::uuid', [orderId]); +} + +test.describe('shop e2e minimal phase 9', () => { + test.afterAll(async () => { + await pool.end(); + }); + + test('flow 1: guest status endpoint requires token', async ({ request }) => { + const orderId = crypto.randomUUID(); + await insertOrder({ orderId }); + + try { + const res = await request.get(`/api/shop/orders/${orderId}/status?view=lite`); + expect(res.status()).toBe(401); + const body = await res.json(); + expect(body.code).toBe('STATUS_TOKEN_REQUIRED'); + } finally { + await cleanupOrder(orderId); + } + }); + + test('flow 2: guest token with status_lite scope reads status', async ({ + request, + }) => { + const orderId = crypto.randomUUID(); + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 4200, + paymentProvider: 'monobank', + paymentStatus: 'pending', + }); + + try { + const token = createStatusToken({ + orderId, + scopes: ['status_lite'], + }); + + const res = await request.get( + `/api/shop/orders/${orderId}/status?view=lite&statusToken=${encodeURIComponent( + token + )}` + ); + + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.id).toBe(orderId); + expect(body.currency).toBe('UAH'); + expect(body.totalAmountMinor).toBe(4200); + expect(body.paymentStatus).toBe('pending'); + expect(typeof body.itemsCount).toBe('number'); + } finally { + await cleanupOrder(orderId); + } + }); + + test('flow 3: payment init rejects token without order_payment_init scope', async ({ + request, + }) => { + const orderId = crypto.randomUUID(); + await insertOrder({ + orderId, + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'pending', + fulfillmentMode: 'ua_np', + quoteStatus: 'none', + inventoryStatus: 'reserved', + }); + + try { + const token = createStatusToken({ + orderId, + scopes: ['status_lite'], + }); + + const res = await request.post( + `/api/shop/orders/${orderId}/payment/init?statusToken=${encodeURIComponent( + token + )}`, + { + headers: { + origin: 'http://localhost:3000', + 'content-type': 'application/json', + }, + data: { provider: 'stripe' }, + } + ); + + expect(res.status()).toBe(403); + const body = await res.json(); + expect(body.code).toBe('STATUS_TOKEN_SCOPE_FORBIDDEN'); + } finally { + await cleanupOrder(orderId); + } + }); +}); diff --git a/frontend/vitest.shop.config.ts b/frontend/vitest.shop.config.ts new file mode 100644 index 00000000..9ecb7b5f --- /dev/null +++ b/frontend/vitest.shop.config.ts @@ -0,0 +1,21 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vitest/config'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@': __dirname, + 'server-only': path.join(__dirname, 'lib/tests/__mocks__/server-only.ts'), + }, + }, + test: { + environment: 'node', + include: ['lib/tests/shop/**/*.test.ts'], + globals: true, + setupFiles: ['./vitest.setup.ts', './lib/tests/shop/setup.ts'], + }, +}); diff --git a/studio/package.json b/studio/package.json index b5b2b821..9f1d2ffa 100644 --- a/studio/package.json +++ b/studio/package.json @@ -1,7 +1,7 @@ { "name": "devlovers", "private": true, - "version": "1.0.5", + "version": "1.0.6", "main": "package.json", "license": "UNLICENSED", "scripts": {