diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..4144fa87 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,57 @@ +name: Security Check + +permissions: + contents: read + +on: + pull_request: + push: + branches: [main, develop] + +jobs: + safe-chain: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install Safe-chain + run: npm install -g @aikidosec/safe-chain@1.4.2 + + - name: Setup Safe-chain for CI + run: safe-chain setup-ci + + - name: Add Safe-chain to PATH + run: | + echo "$HOME/.safe-chain/bin" >> "$GITHUB_PATH" + echo "$HOME/.safe-chain/shims" >> "$GITHUB_PATH" + + - name: Verify Safe-chain is active + run: | + set -euo pipefail + command -v safe-chain + safe-chain --version + NPM_BIN="$(command -v npm)" + echo "npm path: ${NPM_BIN}" + case "${NPM_BIN}" in + *".safe-chain/shims/"*) ;; + *) + echo "Safe-chain npm shim is not active" + exit 1 + ;; + esac + + - name: Install dependencies + run: npm ci --include=dev diff --git a/CHANGELOG.md b/CHANGELOG.md index bac5d7bd..71b5e887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -457,3 +457,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Fixed inconsistent scroll behavior when navigating Q&A pages - Improved UX predictability across desktop and mobile devices + +## [0.5.7] - 2026-02-12 + +### Added + +- Monitoring & observability: + - Production error and performance monitoring via Sentry + - Global error boundary and release tracking +- Security & CI: + - Safe-chain dependency malware protection in GitHub Actions + - Automated dependency validation on push and pull requests +- SEO & social metadata: + - Full Open Graph and Twitter Card support + - Localized OG metadata and alt text + - Canonical URL handling via metadataBase +- Quiz platform improvements: + - Redis caching for quiz questions to reduce database load + - Guest warning with Login / Sign up / Continue as guest options + - Bot protection: single verification per question attempt +- Shop enhancements: + - Monobank payment integration (UAH-only, feature-gated) + - Secure webhook processing with signature validation and idempotency + - Admin refund and cancel endpoints +- Platform transparency: + - Added `/public/humans.txt` with team, mission, and technology stack + +### Changed + +- Navigation & UX: + - Added global page transition loading indicators + - Context-aware header behavior for Blog and Shop + - Improved mobile menu interactions and auto-close behavior +- Leaderboard: + - User avatars with DiceBear fallback for missing images +- Home & SEO: + - Home title standardized to "DevLovers" + - Subtitle used for OG/Twitter previews +- Authentication & routing: + - Improved locale detection and dashboard redirect handling + - OAuth and database configuration stability improvements + +### Fixed + +- Fixed missing locale handling for `/dashboard` redirects +- Restored authentication flow after environment configuration issues +- Improved handling of missing or invalid avatar data +- Fixed OG preview URL resolution issues +- Improved reliability of environment configuration and credentials + +### Performance + +- Reduced database load for quiz pages via Redis caching +- Improved frontend loading experience during navigation diff --git a/frontend/.env.example b/frontend/.env.example index e0650744..e92ad433 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -102,4 +102,4 @@ TRUST_FORWARDED_HEADERS=0 # emergency switch RATE_LIMIT_DISABLED=0 -GROQ_API_KEY= +GROQ_API_KEY= \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index f5dd8f9e..735afbaf 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -48,3 +48,6 @@ next-env.d.ts CLAUDE.md _dev-notes/ .claude + +# Sentry Config File +.env.sentry-build-plugin diff --git a/frontend/app/[locale]/dashboard/page.tsx b/frontend/app/[locale]/dashboard/page.tsx index 3998a5e5..be15de5f 100644 --- a/frontend/app/[locale]/dashboard/page.tsx +++ b/frontend/app/[locale]/dashboard/page.tsx @@ -1,7 +1,6 @@ import { getTranslations } from 'next-intl/server'; import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync'; -import { QuizResultsSection } from '@/components/dashboard/QuizResultsSection'; import { ExplainedTermsCard } from '@/components/dashboard/ExplainedTermsCard'; import { ProfileCard } from '@/components/dashboard/ProfileCard'; import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner'; @@ -111,12 +110,9 @@ export default async function DashboardPage({
-
-
-
- +
diff --git a/frontend/app/[locale]/page.tsx b/frontend/app/[locale]/page.tsx index a57e9bda..e14bdcac 100644 --- a/frontend/app/[locale]/page.tsx +++ b/frontend/app/[locale]/page.tsx @@ -19,6 +19,8 @@ export async function generateMetadata({ }; const ogLocale = localeMap[locale] ?? 'en_US'; + const ogTitle = t('subtitle'); + return { title: t('title'), description: t('description'), @@ -31,7 +33,7 @@ export async function generateMetadata({ }, }, openGraph: { - title: t('title'), + title: ogTitle, description: t('description'), url: canonicalUrl, siteName: 'DevLovers', @@ -48,7 +50,7 @@ export async function generateMetadata({ }, twitter: { card: 'summary_large_image', - title: t('title'), + title: ogTitle, description: t('description'), images: ['/og.png'], }, diff --git a/frontend/app/[locale]/quiz/[slug]/page.tsx b/frontend/app/[locale]/quiz/[slug]/page.tsx index f7a05cda..47145586 100644 --- a/frontend/app/[locale]/quiz/[slug]/page.tsx +++ b/frontend/app/[locale]/quiz/[slug]/page.tsx @@ -1,12 +1,11 @@ import type { Metadata } from 'next'; +import Image from 'next/image'; import { notFound } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; -import Image from 'next/image'; +import { QuizContainer } from '@/components/quiz/QuizContainer'; import { categoryTabStyles } from '@/data/categoryStyles'; import { cn } from '@/lib/utils'; - -import { QuizContainer } from '@/components/quiz/QuizContainer'; import { stripCorrectAnswers } from '@/db/queries/quiz'; import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz'; import { getCurrentUser } from '@/lib/auth'; @@ -53,10 +52,9 @@ export default async function QuizPage({ notFound(); } - const categoryStyle = - quiz.categorySlug && quiz.categorySlug in categoryTabStyles - ? categoryTabStyles[quiz.categorySlug as keyof typeof categoryTabStyles] - : null; + const categoryStyle = quiz.categorySlug + ? categoryTabStyles[quiz.categorySlug as keyof typeof categoryTabStyles] + : null; const parsedSeed = seedParam ? Number.parseInt(seedParam, 10) : Number.NaN; const seed = Number.isFinite(parsedSeed) @@ -84,9 +82,15 @@ export default async function QuizPage({ )} diff --git a/frontend/app/[locale]/shop/orders/[id]/page.tsx b/frontend/app/[locale]/shop/orders/[id]/page.tsx index 4a730309..cbf61d2d 100644 --- a/frontend/app/[locale]/shop/orders/[id]/page.tsx +++ b/frontend/app/[locale]/shop/orders/[id]/page.tsx @@ -29,19 +29,16 @@ export const metadata: Metadata = { export const dynamic = 'force-dynamic'; type OrderCurrency = (typeof orders.$inferSelect)['currency']; +type OrderPaymentStatus = (typeof orders.$inferSelect)['paymentStatus']; +type OrderPaymentProvider = (typeof orders.$inferSelect)['paymentProvider']; type OrderDetail = { id: string; userId: string | null; totalAmount: string; currency: OrderCurrency; - paymentStatus: - | 'pending' - | 'requires_payment' - | 'paid' - | 'failed' - | 'refunded'; - paymentProvider: string; + paymentStatus: OrderPaymentStatus; + paymentProvider: OrderPaymentProvider; paymentIntentId: string | null; stockRestored: boolean; restockedAt: string | null; diff --git a/frontend/app/api/shop/admin/orders/[id]/cancel-payment/route.ts b/frontend/app/api/shop/admin/orders/[id]/cancel-payment/route.ts new file mode 100644 index 00000000..b76ca561 --- /dev/null +++ b/frontend/app/api/shop/admin/orders/[id]/cancel-payment/route.ts @@ -0,0 +1,214 @@ +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, + PspUnavailableError, +} from '@/lib/services/errors'; +import { cancelMonobankUnpaidPayment } from '@/lib/services/orders/monobank-cancel-payment'; +import { orderIdParamSchema, orderSummarySchema } 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 invalidPayloadStatus(error: InvalidPayloadError): number { + if ( + error.code === 'CANCEL_PROVIDER_NOT_MONOBANK' || + error.code === 'CANCEL_NOT_ALLOWED' || + error.code === 'CANCEL_DISABLED' || + error.code === 'CANCEL_MISSING_PROVIDER_REF' || + error.code === 'CANCEL_IN_PROGRESS' + ) { + return 409; + } + + return 400; +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const startedAtMs = Date.now(); + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + logWarn('admin_orders_cancel_payment_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + durationMs: Date.now() - startedAtMs, + }); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let orderIdForLog: string | null = null; + + try { + await requireAdminApi(request); + + const csrfRes = requireAdminCsrf(request, 'admin:orders:cancel-payment'); + if (csrfRes) { + logWarn('admin_orders_cancel_payment_csrf_rejected', { + ...baseMeta, + code: 'CSRF_REJECTED', + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } + + const rawParams = await context.params; + const parsed = orderIdParamSchema.safeParse(rawParams); + if (!parsed.success) { + logWarn('admin_orders_cancel_payment_invalid_order_id', { + ...baseMeta, + code: 'INVALID_ORDER_ID', + issuesCount: parsed.error.issues?.length ?? 0, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, + { status: 400 } + ); + } + + orderIdForLog = parsed.data.id; + const result = await cancelMonobankUnpaidPayment({ + orderId: orderIdForLog, + requestId, + }); + + const orderSummary = orderSummarySchema.parse(result.order); + + return noStoreJson({ + success: true, + order: { + ...orderSummary, + createdAt: + orderSummary.createdAt instanceof Date + ? orderSummary.createdAt.toISOString() + : String(orderSummary.createdAt), + }, + cancel: { + ...result.cancel, + }, + deduped: result.cancel.deduped, + }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + logWarn('admin_orders_cancel_payment_admin_api_disabled', { + ...baseMeta, + code: 'ADMIN_API_DISABLED', + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( + { code: 'ADMIN_API_DISABLED', message: 'Admin API is disabled.' }, + { status: 403 } + ); + } + + if (error instanceof AdminUnauthorizedError) { + logWarn('admin_orders_cancel_payment_unauthorized', { + ...baseMeta, + code: error.code, + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( + { code: error.code, message: 'Unauthorized.' }, + { status: 401 } + ); + } + + if (error instanceof AdminForbiddenError) { + logWarn('admin_orders_cancel_payment_forbidden', { + ...baseMeta, + code: error.code, + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( + { code: error.code, message: 'Forbidden.' }, + { status: 403 } + ); + } + + if (error instanceof OrderNotFoundError) { + logWarn('admin_orders_cancel_payment_not_found', { + ...baseMeta, + code: error.code, + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( + { code: error.code, message: error.message }, + { status: 404 } + ); + } + + if (error instanceof InvalidPayloadError) { + logWarn('admin_orders_cancel_payment_invalid_payload', { + ...baseMeta, + code: error.code, + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( + { code: error.code, message: error.message }, + { status: invalidPayloadStatus(error) } + ); + } + + if (error instanceof PspUnavailableError) { + logWarn('admin_orders_cancel_payment_psp_unavailable', { + ...baseMeta, + code: 'PSP_UNAVAILABLE', + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( + { code: 'PSP_UNAVAILABLE', message: 'Payment provider unavailable.' }, + { status: 503 } + ); + } + + logError('admin_orders_cancel_payment_failed', error, { + ...baseMeta, + code: 'ADMIN_CANCEL_PAYMENT_FAILED', + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to cancel payment.' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts index 0687fbd0..a560f375 100644 --- a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts @@ -1,17 +1,25 @@ import crypto from 'node:crypto'; +import { eq } from 'drizzle-orm'; import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { orders } from '@/db/schema'; import { AdminApiDisabledError, AdminForbiddenError, AdminUnauthorizedError, requireAdminApi, } from '@/lib/auth/admin'; +import { getMonobankConfig } from '@/lib/env/monobank'; 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 { + InvalidPayloadError, + OrderNotFoundError, + PspUnavailableError, +} from '@/lib/services/errors'; import { refundOrder } from '@/lib/services/orders'; import { orderIdParamSchema, orderSummarySchema } from '@/lib/validation/shop'; @@ -76,12 +84,62 @@ export async function POST( }); return noStoreJson( - { error: 'Invalid order id', code: 'INVALID_ORDER_ID' }, + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, { status: 400 } ); } orderIdForLog = parsed.data.id; + const [targetOrder] = await db + .select({ paymentProvider: orders.paymentProvider }) + .from(orders) + .where(eq(orders.id, orderIdForLog)) + .limit(1); + + if (targetOrder?.paymentProvider === 'monobank') { + const { refundEnabled } = getMonobankConfig(); + if (!refundEnabled) { + logWarn('admin_orders_refund_disabled', { + ...baseMeta, + code: 'REFUND_DISABLED', + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { code: 'REFUND_DISABLED', message: 'Refunds are disabled.' }, + { status: 409 } + ); + } + } + + if (targetOrder?.paymentProvider === 'monobank') { + const { requestMonobankFullRefund } = await import( + '@/lib/services/orders/monobank-refund' + ); + const result = await requestMonobankFullRefund({ + orderId: orderIdForLog, + requestId, + }); + + const orderSummary = orderSummarySchema.parse(result.order); + + return noStoreJson({ + success: true, + order: { + ...orderSummary, + createdAt: + orderSummary.createdAt instanceof Date + ? orderSummary.createdAt.toISOString() + : String(orderSummary.createdAt), + }, + refund: { + ...result.refund, + deduped: result.deduped, + }, + }); + } + const order = await refundOrder(orderIdForLog, { requestedBy: 'admin' }); const orderSummary = orderSummarySchema.parse(order); @@ -104,8 +162,10 @@ export async function POST( orderId: orderIdForLog, durationMs: Date.now() - startedAtMs, }); - - return noStoreJson({ code: 'ADMIN_API_DISABLED' }, { status: 403 }); + return noStoreJson( + { code: 'ADMIN_API_DISABLED', message: 'Admin API is disabled.' }, + { status: 403 } + ); } if (error instanceof AdminUnauthorizedError) { @@ -115,7 +175,10 @@ export async function POST( orderId: orderIdForLog, durationMs: Date.now() - startedAtMs, }); - return noStoreJson({ code: error.code }, { status: 401 }); + return noStoreJson( + { code: error.code, message: 'Unauthorized.' }, + { status: 401 } + ); } if (error instanceof AdminForbiddenError) { @@ -126,7 +189,10 @@ export async function POST( durationMs: Date.now() - startedAtMs, }); - return noStoreJson({ code: error.code }, { status: 403 }); + return noStoreJson( + { code: error.code, message: 'Forbidden.' }, + { status: 403 } + ); } if (error instanceof OrderNotFoundError) { @@ -138,7 +204,7 @@ export async function POST( }); return noStoreJson( - { error: error.message, code: error.code }, + { code: error.code, message: error.message }, { status: 404 } ); } @@ -152,11 +218,25 @@ export async function POST( }); return noStoreJson( - { error: error.message, code: error.code }, + { code: error.code, message: error.message }, { status: 400 } ); } + if (error instanceof PspUnavailableError) { + logWarn('admin_orders_refund_psp_unavailable', { + ...baseMeta, + code: 'PSP_UNAVAILABLE', + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { code: 'PSP_UNAVAILABLE', message: 'Payment provider unavailable.' }, + { status: 503 } + ); + } + logError('admin_orders_refund_failed', error, { ...baseMeta, orderId: orderIdForLog, @@ -165,7 +245,7 @@ export async function POST( }); return noStoreJson( - { error: 'Unable to refund order', code: 'INTERNAL_ERROR' }, + { code: 'INTERNAL_ERROR', message: 'Unable to refund order.' }, { status: 500 } ); } diff --git a/frontend/app/api/shop/admin/orders/[id]/route.ts b/frontend/app/api/shop/admin/orders/[id]/route.ts index 9a67a973..6507132d 100644 --- a/frontend/app/api/shop/admin/orders/[id]/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/route.ts @@ -52,7 +52,7 @@ export async function GET( }); return noStoreJson( - { error: 'Invalid order id', code: 'INVALID_ORDER_ID' }, + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, { status: 400 } ); } @@ -70,7 +70,7 @@ export async function GET( }); return noStoreJson( - { error: 'Order not found', code: 'ORDER_NOT_FOUND' }, + { code: 'ORDER_NOT_FOUND', message: 'Order not found.' }, { status: 404 } ); } @@ -97,7 +97,10 @@ export async function GET( orderId: orderIdForLog, durationMs: Date.now() - startedAtMs, }); - return noStoreJson({ code: error.code }, { status: 403 }); + return noStoreJson( + { code: error.code, message: 'Admin API is disabled.' }, + { status: 403 } + ); } if (error instanceof AdminUnauthorizedError) { @@ -107,7 +110,10 @@ export async function GET( orderId: orderIdForLog, durationMs: Date.now() - startedAtMs, }); - return noStoreJson({ code: error.code }, { status: 401 }); + return noStoreJson( + { code: error.code, message: 'Unauthorized.' }, + { status: 401 } + ); } if (error instanceof AdminForbiddenError) { @@ -117,7 +123,10 @@ export async function GET( orderId: orderIdForLog, durationMs: Date.now() - startedAtMs, }); - return noStoreJson({ code: error.code }, { status: 403 }); + return noStoreJson( + { code: error.code, message: 'Forbidden.' }, + { status: 403 } + ); } logError('admin_order_detail_failed', error, { @@ -128,7 +137,7 @@ export async function GET( }); return noStoreJson( - { error: 'internal_error', code: 'INTERNAL_ERROR' }, + { code: 'INTERNAL_ERROR', message: 'Internal error.' }, { status: 500 } ); } diff --git a/frontend/app/api/shop/catalog/route.ts b/frontend/app/api/shop/catalog/route.ts index e7faff91..35a5f8b9 100644 --- a/frontend/app/api/shop/catalog/route.ts +++ b/frontend/app/api/shop/catalog/route.ts @@ -54,7 +54,6 @@ export async function GET(request: NextRequest) { const { locale, filter, ...rest } = raw; const effectiveLocale = normalizeLocale(locale); - // legacy support: ?filter=new => sort=newest (only if sort not provided) const normalizedRest: Record = { ...rest }; if (filter === 'new' && !normalizedRest.sort) { normalizedRest.sort = 'newest'; diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index 2d8e4c5b..0cfcb0f5 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -4,8 +4,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { MoneyValueError } from '@/db/queries/shop/orders'; import { getCurrentUser } from '@/lib/auth'; -import { isPaymentsEnabled } from '@/lib/env/stripe'; -import { logError, logWarn } from '@/lib/logging'; +import { isMonobankEnabled } from '@/lib/env/monobank'; +import { logError, logInfo, logWarn } from '@/lib/logging'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; import { enforceRateLimit, @@ -19,6 +19,7 @@ import { InvalidVariantError, OrderStateInvalidError, PriceConfigError, + PspUnavailableError, } from '@/lib/services/errors'; import { createOrderWithItems, restockOrder } from '@/lib/services/orders'; import { @@ -27,11 +28,14 @@ import { } from '@/lib/services/orders/payment-attempts'; import { type PaymentProvider, type PaymentStatus } from '@/lib/shop/payments'; import { resolveRequestLocale } from '@/lib/shop/request-locale'; +import { createStatusToken } from '@/lib/shop/status-token'; import { checkoutPayloadSchema, idempotencyKeySchema, } from '@/lib/validation/shop'; +type CheckoutRequestedProvider = 'stripe' | 'monobank'; + const EXPECTED_BUSINESS_ERROR_CODES = new Set([ 'IDEMPOTENCY_CONFLICT', 'INVALID_PAYLOAD', @@ -41,6 +45,50 @@ const EXPECTED_BUSINESS_ERROR_CODES = new Set([ 'PAYMENT_ATTEMPTS_EXHAUSTED', ]); +function parseRequestedProvider( + raw: unknown +): CheckoutRequestedProvider | 'invalid' | null { + if (raw === null || raw === undefined) return null; + if (typeof raw !== 'string') return 'invalid'; + + const normalized = raw.trim().toLowerCase(); + if (!normalized) return 'invalid'; + + if (normalized === 'stripe' || normalized === 'monobank') { + return normalized; + } + + return 'invalid'; +} + +function isMonoAlias(raw: unknown): boolean { + if (typeof raw !== 'string') return false; + return raw.trim().toLowerCase() === 'mono'; +} + +function stripMonobankClientMoneyFields(payload: unknown): unknown { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return payload; + } + + const { + amount, + amountMinor, + totalAmount, + totalAmountMinor, + currency, + ...rest + } = payload as Record; + + void amount; + void amountMinor; + void totalAmount; + void totalAmountMinor; + void currency; + + return rest; +} + function getErrorCode(err: unknown): string | null { if (typeof err !== 'object' || err === null) return null; @@ -48,6 +96,102 @@ function getErrorCode(err: unknown): string | null { return typeof e.code === 'string' ? e.code : null; } +function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) return error.message; + return fallback; +} + +function isMonobankInvalidRequestError(error: unknown): boolean { + const code = getErrorCode(error); + + if (error instanceof InvalidPayloadError) return true; + if (error instanceof InvalidVariantError) return true; + if (code === 'INVALID_PAYLOAD' || code === 'INVALID_VARIANT') return true; + + if (!error || typeof error !== 'object') return false; + + const maybeIssues = (error as { issues?: unknown }).issues; + if (Array.isArray(maybeIssues)) return true; + + const maybeName = (error as { name?: unknown }).name; + if (typeof maybeName === 'string' && maybeName === 'ZodError') return true; + + return false; +} + +function mapMonobankCheckoutError(error: unknown) { + const code = getErrorCode(error); + + if (isMonobankInvalidRequestError(error)) { + return { + code: 'INVALID_REQUEST', + message: getErrorMessage(error, 'Invalid request.'), + status: 400, + } as const; + } + + if ( + error instanceof InsufficientStockError || + code === 'INSUFFICIENT_STOCK' || + code === 'OUT_OF_STOCK' + ) { + return { + code: 'OUT_OF_STOCK', + message: getErrorMessage(error, 'Insufficient stock.'), + status: 409, + } as const; + } + + if (error instanceof PriceConfigError || code === 'PRICE_CONFIG_ERROR') { + return { + code: 'PRICE_CONFIG_ERROR', + message: getErrorMessage(error, 'Price configuration error.'), + status: 422, + details: + error instanceof PriceConfigError + ? { + productId: error.productId, + currency: error.currency, + } + : undefined, + } as const; + } + + if ( + error instanceof PspUnavailableError || + code === 'PSP_UNAVAILABLE' || + code === 'PSP_INVOICE_PERSIST_FAILED' + ) { + return { + code: 'PSP_UNAVAILABLE', + message: 'Payment provider unavailable.', + status: 503, + } as const; + } + + if ( + error instanceof IdempotencyConflictError || + code === 'IDEMPOTENCY_CONFLICT' + ) { + return { + code: 'CHECKOUT_IDEMPOTENCY_CONFLICT', + message: + error instanceof IdempotencyConflictError + ? error.message + : 'Checkout idempotency conflict.', + status: 409, + details: + error instanceof IdempotencyConflictError ? error.details : undefined, + } as const; + } + + return { + code: 'CHECKOUT_FAILED', + message: 'Unable to process checkout.', + status: 500, + } as const; +} + function isExpectedBusinessError(err: unknown): boolean { const code = getErrorCode(err); if (code && EXPECTED_BUSINESS_ERROR_CODES.has(code)) return true; @@ -136,6 +280,131 @@ function buildCheckoutResponse({ return res; } +function buildMonobankCheckoutResponse({ + order, + itemCount, + status, + attemptId, + pageUrl, + currency, + totalAmountMinor, +}: { + order: CheckoutOrderShape; + itemCount: number; + status: number; + attemptId: string; + pageUrl: string; + currency: 'UAH'; + totalAmountMinor: number; +}) { + const res = NextResponse.json( + { + success: true, + order: { + id: order.id, + currency: order.currency, + totalAmount: order.totalAmount, + itemCount, + paymentStatus: order.paymentStatus, + paymentProvider: order.paymentProvider, + paymentIntentId: order.paymentIntentId, + clientSecret: null, + }, + orderId: order.id, + paymentStatus: order.paymentStatus, + paymentProvider: order.paymentProvider, + paymentIntentId: order.paymentIntentId, + clientSecret: null, + attemptId, + pageUrl, + provider: 'mono' as const, + currency, + totalAmountMinor, + }, + { status } + ); + + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +async function runMonobankCheckoutFlow(args: { + order: CheckoutOrderShape; + itemCount: number; + status: number; + requestId: string; + totalCents: number; + orderMeta: Record; +}) { + try { + logInfo('monobank_lazy_import_invoked', { + requestId: args.requestId, + orderId: args.order.id, + }); + + const { createMonobankAttemptAndInvoice } = + await import('@/lib/services/orders/monobank'); + + const statusToken = createStatusToken({ orderId: args.order.id }); + + const monobankAttempt = await createMonobankAttemptAndInvoice({ + orderId: args.order.id, + statusToken, + requestId: args.requestId, + }); + + if (args.totalCents !== monobankAttempt.totalAmountMinor) { + logError( + 'checkout_mono_amount_mismatch', + new Error('Monobank amount mismatch'), + { + ...args.orderMeta, + code: 'MONO_AMOUNT_MISMATCH', + totalCents: args.totalCents, + totalAmountMinor: monobankAttempt.totalAmountMinor, + } + ); + + return errorResponse( + 'CHECKOUT_FAILED', + 'Unable to process checkout.', + 500 + ); + } + + return buildMonobankCheckoutResponse({ + order: args.order, + itemCount: args.itemCount, + status: args.status, + attemptId: monobankAttempt.attemptId, + pageUrl: monobankAttempt.pageUrl, + currency: monobankAttempt.currency, + totalAmountMinor: monobankAttempt.totalAmountMinor, + }); + } catch (error) { + const mapped = mapMonobankCheckoutError(error); + + if (mapped.status >= 500) { + logError('checkout_mono_flow_failed', error, { + ...args.orderMeta, + code: mapped.code, + }); + } else { + logWarn('checkout_mono_flow_rejected', { + ...args.orderMeta, + code: mapped.code, + }); + } + + return errorResponse( + mapped.code, + mapped.message, + mapped.status, + mapped.details + ); + } +} + function getSessionUserId(user: unknown): string | null { if (!user || typeof user !== 'object') return null; @@ -196,6 +465,16 @@ export async function POST(request: NextRequest) { ); } + let monobankRequestHint = false; + if (body && typeof body === 'object' && !Array.isArray(body)) { + const { paymentProvider, provider } = body as Record; + const rawProvider = paymentProvider ?? provider; + const parsedProvider = parseRequestedProvider(rawProvider); + monobankRequestHint = + parsedProvider === 'monobank' || + (parsedProvider === 'invalid' && isMonoAlias(rawProvider)); + } + const idempotencyKey = getIdempotencyKey(request); if (idempotencyKey === null) { @@ -204,6 +483,14 @@ export async function POST(request: NextRequest) { code: 'MISSING_IDEMPOTENCY_KEY', }); + if (monobankRequestHint) { + return errorResponse( + 'INVALID_REQUEST', + 'Idempotency-Key header is required.', + 400 + ); + } + return errorResponse( 'MISSING_IDEMPOTENCY_KEY', 'Idempotency-Key header is required.', @@ -217,6 +504,15 @@ export async function POST(request: NextRequest) { code: 'INVALID_IDEMPOTENCY_KEY', }); + if (monobankRequestHint) { + return errorResponse( + 'INVALID_REQUEST', + 'Idempotency key must be 16-128 chars and contain only A-Z a-z 0-9 _ -.', + 400, + idempotencyKey.format?.() + ); + } + return errorResponse( 'INVALID_IDEMPOTENCY_KEY', 'Idempotency key must be 16-128 chars and contain only A-Z a-z 0-9 _ -.', @@ -232,9 +528,104 @@ export async function POST(request: NextRequest) { idempotencyKey: idempotencyKeyShort, }; - const parsedPayload = checkoutPayloadSchema.safeParse(body); + let requestedProvider: CheckoutRequestedProvider | null = null; + let payloadForValidation: unknown = body; + + if (body && typeof body === 'object' && !Array.isArray(body)) { + const { paymentProvider, provider, ...rest } = body as Record< + string, + unknown + >; + const rawProvider = paymentProvider ?? provider; + const parsedProvider = parseRequestedProvider(rawProvider); + + if (parsedProvider === 'invalid') { + if (isMonoAlias(rawProvider)) { + return errorResponse('INVALID_REQUEST', 'Invalid request.', 422); + } + + return errorResponse( + 'PAYMENTS_PROVIDER_INVALID', + 'Invalid payment provider.', + 422 + ); + } + + requestedProvider = parsedProvider; + payloadForValidation = rest; + } + + const selectedProvider: CheckoutRequestedProvider = + requestedProvider ?? 'stripe'; + if (selectedProvider === 'monobank') { + payloadForValidation = stripMonobankClientMoneyFields(payloadForValidation); + } + + const paymentsEnabled = + (process.env.PAYMENTS_ENABLED ?? '').trim() === 'true'; + + const rawStripePaymentsEnabled = ( + process.env.STRIPE_PAYMENTS_ENABLED ?? '' + ).trim(); + const stripePaymentsEnabled = + rawStripePaymentsEnabled.length > 0 + ? rawStripePaymentsEnabled === 'true' + : paymentsEnabled; + + if (selectedProvider === 'monobank') { + let enabled = false; + + try { + enabled = isMonobankEnabled(); + } catch (error) { + logError('monobank_env_invalid', error, { + ...baseMeta, + code: 'MONOBANK_ENV_INVALID', + }); + enabled = false; + } + + if (!enabled) { + logWarn('provider_disabled', { + requestedProvider: 'monobank', + requestId, + }); + + return errorResponse('INVALID_REQUEST', 'Invalid request.', 422); + } + + if (!paymentsEnabled) { + logWarn('monobank_payments_disabled', { + ...baseMeta, + code: 'PAYMENTS_DISABLED', + }); + + return errorResponse( + 'PSP_UNAVAILABLE', + 'Payment provider unavailable.', + 503 + ); + } + } + + const parsedPayload = checkoutPayloadSchema.safeParse(payloadForValidation); if (!parsedPayload.success) { + if (selectedProvider === 'monobank') { + logWarn('checkout_invalid_request', { + ...meta, + code: 'INVALID_REQUEST', + issuesCount: parsedPayload.error.issues?.length ?? 0, + }); + + return errorResponse( + 'INVALID_REQUEST', + 'Invalid request.', + 400, + parsedPayload.error.format() + ); + } + logWarn('checkout_invalid_payload', { ...meta, code: 'INVALID_PAYLOAD', @@ -278,6 +669,14 @@ export async function POST(request: NextRequest) { code: 'USER_ID_NOT_ALLOWED', }); + if (selectedProvider === 'monobank') { + return errorResponse( + 'INVALID_REQUEST', + 'userId is not allowed for guest checkout.', + 400 + ); + } + return errorResponse( 'USER_ID_NOT_ALLOWED', 'userId is not allowed for guest checkout.', @@ -291,6 +690,14 @@ export async function POST(request: NextRequest) { code: 'USER_MISMATCH', }); + if (selectedProvider === 'monobank') { + return errorResponse( + 'INVALID_REQUEST', + 'Authenticated user does not match payload userId.', + 400 + ); + } + return errorResponse( 'USER_MISMATCH', 'Authenticated user does not match payload userId.', @@ -334,23 +741,13 @@ export async function POST(request: NextRequest) { }); } - const paymentsEnabled = isPaymentsEnabled(); - - if (!paymentsEnabled) { - logWarn('checkout_payments_disabled', { - ...authMeta, - code: 'PAYMENTS_DISABLED', - }); - - return errorResponse('PAYMENTS_DISABLED', 'Payments are disabled.', 503); - } - try { const result = await createOrderWithItems({ items, idempotencyKey, userId: sessionUserId, locale, + paymentProvider: selectedProvider === 'monobank' ? 'monobank' : undefined, }); const { order } = result; @@ -362,8 +759,26 @@ export async function POST(request: NextRequest) { paymentIntentId: order.paymentIntentId ?? null, }; - const stripePaymentFlow = - paymentsEnabled && order.paymentProvider === 'stripe'; + const orderProvider = order.paymentProvider as unknown as + | 'stripe' + | 'monobank' + | 'none'; + + const stripePaymentFlow = orderProvider === 'stripe'; + const monobankPaymentFlow = orderProvider === 'monobank'; + + if (stripePaymentFlow && !stripePaymentsEnabled) { + logWarn('checkout_stripe_payments_disabled', { + ...orderMeta, + code: 'PAYMENTS_DISABLED', + }); + + return errorResponse( + 'PSP_UNAVAILABLE', + 'Payment provider unavailable.', + 503 + ); + } if (!result.isNew) { if (stripePaymentFlow) { @@ -450,6 +865,23 @@ export async function POST(request: NextRequest) { ); } } + if (monobankPaymentFlow) { + return runMonobankCheckoutFlow({ + order: { + id: order.id, + currency: order.currency, + totalAmount: order.totalAmount, + paymentStatus: order.paymentStatus, + paymentProvider: order.paymentProvider, + paymentIntentId: order.paymentIntentId ?? null, + }, + itemCount, + status: 200, + requestId, + totalCents: result.totalCents, + orderMeta, + }); + } return buildCheckoutResponse({ order: { @@ -466,6 +898,24 @@ export async function POST(request: NextRequest) { }); } + if (monobankPaymentFlow) { + return runMonobankCheckoutFlow({ + order: { + id: order.id, + currency: order.currency, + totalAmount: order.totalAmount, + paymentStatus: order.paymentStatus, + paymentProvider: order.paymentProvider, + paymentIntentId: order.paymentIntentId ?? null, + }, + itemCount, + status: 201, + requestId, + totalCents: result.totalCents, + orderMeta, + }); + } + if (!stripePaymentFlow) { return buildCheckoutResponse({ order: { @@ -587,6 +1037,16 @@ export async function POST(request: NextRequest) { }); } + if (selectedProvider === 'monobank') { + const mapped = mapMonobankCheckoutError(error); + return errorResponse( + mapped.code, + mapped.message, + mapped.status, + mapped.details + ); + } + if (error instanceof InvalidPayloadError) { return errorResponse( error.code, @@ -624,6 +1084,17 @@ export async function POST(request: NextRequest) { }); } + if ( + error instanceof PspUnavailableError || + getErrorCode(error) === 'PSP_UNAVAILABLE' + ) { + return errorResponse( + 'PSP_UNAVAILABLE', + 'Payment provider unavailable.', + 503 + ); + } + if (error instanceof InsufficientStockError) { return errorResponse('INSUFFICIENT_STOCK', error.message, 409); } diff --git a/frontend/app/api/shop/orders/[id]/route.ts b/frontend/app/api/shop/orders/[id]/route.ts index 4708903d..65ba63b4 100644 --- a/frontend/app/api/shop/orders/[id]/route.ts +++ b/frontend/app/api/shop/orders/[id]/route.ts @@ -19,17 +19,13 @@ function noStoreJson(body: unknown, init?: { status?: number }) { return res; } type OrderCurrency = (typeof orders.$inferSelect)['currency']; +type OrderPaymentStatus = (typeof orders.$inferSelect)['paymentStatus']; type OrderDetailResponse = { id: string; userId: string | null; totalAmount: string; currency: OrderCurrency; - paymentStatus: - | 'pending' - | 'requires_payment' - | 'paid' - | 'failed' - | 'refunded'; + paymentStatus: OrderPaymentStatus; paymentProvider: string; paymentIntentId: string | null; stockRestored: boolean; diff --git a/frontend/app/api/shop/orders/[id]/status/route.ts b/frontend/app/api/shop/orders/[id]/status/route.ts new file mode 100644 index 00000000..42961f50 --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/status/route.ts @@ -0,0 +1,147 @@ +import 'server-only'; + +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 { + OrderNotFoundError, + OrderStateInvalidError, +} from '@/lib/services/errors'; +import { getOrderAttemptSummary, getOrderSummary } from '@/lib/services/orders/summary'; +import { verifyStatusToken } from '@/lib/shop/status-token'; +import { orderIdParamSchema } from '@/lib/validation/shop'; + +export const dynamic = 'force-dynamic'; + +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 async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const startedAtMs = Date.now(); + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const rawParams = await context.params; + const parsed = orderIdParamSchema.safeParse(rawParams); + if (!parsed.success) { + logWarn('order_status_invalid_order_id', { + requestId, + code: 'INVALID_ORDER_ID', + orderId: null, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: 'INVALID_ORDER_ID' }, { status: 400 }); + } + + const orderId = parsed.data.id; + const statusToken = request.nextUrl.searchParams.get('statusToken'); + + try { + const user = await getCurrentUser(); + let authorized = false; + + if (user) { + const isAdmin = user.role === 'admin'; + if (isAdmin) { + authorized = true; + } else { + const [row] = await db + .select({ id: orders.id }) + .from(orders) + .where(and(eq(orders.id, orderId), eq(orders.userId, user.id))) + .limit(1); + if (row) authorized = true; + } + } + + if (!authorized) { + if (!statusToken || !statusToken.trim()) { + const status = user ? 403 : 401; + const code = user ? 'FORBIDDEN' : 'STATUS_TOKEN_REQUIRED'; + logWarn('order_status_access_denied', { + requestId, + orderId, + code, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code }, { status }); + } + + const tokenResult = verifyStatusToken({ + token: statusToken, + orderId, + }); + if (!tokenResult.ok) { + if (tokenResult.reason === 'missing_secret') { + logError( + 'order_status_token_misconfigured', + new Error('SHOP_STATUS_TOKEN_SECRET is not configured'), + { + requestId, + orderId, + code: 'STATUS_TOKEN_MISCONFIGURED', + durationMs: Date.now() - startedAtMs, + } + ); + return noStoreJson( + { code: 'STATUS_TOKEN_MISCONFIGURED' }, + { status: 500 } + ); + } + + logWarn('order_status_token_invalid', { + requestId, + orderId, + code: 'STATUS_TOKEN_INVALID', + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: 'STATUS_TOKEN_INVALID' }, { status: 403 }); + } + } + + const order = await getOrderSummary(orderId); + const attempt = await getOrderAttemptSummary(orderId); + return noStoreJson({ success: true, order, attempt }, { status: 200 }); + } catch (error) { + if (error instanceof OrderNotFoundError) { + logWarn('order_status_not_found', { + requestId, + code: 'ORDER_NOT_FOUND', + orderId, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: 'ORDER_NOT_FOUND' }, { status: 404 }); + } + + if (error instanceof OrderStateInvalidError) { + logError('order_status_state_invalid', error, { + requestId, + code: 'INTERNAL_ERROR', + orderId, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: 'INTERNAL_ERROR' }, { status: 500 }); + } + + logError('order_status_failed', error, { + requestId, + code: 'ORDER_STATUS_FAILED', + orderId, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson({ code: 'INTERNAL_ERROR' }, { status: 500 }); + } +} diff --git a/frontend/app/api/shop/webhooks/monobank/route.ts b/frontend/app/api/shop/webhooks/monobank/route.ts new file mode 100644 index 00000000..7e2cbc02 --- /dev/null +++ b/frontend/app/api/shop/webhooks/monobank/route.ts @@ -0,0 +1,151 @@ +import 'server-only'; + +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; + +import { getMonobankConfig } from '@/lib/env/monobank'; +import { logError, logInfo, logWarn } from '@/lib/logging'; +import { verifyWebhookSignatureWithRefresh } from '@/lib/psp/monobank'; +import { handleMonobankWebhook } from '@/lib/services/orders/monobank-webhook'; + +export const dynamic = 'force-dynamic'; + +type WebhookMode = 'drop' | 'store' | 'apply'; + +function parseWebhookMode(raw: unknown): WebhookMode { + const v = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (v === 'drop' || v === 'store' || v === 'apply') return v; + return 'apply'; +} + +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 parseWebhookPayload( + rawBodyBytes: Buffer +): Record | null { + const rawBody = rawBodyBytes.toString('utf8').replace(/^\uFEFF/, ''); + + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + return parsed as Record; +} + +export async function POST(request: NextRequest) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let webhookMode: WebhookMode = parseWebhookMode( + process.env.MONO_WEBHOOK_MODE + ); + if (!process.env.MONO_WEBHOOK_MODE) { + try { + webhookMode = parseWebhookMode(getMonobankConfig().webhookMode); + } catch (error) { + logError('monobank_webhook_mode_invalid', error, { + ...baseMeta, + code: 'MONO_WEBHOOK_MODE_INVALID', + }); + webhookMode = 'apply'; + } + } + + let rawBodyBytes: Buffer; + try { + rawBodyBytes = Buffer.from(await request.arrayBuffer()); + } catch (error) { + logError('monobank_webhook_body_read_failed', error, { + ...baseMeta, + code: 'MONO_BODY_READ_FAILED', + }); + return noStoreJson({ ok: true }, { status: 200 }); + } + + const rawSha256 = crypto + .createHash('sha256') + .update(rawBodyBytes) + .digest('hex'); + const eventKey = rawSha256; + const signature = request.headers.get('x-sign'); + + let validSignature = false; + try { + validSignature = await verifyWebhookSignatureWithRefresh({ + rawBodyBytes, + signature, + }); + } catch (error) { + logError('monobank_webhook_signature_error', error, { + ...baseMeta, + code: 'MONO_SIG_INVALID', + rawSha256, + }); + } + + if (!validSignature) { + logWarn('monobank_webhook_signature_invalid', { + ...baseMeta, + code: 'MONO_SIG_INVALID', + rawSha256, + }); + return noStoreJson({ ok: true }, { status: 200 }); + } + + const parsedPayload = parseWebhookPayload(rawBodyBytes); + if (!parsedPayload) { + logWarn('monobank_webhook_payload_invalid', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + eventKey, + rawSha256, + }); + return noStoreJson({ ok: true }, { status: 200 }); + } + + try { + const result = await handleMonobankWebhook({ + rawBodyBytes, + parsedPayload, + eventKey, + requestId, + mode: webhookMode, + }); + + logInfo('monobank_webhook_processed', { + ...baseMeta, + eventKey, + rawSha256, + invoiceId: result.invoiceId, + appliedResult: result.appliedResult, + deduped: result.deduped, + }); + } catch (error) { + logError('monobank_webhook_apply_failed', error, { + ...baseMeta, + code: 'WEBHOOK_APPLY_FAILED', + eventKey, + rawSha256, + }); + } + + return noStoreJson({ ok: true }, { status: 200 }); +} diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx new file mode 100644 index 00000000..4f9c8a9d --- /dev/null +++ b/frontend/app/global-error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/frontend/components/leaderboard/LeaderboardPodium.tsx b/frontend/components/leaderboard/LeaderboardPodium.tsx index 92fc89c1..39470c59 100644 --- a/frontend/components/leaderboard/LeaderboardPodium.tsx +++ b/frontend/components/leaderboard/LeaderboardPodium.tsx @@ -2,11 +2,11 @@ import { motion } from 'framer-motion'; import { Crown } from 'lucide-react'; -import Image from 'next/image'; import { cn } from '@/lib/utils'; import { User } from './types'; +import { UserAvatar } from './UserAvatar'; const rankConfig = { 1: { @@ -52,7 +52,7 @@ export function LeaderboardPodium({ topThree }: { topThree: User[] }) { ].filter(Boolean) as User[]; return ( -
+
{podiumOrder.map(user => { const rank = user.rank as 1 | 2 | 3; const isFirst = rank === 1; @@ -86,11 +86,11 @@ export function LeaderboardPodium({ topThree }: { topThree: User[] }) { )} >
- {user.username}
@@ -105,7 +105,7 @@ export function LeaderboardPodium({ topThree }: { topThree: User[] }) {
-
+
{user.username}
diff --git a/frontend/components/leaderboard/LeaderboardTable.tsx b/frontend/components/leaderboard/LeaderboardTable.tsx index d4fbd92a..5b580a57 100644 --- a/frontend/components/leaderboard/LeaderboardTable.tsx +++ b/frontend/components/leaderboard/LeaderboardTable.tsx @@ -7,6 +7,7 @@ import { useTranslations } from 'next-intl'; import { cn } from '@/lib/utils'; import { CurrentUser, User } from './types'; +import { UserAvatar } from './UserAvatar'; interface LeaderboardTableProps { users: User[]; @@ -79,7 +80,7 @@ export function LeaderboardTable({ • • •
-
+
@@ -106,13 +107,8 @@ function TableRow({ const cellClass = 'px-2 sm:px-6 py-3 sm:py-4 border-b border-slate-100 dark:border-white/5'; - const leftBorderClass = isCurrentUser - ? 'border-l-[1px] sm:border-l-[1px] border-l-transparent' - : 'border-l-[1px] sm:border-l-[1px] border-l-transparent'; - - const rightBorderClass = isCurrentUser - ? 'border-r-[1px] sm:border-r-[1px] border-r-transparent' - : 'border-r-[1px] sm:border-r-[1px] border-r-transparent'; + const leftBorderClass = 'border-l border-l-transparent'; + const rightBorderClass = 'border-r border-r-transparent'; return (
@@ -133,14 +129,17 @@ function TableRow({
@@ -148,14 +147,14 @@ function TableRow({ className={cn( 'flex items-center gap-1 text-sm font-medium transition-colors sm:gap-2', isCurrentUser - ? 'text-sm font-black text-[var(--accent-primary)] sm:text-base' - : 'text-slate-700 group-hover:text-[var(--accent-primary)] dark:text-slate-200 dark:group-hover:text-[var(--accent-primary)]' + ? 'text-sm font-black text-(--accent-primary) sm:text-base' + : 'text-slate-700 group-hover:text-(--accent-primary) dark:text-slate-200 dark:group-hover:text-(--accent-primary)' )} > {user.username} {isCurrentUser && ( -
+
diff --git a/frontend/components/leaderboard/UserAvatar.tsx b/frontend/components/leaderboard/UserAvatar.tsx new file mode 100644 index 00000000..bfe04bf4 --- /dev/null +++ b/frontend/components/leaderboard/UserAvatar.tsx @@ -0,0 +1,45 @@ +'use client'; + +import Image from 'next/image'; +import { useState } from 'react'; + +import { cn } from '@/lib/utils'; + +interface UserAvatarProps { + src: string; + username: string; + userId?: string; + className?: string; + sizes?: string; +} + +function UserAvatarInner({ + src, + username, + userId, + className, + sizes = '40px', +}: UserAvatarProps) { + const [hasError, setHasError] = useState(false); + + const seed = userId ? `${username}-${userId}` : username; + const fallback = `https://api.dicebear.com/9.x/avataaars/svg?seed=${encodeURIComponent(seed)}`; + const imgSrc = hasError ? fallback : src; + const isSvg = imgSrc.endsWith('.svg') || imgSrc.includes('/svg?'); + + return ( + setHasError(true)} + /> + ); +} + +export function UserAvatar(props: UserAvatarProps) { + return ; +} diff --git a/frontend/components/leaderboard/types.ts b/frontend/components/leaderboard/types.ts index 38c64958..84c98b2c 100644 --- a/frontend/components/leaderboard/types.ts +++ b/frontend/components/leaderboard/types.ts @@ -1,5 +1,6 @@ export interface User { id: number; + userId: string; rank: number; username: string; points: number; diff --git a/frontend/db/queries/leaderboard.ts b/frontend/db/queries/leaderboard.ts index d90c9d49..5eab248b 100644 --- a/frontend/db/queries/leaderboard.ts +++ b/frontend/db/queries/leaderboard.ts @@ -26,7 +26,7 @@ const getLeaderboardDataCached = unstable_cache( return dbUsers.map((u, index) => { const username = u.username || 'Anonymous'; const avatar = - u.avatar && u.avatar !== 'null' + u.avatar && u.avatar.trim() !== '' && u.avatar !== 'null' ? u.avatar : `https://api.dicebear.com/9.x/avataaars/svg?seed=${encodeURIComponent( `${username}-${u.id}` @@ -34,6 +34,7 @@ const getLeaderboardDataCached = unstable_cache( return { id: index + 1, + userId: u.id, rank: index + 1, username, points: Number(u.points) || 0, diff --git a/frontend/db/queries/quiz.ts b/frontend/db/queries/quiz.ts index 2fe2a8a2..1a3fc284 100644 --- a/frontend/db/queries/quiz.ts +++ b/frontend/db/queries/quiz.ts @@ -2,8 +2,13 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'; import { unstable_cache } from 'next/cache'; import { cache } from 'react'; -import { cacheAttemptReview, getCachedAttemptReview, getOrCreateQuestionsCache } from '@/lib/quiz/quiz-answers-redis'; -import type { AttemptQuestionDetail, AttemptReview, QuizQuestionWithAnswers, UserLastAttempt } from '@/types/quiz'; +import { getOrCreateQuestionsCache } from '@/lib/quiz/quiz-answers-redis'; +import type { + AttemptReview, + AttemptQuestionDetail, + QuizQuestionWithAnswers, + UserLastAttempt, +} from '@/types/quiz'; import { db } from '../index'; import { categories, categoryTranslations } from '../schema/categories'; @@ -17,7 +22,14 @@ import { quizTranslations, quizzes, } from '../schema/quiz'; -export type { AttemptAnswerDetail, AttemptQuestionDetail, AttemptReview, QuizAnswer, QuizQuestion, QuizQuestionWithAnswers, UserLastAttempt } from '@/types/quiz'; +export type { + AttemptReview, + AttemptQuestionDetail, + QuizAnswer, + QuizQuestion, + QuizQuestionWithAnswers, + UserLastAttempt, +} from '@/types/quiz'; export interface Quiz { id: string; @@ -47,6 +59,27 @@ export interface QuizQuestionClient { answers: QuizAnswerClient[]; } +const attemptReviewCache = new Map(); + +function getAttemptReviewCacheKey(attemptId: string, locale: string) { + return `${attemptId}:${locale}`; +} + +async function getCachedAttemptReview( + attemptId: string, + locale: string +): Promise { + return attemptReviewCache.get(getAttemptReviewCacheKey(attemptId, locale)) ?? null; +} + +async function cacheAttemptReview( + attemptId: string, + locale: string, + review: AttemptReview +): Promise { + attemptReviewCache.set(getAttemptReviewCacheKey(attemptId, locale), review); +} + export function stripCorrectAnswers( questions: QuizQuestionWithAnswers[] ): QuizQuestionClient[] { diff --git a/frontend/db/queries/shop/products.ts b/frontend/db/queries/shop/products.ts index 7b3debe5..93de8dee 100644 --- a/frontend/db/queries/shop/products.ts +++ b/frontend/db/queries/shop/products.ts @@ -88,7 +88,6 @@ const publicProductSelect = { createdAt: products.createdAt, updatedAt: products.updatedAt, - // PRICE SOURCE OF TRUTH: price: productPrices.price, originalPrice: productPrices.originalPrice, currency: productPrices.currency, @@ -145,15 +144,12 @@ function buildWhereClause(options: { if (options.category && options.category !== 'all') { if (options.category === 'new-arrivals') { - // "New Arrivals" is derived, not "featured". - // Back-compat: also allow products.category='new-arrivals' if you already saved such rows. const clause = or( eq(products.badge, 'NEW'), eq(products.category, 'new-arrivals') ); if (clause) conditions.push(clause); } else if (options.category === 'sale') { - // sale = has compare-at/original price for the selected currency conditions.push(sql`${productPrices.originalPriceMinor} IS NOT NULL`); } else { conditions.push(eq(products.category, options.category)); diff --git a/frontend/db/schema/shop.ts b/frontend/db/schema/shop.ts index e14f00db..489fae29 100644 --- a/frontend/db/schema/shop.ts +++ b/frontend/db/schema/shop.ts @@ -1,5 +1,6 @@ import { sql } from 'drizzle-orm'; import { + bigint, boolean, check, index, @@ -29,6 +30,7 @@ export const paymentStatusEnum = pgEnum('payment_status', [ 'paid', 'failed', 'refunded', + 'needs_review', ]); export const currencyEnum = pgEnum('currency', ['USD', 'UAH']); @@ -164,8 +166,9 @@ export const orders = pgTable( table => [ check( 'orders_payment_provider_valid', - sql`${table.paymentProvider} in ('stripe', 'none')` + sql`${table.paymentProvider} in ('stripe', 'monobank', 'none')` ), + check( 'orders_total_amount_minor_non_negative', sql`${table.totalAmountMinor} >= 0` @@ -280,6 +283,138 @@ export const stripeEvents = pgTable( ] ); +export const monobankEvents = pgTable( + 'monobank_events', + { + id: uuid('id').defaultRandom().primaryKey(), + provider: text('provider').notNull().default('monobank'), + eventKey: text('event_key').notNull(), + invoiceId: text('invoice_id'), + status: text('status'), + amount: integer('amount'), + ccy: integer('ccy'), + reference: text('reference'), + rawPayload: jsonb('raw_payload').$type | null>(), + normalizedPayload: jsonb('normalized_payload').$type | null>(), + attemptId: uuid('attempt_id').references(() => paymentAttempts.id, { + onDelete: 'set null', + }), + orderId: uuid('order_id').references(() => orders.id, { + onDelete: 'cascade', + }), + providerModifiedAt: timestamp('provider_modified_at', { + withTimezone: true, + }), + claimedAt: timestamp('claimed_at', { withTimezone: true }), + claimExpiresAt: timestamp('claim_expires_at', { withTimezone: true }), + claimedBy: text('claimed_by'), + appliedAt: timestamp('applied_at', { withTimezone: true }), + appliedResult: text('applied_result'), + appliedErrorCode: text('applied_error_code'), + appliedErrorMessage: text('applied_error_message'), + rawSha256: text('raw_sha256').notNull(), + receivedAt: timestamp('received_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + t => [ + check('monobank_events_provider_check', sql`${t.provider} in ('monobank')`), + uniqueIndex('monobank_events_event_key_unique').on(t.eventKey), + uniqueIndex('monobank_events_raw_sha256_unique').on(t.rawSha256), + index('monobank_events_order_id_idx').on(t.orderId), + index('monobank_events_attempt_id_idx').on(t.attemptId), + index('monobank_events_claim_expires_idx').on(t.claimExpiresAt), + ] +); + +export const monobankRefunds = pgTable( + 'monobank_refunds', + { + id: uuid('id').defaultRandom().primaryKey(), + provider: text('provider').notNull().default('monobank'), + orderId: uuid('order_id') + .notNull() + .references(() => orders.id, { onDelete: 'cascade' }), + attemptId: uuid('attempt_id').references(() => paymentAttempts.id, { + onDelete: 'set null', + }), + extRef: text('ext_ref').notNull(), + status: text('status').notNull().default('requested'), + amountMinor: bigint('amount_minor', { mode: 'number' }).notNull(), + currency: currencyEnum('currency').notNull().default('UAH'), + providerCreatedAt: timestamp('provider_created_at', { + withTimezone: true, + }), + providerModifiedAt: timestamp('provider_modified_at', { + withTimezone: true, + }), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + t => [ + check( + 'monobank_refunds_provider_check', + sql`${t.provider} in ('monobank')` + ), + check( + 'monobank_refunds_status_check', + sql`${t.status} in ('requested','processing','success','failure','needs_review')` + ), + check( + 'monobank_refunds_amount_minor_non_negative', + sql`${t.amountMinor} >= 0` + ), + check('monobank_refunds_currency_uah', sql`${t.currency} = 'UAH'`), + uniqueIndex('monobank_refunds_ext_ref_unique').on(t.extRef), + index('monobank_refunds_order_id_idx').on(t.orderId), + index('monobank_refunds_attempt_id_idx').on(t.attemptId), + ] +); + +export const monobankPaymentCancels = pgTable( + 'monobank_payment_cancels', + { + id: uuid('id').defaultRandom().primaryKey(), + orderId: uuid('order_id') + .notNull() + .references(() => orders.id, { onDelete: 'cascade' }), + extRef: text('ext_ref').notNull(), + invoiceId: text('invoice_id').notNull(), + attemptId: uuid('attempt_id').references(() => paymentAttempts.id, { + onDelete: 'set null', + }), + status: text('status').notNull().default('requested'), + requestId: text('request_id').notNull(), + errorCode: text('error_code'), + errorMessage: text('error_message'), + pspResponse: jsonb('psp_response').$type | null>(), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + t => [ + check( + 'monobank_payment_cancels_status_check', + sql`${t.status} in ('requested','processing','success','failure')` + ), + uniqueIndex('monobank_payment_cancels_ext_ref_unique').on(t.extRef), + index('monobank_payment_cancels_order_id_idx').on(t.orderId), + index('monobank_payment_cancels_attempt_id_idx').on(t.attemptId), + ] +); + export const productPrices = pgTable( 'product_prices', { @@ -403,9 +538,16 @@ export const paymentAttempts = pgTable( provider: text('provider').notNull(), status: text('status').notNull().default('active'), attemptNumber: integer('attempt_number').notNull(), + currency: currencyEnum('currency'), + expectedAmountMinor: bigint('expected_amount_minor', { mode: 'number' }), idempotencyKey: text('idempotency_key').notNull(), providerPaymentIntentId: text('provider_payment_intent_id'), + checkoutUrl: text('checkout_url'), + providerCreatedAt: timestamp('provider_created_at', { withTimezone: true }), + providerModifiedAt: timestamp('provider_modified_at', { + withTimezone: true, + }), lastErrorCode: text('last_error_code'), lastErrorMessage: text('last_error_message'), @@ -425,16 +567,28 @@ export const paymentAttempts = pgTable( finalizedAt: timestamp('finalized_at', { withTimezone: true }), }, t => [ - check('payment_attempts_provider_check', sql`${t.provider} in ('stripe')`), + check( + 'payment_attempts_provider_check', + sql`${t.provider} in ('stripe','monobank')` + ), check( 'payment_attempts_status_check', - sql`${t.status} in ('active','succeeded','failed','canceled')` + sql`${t.status} in ('creating','active','succeeded','failed','canceled')` ), + check( 'payment_attempts_attempt_number_check', sql`${t.attemptNumber} >= 1` ), + check( + 'payment_attempts_expected_amount_minor_non_negative', + sql`${t.expectedAmountMinor} is null or ${t.expectedAmountMinor} >= 0` + ), + check( + 'payment_attempts_mono_currency_uah', + sql`${t.provider} <> 'monobank' OR ${t.currency} = 'UAH'` + ), uniqueIndex('payment_attempts_order_provider_attempt_unique').on( t.orderId, @@ -453,7 +607,12 @@ export const paymentAttempts = pgTable( uniqueIndex('payment_attempts_order_provider_active_unique') .on(t.orderId, t.provider) - .where(sql`${t.status} = 'active'`), + .where(sql`${t.status} in ('active','creating')`), + index('payment_attempts_provider_status_updated_idx').on( + t.provider, + t.status, + t.updatedAt + ), ] ); @@ -464,3 +623,6 @@ export type DbInventoryMove = typeof inventoryMoves.$inferSelect; 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 DbMonobankRefund = typeof monobankRefunds.$inferSelect; +export type DbMonobankPaymentCancel = typeof monobankPaymentCancels.$inferSelect; diff --git a/frontend/docs/monobank-b3-verification.md b/frontend/docs/monobank-b3-verification.md new file mode 100644 index 00000000..5e0d1978 --- /dev/null +++ b/frontend/docs/monobank-b3-verification.md @@ -0,0 +1,62 @@ +# Monobank B3 Verification (Read-Only) + +This script verifies Monobank data invariants without modifying data. It is safe to run in DEV/UAT/PROD. + +## What it checks + +- All **checkout-eligible** products have a UAH price row (currency = `UAH`) with a non-negative minor price. +- Required indexes exist on `payment_attempts`: + - `payment_attempts_order_provider_active_unique` + - `payment_attempts_provider_status_updated_idx` + +Checkout-eligible predicate is derived from shop code: + +- `products.is_active = true` (see `frontend/db/queries/shop/products.ts` and `frontend/lib/services/orders/checkout.ts`). + +## Run (PowerShell) + +```powershell +cd frontend +$env:DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/DB" +npx tsx .\scripts\verify-monobank-b3.ts +``` + +### Environment examples + +```powershell +# DEV +$env:DATABASE_URL="postgres://dev_user:dev_pass@dev-host:5432/dev_db" +npx tsx .\scripts\verify-monobank-b3.ts + +# UAT +$env:DATABASE_URL="postgres://uat_user:uat_pass@uat-host:5432/uat_db" +npx tsx .\scripts\verify-monobank-b3.ts + +# PROD +$env:DATABASE_URL="postgres://prod_user:prod_pass@prod-host:5432/prod_db" +npx tsx .\scripts\verify-monobank-b3.ts +``` + +The script exits with code `1` if any requirement fails. + +## SQL snippets (manual verification) + +```sql +-- Missing/invalid UAH prices for active products +SELECT p.id, p.slug, p.title +FROM products p +LEFT JOIN product_prices pp + ON pp.product_id = p.id AND pp.currency = 'UAH' +WHERE p.is_active = true + AND (pp.price_minor IS NULL OR pp.price_minor < 0); + +-- Required indexes on payment_attempts +SELECT indexname, indexdef +FROM pg_indexes +WHERE schemaname = 'public' + AND tablename = 'payment_attempts' + AND indexname IN ( + 'payment_attempts_order_provider_active_unique', + 'payment_attempts_provider_status_updated_idx' + ); +``` diff --git a/frontend/docs/payments/monobank/E0-gap-report.md b/frontend/docs/payments/monobank/E0-gap-report.md new file mode 100644 index 00000000..ba1cc256 --- /dev/null +++ b/frontend/docs/payments/monobank/E0-gap-report.md @@ -0,0 +1,126 @@ +# Monobank E0 Gap Report (Facts vs Proposals) + +## FACTS vs PROPOSALS +- **FACTS** in this document are verified directly from repo code. +- **PROPOSALS** are suggestions only; they do **not** imply any code changes. +- **No Stripe changes** are proposed; Stripe references are read‑only for architecture parity. + +--- + +## FACTS — Order creation (entrypoints) +- **Route handler:** `frontend/app/api/shop/checkout/route.ts` + - `POST` handler validates payload + Idempotency‑Key, resolves provider, then calls `createOrderWithItems(...)`. +- **Service function:** `frontend/lib/services/orders/checkout.ts` + - `export async function createOrderWithItems(...)` is the order creation + inventory reserve flow. + - Uses `hashIdempotencyRequest(...)` from `frontend/lib/services/orders/_shared.ts` to enforce idempotency. + +--- + +## FACTS — Payment attempts creation (Stripe vs Monobank) + +### Stripe attempts +- **Primary entrypoint:** `frontend/lib/services/orders/payment-attempts.ts` + - `export async function ensureStripePaymentIntentForOrder(...)` + - Internal helpers: + - `createActiveAttempt(...)` + - `upsertBackfillAttemptForExistingPI(...)` + - Uses `buildStripeAttemptIdempotencyKey(...)` from `frontend/lib/services/orders/attempt-idempotency.ts`. +- **Caller:** `frontend/app/api/shop/checkout/route.ts` invokes `ensureStripePaymentIntentForOrder(...)` in Stripe flow. + +### Monobank attempts +- **Primary entrypoint:** `frontend/lib/services/orders/monobank.ts` + - `export async function createMonoAttemptAndInvoice(...)` + - Wrapper: `export async function createMonobankAttemptAndInvoice(...)` (builds redirect + webhook URLs and calls `createMonoAttemptAndInvoice`). + - Internal helper: `createCreatingAttempt(...)` inserts a `payment_attempts` row with status `creating`. + - Uses `buildMonobankAttemptIdempotencyKey(...)` from `frontend/lib/services/orders/attempt-idempotency.ts`. +- **Caller:** `frontend/app/api/shop/checkout/route.ts` (Monobank branch uses lazy import and calls `createMonobankAttemptAndInvoice(...)`). + +--- + +## FACTS — Orders + payment_attempts data contract (schema + usage) + +### Orders table (`frontend/db/schema/shop.ts`) +- **Key fields:** + - `paymentStatus` (enum): `pending | requires_payment | paid | failed | refunded` + - `paymentProvider` (text + CHECK): `'stripe' | 'monobank' | 'none'` + - `status` (enum): `CREATED | INVENTORY_RESERVED | INVENTORY_FAILED | PAID | CANCELED` + - `paymentIntentId`, `pspChargeId`, `pspStatusReason`, `pspMetadata` + - `idempotencyKey`, `idempotencyRequestHash` + - `stockRestored`, `restockedAt`, `inventoryStatus` +- **Usage examples:** + - `createOrderWithItems(...)` writes `paymentProvider`, `paymentStatus`, `status`, `idempotencyKey`, `idempotencyRequestHash`. + (`frontend/lib/services/orders/checkout.ts`) + +### payment_attempts table (`frontend/db/schema/shop.ts`) +- **Key fields:** + - `provider` (CHECK): `'stripe' | 'monobank'` + - `status` (CHECK): `creating | active | succeeded | failed | canceled` + - `attemptNumber`, `currency`, `expectedAmountMinor` + - `idempotencyKey` (unique) + - `providerPaymentIntentId` (Stripe PI id / Monobank invoice id) + - `checkoutUrl`, `providerCreatedAt`, `providerModifiedAt` + - `lastErrorCode`, `lastErrorMessage`, `metadata` + - `createdAt`, `updatedAt`, `finalizedAt` +- **Usage examples:** + - Stripe: `ensureStripePaymentIntentForOrder(...)` creates/updates attempts and sets `providerPaymentIntentId`. + (`frontend/lib/services/orders/payment-attempts.ts`) + - Monobank: `createMonoAttemptAndInvoice(...)` inserts attempt with `status='creating'` and finalizes with `providerPaymentIntentId` + `metadata.pageUrl`. + (`frontend/lib/services/orders/monobank.ts`) + +--- + +## FACTS — Idempotency + +### Orders +- **Fields:** `orders.idempotencyKey` and `orders.idempotencyRequestHash` + (`frontend/db/schema/shop.ts`) +- **Enforcement path:** + - `Idempotency-Key` header parsed in `frontend/app/api/shop/checkout/route.ts` + - `createOrderWithItems(...)` checks existing order via `getOrderByIdempotencyKey(...)` and verifies the request hash using `hashIdempotencyRequest(...)`. + (`frontend/lib/services/orders/summary.ts`, `frontend/lib/services/orders/_shared.ts`, `frontend/lib/services/orders/checkout.ts`) +- **Behavior (facts):** + - If an existing order is found and the request hash matches, the existing order is returned. + - If the request hash does not match, `IdempotencyConflictError` is thrown and the route returns a conflict response. + +### Payment attempts +- **Unique constraint:** `payment_attempts_idempotency_key_unique` + (`frontend/db/schema/shop.ts`) +- **Builder helpers:** + - `buildStripeAttemptIdempotencyKey(provider, orderId, attemptNo)` + - `buildMonobankAttemptIdempotencyKey(orderId, attemptNo)` + (`frontend/lib/services/orders/attempt-idempotency.ts`) +- **Usage:** + - Stripe attempts: `createActiveAttempt(...)` / `upsertBackfillAttemptForExistingPI(...)` + (`frontend/lib/services/orders/payment-attempts.ts`) + - Monobank attempts: `createCreatingAttempt(...)` + (`frontend/lib/services/orders/monobank.ts`) + +--- + +## FACTS — Stripe events dedupe/claim (read‑only) +- **Route:** `frontend/app/api/shop/webhooks/stripe/route.ts` + - Uses `tryClaimStripeEvent(...)` to claim events via `stripe_events.claimedAt/claimExpiresAt/claimedBy`. + - Flow (high‑level): insert `stripe_events` row (dedupe), claim lease, apply updates, then mark `processedAt`. +- **Schema:** `stripe_events` in `frontend/db/schema/shop.ts` + - Fields include: `eventId`, `paymentIntentId`, `orderId`, `eventType`, `paymentStatus`, `claimedAt`, `claimExpiresAt`, `claimedBy`, `processedAt`. + - Unique index: `stripe_events_event_id_idx`. + +--- + +## PROPOSAL — Monobank events parity (no Stripe changes) +**Goal:** mirror Stripe’s event persistence model without touching `stripe_events` or Stripe webhooks. + +- **Table strategy:** use a provider‑scoped events table (e.g., `monobank_events` or generic `psp_events`) with `provider='monobank'`. +- **Dedupe:** `eventKey` and/or `raw_sha256` (e.g., `sha256(rawBytes)`) to prevent double‑apply. +- **Claim/lease fields:** add `claimedAt`, `claimExpiresAt`, `claimedBy` (TTL‑based) to allow multi‑instance safe applies. +- **Apply modes:** honor `apply | store | drop` modes if `MONO_WEBHOOK_MODE` exists in config (`frontend/lib/env/monobank.ts`). +- **Explicit statement:** **No changes to `stripe_events` or Stripe webhook route.** + +--- + +## FACTS — Gaps / TODO list (observed from code) +- **No Monobank refund implementation:** `frontend/lib/services/orders/refund.ts` is Stripe‑only (Monobank refunds are not handled there). + (`frontend/app/api/shop/admin/orders/[id]/refund/route.ts` blocks monobank refunds when `MONO_REFUND_ENABLED=false`.) +- **No Monobank event claim/lease fields:** `monobank_events` schema does not include `claimedAt/claimExpiresAt/claimedBy` (present only in `stripe_events`). +- **No explicit Monobank event processing marker:** `monobank_events` has `appliedAt`/`appliedResult`, but no `processedAt` or claim TTL fields like Stripe’s flow. + diff --git a/frontend/docs/payments/monobank/F0-report.md b/frontend/docs/payments/monobank/F0-report.md new file mode 100644 index 00000000..84bc5a2d --- /dev/null +++ b/frontend/docs/payments/monobank/F0-report.md @@ -0,0 +1,79 @@ +# F0 Recon: Shop Checkout + Monobank Route Surface + +## 1) Checkout route (POST /api/shop/checkout) +- Route file: `frontend/app/api/shop/checkout/route.ts` +- Handler: `export async function POST(request: NextRequest)` + +## 2) API response/error + logging helpers used by checkout +- Checkout-local JSON helpers in `frontend/app/api/shop/checkout/route.ts`: + - `errorResponse(code, message, status, details?)` + - `buildCheckoutResponse({ order, itemCount, clientSecret, status })` +- Shared rate-limit response helper: + - `rateLimitResponse(...)` from `frontend/lib/security/rate-limit.ts` +- Logging helpers: + - `logWarn`, `logInfo`, `logError` from `frontend/lib/logging.ts` + +Related API pattern in other shop routes: +- `noStoreJson(...)` local helper pattern appears in multiple route files (example: `frontend/app/api/shop/catalog/route.ts`, `frontend/app/api/shop/webhooks/monobank/route.ts`). + +## 3) Checkout rate limit helper wiring +- Subject derivation: `getRateLimitSubject(request)` from `frontend/lib/security/rate-limit.ts` +- Enforcement: `enforceRateLimit({ key, limit, windowSeconds })` +- Rejection response: `rateLimitResponse({ retryAfterSeconds, details })` +- Checkout key format: ``checkout:${checkoutSubject}`` in `frontend/app/api/shop/checkout/route.ts` + +## 4) Existing checkout request shape + provider selection +- Payload schema: `checkoutPayloadSchema` in `frontend/lib/validation/shop.ts` + - Shape: `{ items: CheckoutItemInput[]; userId?: string }` + - `items[]` fields: `productId`, `quantity`, optional `selectedSize`, optional `selectedColor` + - Schema is strict. +- Provider selection in checkout route: + - Helper: `parseRequestedProvider(raw)` in `frontend/app/api/shop/checkout/route.ts` + - Reads `paymentProvider` or `provider` from request body object. + - Accepts `stripe` or `monobank` (case-insensitive trim+lowercase). + - Invalid provider -> `422 PAYMENTS_PROVIDER_INVALID`. + - Default when omitted -> `stripe`. + +## 5) Existing idempotency behavior (extraction + storage) +- Extraction in route: + - Helper: `getIdempotencyKey(request)` in `frontend/app/api/shop/checkout/route.ts` + - Source: HTTP header `Idempotency-Key`. + - Validation schema: `idempotencyKeySchema` in `frontend/lib/validation/shop.ts` + - 16..128 chars, regex `^[A-Za-z0-9_.-]+$`. +- Route-level behavior: + - Missing -> `400 MISSING_IDEMPOTENCY_KEY` + - Invalid format -> `400 INVALID_IDEMPOTENCY_KEY` (with zod-format details) +- Storage/usage: + - Orders dedupe key stored/read via `orders.idempotencyKey`: + - read path: `getOrderByIdempotencyKey(...)` in `frontend/lib/services/orders/summary.ts` + - write/flow: `createOrderWithItems(...)` in `frontend/lib/services/orders/checkout.ts` + - Request fingerprint stored as `orders.idempotencyRequestHash` in `createOrderWithItems(...)`. + - Payment-attempt idempotency keys in `payment_attempts.idempotency_key`: + - Stripe: `buildStripeAttemptIdempotencyKey(...)` in `frontend/lib/services/orders/attempt-idempotency.ts` + - Monobank: `buildMonobankAttemptIdempotencyKey(...)` in `frontend/lib/services/orders/attempt-idempotency.ts` + +## 6) Existing response/error contract in checkout +- Success response (`buildCheckoutResponse`): + - HTTP: `200` or `201` + - Body shape: + - `success: true` + - `order: { id, currency, totalAmount, itemCount, paymentStatus, paymentProvider, paymentIntentId, clientSecret }` + - top-level mirrors: `orderId`, `paymentStatus`, `paymentProvider`, `paymentIntentId`, `clientSecret` +- Error response (`errorResponse`): + - Body shape: `{ code: string, message: string, details?: unknown }` + - Used status codes in this route: `400`, `409`, `422`, `500`, `502`, `503` +- Rate-limit response (`rateLimitResponse`): + - HTTP `429` + - Body shape: `{ success: false, code, retryAfterSeconds, details? }` + +## 7) Monobank services already present (names + paths) +- Order/checkout side: + - `createMonoAttemptAndInvoice(...)` in `frontend/lib/services/orders/monobank.ts` + - `createMonobankAttemptAndInvoice(...)` in `frontend/lib/services/orders/monobank.ts` +- Webhook apply side: + - `applyMonoWebhookEvent(...)` in `frontend/lib/services/orders/monobank-webhook.ts` +- PSP adapter side: + - `createMonobankInvoice(...)` in `frontend/lib/psp/monobank.ts` + - `cancelMonobankInvoice(...)` in `frontend/lib/psp/monobank.ts` + - `verifyMonobankWebhookSignature(...)` in `frontend/lib/psp/monobank.ts` + - Additional exported API methods are in `frontend/lib/psp/monobank.ts`. diff --git a/frontend/drizzle/0006_certain_shocker.sql b/frontend/drizzle/0006_certain_shocker.sql new file mode 100644 index 00000000..fbfbcb15 --- /dev/null +++ b/frontend/drizzle/0006_certain_shocker.sql @@ -0,0 +1,10 @@ +ALTER TABLE "orders" DROP CONSTRAINT "orders_payment_provider_valid";--> statement-breakpoint +ALTER TABLE "payment_attempts" DROP CONSTRAINT "payment_attempts_provider_check";--> statement-breakpoint +ALTER TABLE "payment_attempts" DROP CONSTRAINT "payment_attempts_status_check";--> statement-breakpoint +ALTER TABLE "payment_attempts" ADD COLUMN "currency" "currency";--> statement-breakpoint +ALTER TABLE "payment_attempts" ADD COLUMN "expected_amount_minor" bigint;--> statement-breakpoint +ALTER TABLE "orders" ADD CONSTRAINT "orders_payment_provider_valid" CHECK ("orders"."payment_provider" in ('stripe', 'monobank', 'none'));--> statement-breakpoint +ALTER TABLE "payment_attempts" ADD CONSTRAINT "payment_attempts_expected_amount_minor_non_negative" CHECK ("payment_attempts"."expected_amount_minor" is null or "payment_attempts"."expected_amount_minor" >= 0);--> statement-breakpoint +ALTER TABLE "payment_attempts" ADD CONSTRAINT "payment_attempts_mono_currency_uah" CHECK ("payment_attempts"."provider" <> 'monobank' OR "payment_attempts"."currency" = 'UAH');--> statement-breakpoint +ALTER TABLE "payment_attempts" ADD CONSTRAINT "payment_attempts_provider_check" CHECK ("payment_attempts"."provider" in ('stripe','monobank'));--> statement-breakpoint +ALTER TABLE "payment_attempts" ADD CONSTRAINT "payment_attempts_status_check" CHECK ("payment_attempts"."status" in ('creating','active','succeeded','failed','canceled')); \ No newline at end of file diff --git a/frontend/drizzle/0007_outstanding_shocker.sql b/frontend/drizzle/0007_outstanding_shocker.sql new file mode 100644 index 00000000..081f99a8 --- /dev/null +++ b/frontend/drizzle/0007_outstanding_shocker.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS "payment_attempts_order_provider_active_unique";--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "payment_attempts_order_provider_active_unique" + ON "payment_attempts" ("order_id","provider") + WHERE ("status" in ('active','creating'));--> statement-breakpoint \ No newline at end of file diff --git a/frontend/drizzle/0008_wide_zzzax.sql b/frontend/drizzle/0008_wide_zzzax.sql new file mode 100644 index 00000000..9a56f1d4 --- /dev/null +++ b/frontend/drizzle/0008_wide_zzzax.sql @@ -0,0 +1,46 @@ +CREATE TABLE "monobank_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "provider" text DEFAULT 'monobank' NOT NULL, + "event_key" text NOT NULL, + "invoice_id" text, + "attempt_id" uuid, + "order_id" uuid NOT NULL, + "provider_modified_at" timestamp with time zone, + "applied_at" timestamp with time zone, + "raw_sha256" text NOT NULL, + "received_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "monobank_events_provider_check" CHECK ("monobank_events"."provider" in ('monobank')) +); +--> statement-breakpoint +CREATE TABLE "monobank_refunds" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "provider" text DEFAULT 'monobank' NOT NULL, + "order_id" uuid NOT NULL, + "attempt_id" uuid, + "ext_ref" text NOT NULL, + "status" text DEFAULT 'requested' NOT NULL, + "amount_minor" bigint, + "currency" "currency" DEFAULT 'UAH' NOT NULL, + "provider_created_at" timestamp with time zone, + "provider_modified_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "monobank_refunds_provider_check" CHECK ("monobank_refunds"."provider" in ('monobank')), + CONSTRAINT "monobank_refunds_status_check" CHECK ("monobank_refunds"."status" in ('requested','processing','success','failure','needs_review')), + CONSTRAINT "monobank_refunds_currency_uah" CHECK ("monobank_refunds"."currency" = 'UAH') +); +--> statement-breakpoint +ALTER TABLE "payment_attempts" ADD COLUMN "checkout_url" text;--> statement-breakpoint +ALTER TABLE "payment_attempts" ADD COLUMN "provider_created_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "payment_attempts" ADD COLUMN "provider_modified_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD CONSTRAINT "monobank_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 "monobank_events" ADD CONSTRAINT "monobank_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 "monobank_refunds" ADD CONSTRAINT "monobank_refunds_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "monobank_refunds" ADD CONSTRAINT "monobank_refunds_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 +CREATE UNIQUE INDEX "monobank_events_event_key_unique" ON "monobank_events" USING btree ("event_key");--> statement-breakpoint +CREATE UNIQUE INDEX "monobank_events_raw_sha256_unique" ON "monobank_events" USING btree ("raw_sha256");--> statement-breakpoint +CREATE INDEX "monobank_events_order_id_idx" ON "monobank_events" USING btree ("order_id");--> statement-breakpoint +CREATE INDEX "monobank_events_attempt_id_idx" ON "monobank_events" USING btree ("attempt_id");--> statement-breakpoint +CREATE UNIQUE INDEX "monobank_refunds_ext_ref_unique" ON "monobank_refunds" USING btree ("ext_ref");--> statement-breakpoint +CREATE INDEX "monobank_refunds_order_id_idx" ON "monobank_refunds" USING btree ("order_id");--> statement-breakpoint +CREATE INDEX "monobank_refunds_attempt_id_idx" ON "monobank_refunds" USING btree ("attempt_id"); \ No newline at end of file diff --git a/frontend/drizzle/0009_spicy_martin_li.sql b/frontend/drizzle/0009_spicy_martin_li.sql new file mode 100644 index 00000000..e816427a --- /dev/null +++ b/frontend/drizzle/0009_spicy_martin_li.sql @@ -0,0 +1,3 @@ +ALTER TABLE "monobank_refunds" ALTER COLUMN "amount_minor" SET NOT NULL;--> statement-breakpoint +CREATE INDEX "payment_attempts_provider_status_updated_idx" ON "payment_attempts" USING btree ("provider","status","updated_at");--> statement-breakpoint +ALTER TABLE "monobank_refunds" ADD CONSTRAINT "monobank_refunds_amount_minor_non_negative" CHECK ("monobank_refunds"."amount_minor" >= 0); \ No newline at end of file diff --git a/frontend/drizzle/0010_wakeful_living_lightning.sql b/frontend/drizzle/0010_wakeful_living_lightning.sql new file mode 100644 index 00000000..d37fe185 --- /dev/null +++ b/frontend/drizzle/0010_wakeful_living_lightning.sql @@ -0,0 +1,11 @@ +ALTER TYPE "public"."payment_status" ADD VALUE 'needs_review';--> statement-breakpoint +ALTER TABLE "monobank_events" ALTER COLUMN "order_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "status" text;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "amount" integer;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "ccy" integer;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "reference" text;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "raw_payload" jsonb;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "normalized_payload" jsonb;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "applied_result" text;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "applied_error_code" text;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "applied_error_message" text; \ No newline at end of file diff --git a/frontend/drizzle/0011_rich_gateway.sql b/frontend/drizzle/0011_rich_gateway.sql new file mode 100644 index 00000000..ca46f22b --- /dev/null +++ b/frontend/drizzle/0011_rich_gateway.sql @@ -0,0 +1,4 @@ +ALTER TABLE "monobank_events" ADD COLUMN "claimed_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "claim_expires_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "monobank_events" ADD COLUMN "claimed_by" text;--> statement-breakpoint +CREATE INDEX "monobank_events_claim_expires_idx" ON "monobank_events" USING btree ("claim_expires_at"); \ No newline at end of file diff --git a/frontend/drizzle/0012_atomic_mono_cancel_payment.sql b/frontend/drizzle/0012_atomic_mono_cancel_payment.sql new file mode 100644 index 00000000..21f58fef --- /dev/null +++ b/frontend/drizzle/0012_atomic_mono_cancel_payment.sql @@ -0,0 +1,25 @@ +CREATE TABLE "monobank_payment_cancels" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "order_id" uuid NOT NULL, + "ext_ref" text NOT NULL, + "invoice_id" text NOT NULL, + "attempt_id" uuid, + "status" text DEFAULT 'requested' NOT NULL, + "request_id" text NOT NULL, + "error_code" text, + "error_message" text, + "psp_response" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "monobank_payment_cancels_status_check" CHECK ("monobank_payment_cancels"."status" in ('requested','processing','success','failure')) +); +--> statement-breakpoint +ALTER TABLE "monobank_payment_cancels" ADD CONSTRAINT "monobank_payment_cancels_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "monobank_payment_cancels" ADD CONSTRAINT "monobank_payment_cancels_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 +CREATE UNIQUE INDEX "monobank_payment_cancels_ext_ref_unique" ON "monobank_payment_cancels" USING btree ("ext_ref"); +--> statement-breakpoint +CREATE INDEX "monobank_payment_cancels_order_id_idx" ON "monobank_payment_cancels" USING btree ("order_id"); +--> statement-breakpoint +CREATE INDEX "monobank_payment_cancels_attempt_id_idx" ON "monobank_payment_cancels" USING btree ("attempt_id"); diff --git a/frontend/drizzle/meta/0006_snapshot.json b/frontend/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000..188d5fc6 --- /dev/null +++ b/frontend/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2826 @@ +{ + "id": "a2bbe8f7-7211-4508-ac7b-5a1f495d31cc", + "prevId": "71daad5e-a037-48d6-b75c-7f8b49fd518f", + "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.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 + }, + "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.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 + }, + "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.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.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.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'" + }, + "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": {} + } + }, + "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')" + } + }, + "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 + }, + "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\" = 'active'", + "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.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.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.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.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.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 + } + }, + "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" + ] + }, + "public.product_badge": { + "name": "product_badge", + "schema": "public", + "values": [ + "NEW", + "SALE", + "NONE" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0007_snapshot.json b/frontend/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000..c48e03c9 --- /dev/null +++ b/frontend/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2826 @@ +{ + "id": "ef5ed9b7-f287-494a-a6c7-2dd815547c55", + "prevId": "a2bbe8f7-7211-4508-ac7b-5a1f495d31cc", + "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.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 + }, + "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.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 + }, + "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.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.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.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'" + }, + "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": {} + } + }, + "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')" + } + }, + "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 + }, + "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": {} + } + }, + "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.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.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.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.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.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 + } + }, + "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" + ] + }, + "public.product_badge": { + "name": "product_badge", + "schema": "public", + "values": [ + "NEW", + "SALE", + "NONE" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0008_snapshot.json b/frontend/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..78627019 --- /dev/null +++ b/frontend/drizzle/meta/0008_snapshot.json @@ -0,0 +1,3191 @@ +{ + "id": "2d7c4a37-4c91-40ed-95e7-f077a7e7d62a", + "prevId": "ef5ed9b7-f287-494a-a6c7-2dd815547c55", + "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.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 + }, + "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.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 + }, + "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.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 + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "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": {} + } + }, + "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_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": false + }, + "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_currency_uah": { + "name": "monobank_refunds_currency_uah", + "value": "\"monobank_refunds\".\"currency\" = 'UAH'" + } + }, + "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.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'" + }, + "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": {} + } + }, + "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')" + } + }, + "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 + }, + "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": {} + } + }, + "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.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.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.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.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.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 + } + }, + "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" + ] + }, + "public.product_badge": { + "name": "product_badge", + "schema": "public", + "values": [ + "NEW", + "SALE", + "NONE" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0009_snapshot.json b/frontend/drizzle/meta/0009_snapshot.json new file mode 100644 index 00000000..3e5d7d92 --- /dev/null +++ b/frontend/drizzle/meta/0009_snapshot.json @@ -0,0 +1,3222 @@ +{ + "id": "18eaf281-7abc-4a9c-b048-0a9c39066254", + "prevId": "2d7c4a37-4c91-40ed-95e7-f077a7e7d62a", + "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.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 + }, + "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.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 + }, + "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.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 + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "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": {} + } + }, + "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_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.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.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'" + }, + "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": {} + } + }, + "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')" + } + }, + "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 + }, + "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": {} + } + }, + "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.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.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.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.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.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 + } + }, + "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" + ] + }, + "public.product_badge": { + "name": "product_badge", + "schema": "public", + "values": [ + "NEW", + "SALE", + "NONE" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0010_snapshot.json b/frontend/drizzle/meta/0010_snapshot.json new file mode 100644 index 00000000..2dfa948f --- /dev/null +++ b/frontend/drizzle/meta/0010_snapshot.json @@ -0,0 +1,3277 @@ +{ + "id": "598c15d9-5a3f-4fb0-a34b-ae3a47dadca0", + "prevId": "18eaf281-7abc-4a9c-b048-0a9c39066254", + "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.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 + }, + "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.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 + }, + "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.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 + }, + "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": {} + } + }, + "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_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.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.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'" + }, + "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": {} + } + }, + "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')" + } + }, + "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 + }, + "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": {} + } + }, + "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.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.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.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.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.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 + } + }, + "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" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/0011_snapshot.json b/frontend/drizzle/meta/0011_snapshot.json new file mode 100644 index 00000000..20339ffa --- /dev/null +++ b/frontend/drizzle/meta/0011_snapshot.json @@ -0,0 +1,3310 @@ +{ + "id": "ed7f084a-1b04-4ba2-98c5-8861536b6532", + "prevId": "598c15d9-5a3f-4fb0-a34b-ae3a47dadca0", + "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.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 + }, + "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.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 + }, + "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.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_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.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.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'" + }, + "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": {} + } + }, + "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')" + } + }, + "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 + }, + "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": {} + } + }, + "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.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.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.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.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.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 + } + }, + "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" + ] + } + }, + "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 177e50c8..d003ed6a 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -43,6 +43,55 @@ "when": 1768782782399, "tag": "0005_modern_bromley", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1769708885020, + "tag": "0006_certain_shocker", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1769709728438, + "tag": "0007_outstanding_shocker", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1769721614504, + "tag": "0008_wide_zzzax", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1769722667103, + "tag": "0009_spicy_martin_li", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1769798192982, + "tag": "0010_wakeful_living_lightning", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1769810799649, + "tag": "0011_rich_gateway", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1769972400000, + "tag": "0012_atomic_mono_cancel_payment", + "breakpoints": true } ] } diff --git a/frontend/instrumentation-client.ts b/frontend/instrumentation-client.ts new file mode 100644 index 00000000..acdb9445 --- /dev/null +++ b/frontend/instrumentation-client.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/nextjs'; + +const isProduction = + process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' || + process.env.NODE_ENV === 'production'; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + enabled: isProduction, + + tracesSampleRate: 0.1, + + environment: process.env.NEXT_PUBLIC_VERCEL_ENV || process.env.NODE_ENV, + + release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, + + sendDefaultPii: false, +}); diff --git a/frontend/instrumentation.ts b/frontend/instrumentation.ts new file mode 100644 index 00000000..7cbe93c1 --- /dev/null +++ b/frontend/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from "@sentry/nextjs"; + +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/frontend/lib/env/index.ts b/frontend/lib/env/index.ts index e4797737..fb6fbf1c 100644 --- a/frontend/lib/env/index.ts +++ b/frontend/lib/env/index.ts @@ -19,7 +19,23 @@ export const serverEnvSchema = z.object({ STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), STRIPE_MODE: z.enum(['test', 'live']).optional(), PAYMENTS_ENABLED: z.enum(['true', 'false']).optional().default('false'), + STRIPE_PAYMENTS_ENABLED: z.enum(['true', 'false']).optional(), + + APP_ORIGIN: z.string().url().optional(), NEXT_PUBLIC_SITE_URL: z.string().url().optional(), + SHOP_BASE_URL: z.string().url().optional(), + MONO_MERCHANT_TOKEN: z.string().min(1).optional(), + MONO_WEBHOOK_MODE: z + .enum(['apply', 'store', 'drop']) + .optional() + .default('apply'), + MONO_REFUND_ENABLED: z.enum(['true', 'false']).optional().default('false'), + MONO_INVOICE_VALIDITY_SECONDS: z.string().optional().default('86400'), + MONO_TIME_SKEW_TOLERANCE_SEC: z.string().optional().default('300'), + MONO_PUBLIC_KEY: z.string().min(1).optional(), + MONO_API_BASE: z.string().url().optional(), + MONO_INVOICE_TIMEOUT_MS: z.string().optional(), + SHOP_STATUS_TOKEN_SECRET: z.string().min(32).optional(), UPSTASH_REDIS_REST_URL: z.string().url().optional(), UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(), }); diff --git a/frontend/lib/env/monobank.ts b/frontend/lib/env/monobank.ts new file mode 100644 index 00000000..8a18cf2a --- /dev/null +++ b/frontend/lib/env/monobank.ts @@ -0,0 +1,127 @@ +import 'server-only'; + +import { getRuntimeEnv, getServerEnv } from '@/lib/env'; + +export type MonobankEnv = { + token: string | null; + apiBaseUrl: string; + paymentsEnabled: boolean; + invoiceTimeoutMs: number; + publicKey: string | null; +}; + +export type MonobankWebhookMode = 'apply' | 'store' | 'drop'; + +function parseWebhookMode(raw: string | undefined): MonobankWebhookMode { + const v = (raw ?? 'apply').trim().toLowerCase(); + if (v === 'apply' || v === 'store' || v === 'drop') return v; + return 'apply'; +} + +export function getMonobankConfig(): MonobankConfig { + const env = getServerEnv(); + + const rawMode = process.env.MONO_WEBHOOK_MODE ?? env.MONO_WEBHOOK_MODE; + + return { + webhookMode: parseWebhookMode(rawMode), + refundEnabled: env.MONO_REFUND_ENABLED === 'true', + invoiceValiditySeconds: parsePositiveInt(env.MONO_INVOICE_VALIDITY_SECONDS, 86400), + timeSkewToleranceSec: parsePositiveInt(env.MONO_TIME_SKEW_TOLERANCE_SEC, 300), + baseUrlSource: resolveBaseUrlSource(), + }; +} + + +export type MonobankConfig = { + webhookMode: MonobankWebhookMode; + refundEnabled: boolean; + invoiceValiditySeconds: number; + timeSkewToleranceSec: number; + baseUrlSource: + | 'shop_base_url' + | 'app_origin' + | 'next_public_site_url' + | 'unknown'; +}; + +function nonEmpty(value: string | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function parseTimeoutMs(raw: string | undefined, fallback: number): number { + const v = raw ? Number.parseInt(raw, 10) : NaN; + if (!Number.isFinite(v) || v <= 0) return fallback; + return v; +} + +function parsePositiveInt(raw: string | undefined, fallback: number): number { + const v = raw ? Number.parseInt(raw, 10) : NaN; + if (!Number.isFinite(v) || v <= 0) return fallback; + return v; +} + +function resolveMonobankToken(): string | null { + const env = getServerEnv(); + return nonEmpty(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)) + return 'next_public_site_url'; + return 'unknown'; +} + +export function requireMonobankToken(): string { + const token = resolveMonobankToken(); + if (!token) { + throw new Error('MONO_MERCHANT_TOKEN is required for Monobank operations.'); + } + return token; +} + +export function getMonobankEnv(): MonobankEnv { + const runtimeEnv = getRuntimeEnv(); + const env = getServerEnv(); + + const token = resolveMonobankToken(); + const publicKey = nonEmpty(env.MONO_PUBLIC_KEY); + + const apiBaseUrl = nonEmpty(env.MONO_API_BASE) ?? 'https://api.monobank.ua'; + + const paymentsFlag = env.PAYMENTS_ENABLED ?? 'false'; + const configured = !!token; + const paymentsEnabled = String(paymentsFlag).trim() === 'true' && configured; + + const invoiceTimeoutMs = parseTimeoutMs( + env.MONO_INVOICE_TIMEOUT_MS, + runtimeEnv.NODE_ENV === 'production' ? 8000 : 12000 + ); + + if (!paymentsEnabled) { + return { + token, + apiBaseUrl, + paymentsEnabled: false, + invoiceTimeoutMs, + publicKey, + }; + } + + return { + token, + apiBaseUrl, + paymentsEnabled: true, + invoiceTimeoutMs, + publicKey, + }; +} + +export function isMonobankEnabled(): boolean { + return !!resolveMonobankToken(); +} diff --git a/frontend/lib/env/payments.ts b/frontend/lib/env/payments.ts new file mode 100644 index 00000000..6e1ccc7e --- /dev/null +++ b/frontend/lib/env/payments.ts @@ -0,0 +1,13 @@ +import { isMonobankEnabled } from '@/lib/env/monobank'; +import { isPaymentsEnabled as isStripeEnabled } from '@/lib/env/stripe'; +import type { PaymentProvider } from '@/lib/shop/payments'; + +export function resolveShopPaymentProvider(): PaymentProvider { + if (isMonobankEnabled()) return 'monobank'; + if (isStripeEnabled()) return 'stripe'; + return 'none'; +} + +export function areShopPaymentsEnabled(): boolean { + return resolveShopPaymentProvider() !== 'none'; +} diff --git a/frontend/lib/psp/monobank.ts b/frontend/lib/psp/monobank.ts new file mode 100644 index 00000000..1926c991 --- /dev/null +++ b/frontend/lib/psp/monobank.ts @@ -0,0 +1,877 @@ +import 'server-only'; + +import crypto from 'node:crypto'; + +import { getMonobankEnv } from '@/lib/env/monobank'; +import { logError } from '@/lib/logging'; +export const MONO_CCY = 980 as const; +export const MONO_CURRENCY = 'UAH' as const; + +export type PspErrorCode = + | 'PSP_TIMEOUT' + | 'PSP_BAD_REQUEST' + | 'PSP_AUTH_FAILED' + | 'PSP_UNKNOWN'; + +export class PspError extends Error { + code: PspErrorCode; + safeMeta?: Record; + cause?: unknown; + + constructor( + code: PspErrorCode, + message: string, + safeMeta?: Record, + cause?: unknown + ) { + super(message); + this.code = code; + this.safeMeta = safeMeta; + this.cause = cause; + } +} + +export type MonobankInvoiceCreateInput = { + amountMinor: number; + validitySeconds?: number; + reference: string; + redirectUrl: string; + webHookUrl: string; + merchantPaymInfo: { + reference: string; + destination: string; + basketOrder: Array<{ + name: string; + qty: number; + sum: number; + total: number; + unit?: string; + }>; + }; +}; + +export type MonobankInvoiceCreateResult = { + invoiceId: string; + pageUrl: string; + raw?: Record; +}; + +export type MonobankInvoiceStatusResult = { + invoiceId: string; + status: string; + raw?: Record; +}; + +export type MonobankCancelPaymentInput = { + invoiceId: string; + extRef: string; + amountMinor?: number; +}; + +export type MonobankCancelPaymentResult = { + invoiceId: string; + status: string; + raw?: Record; +}; + +export type MonobankRemoveInvoiceResult = { + invoiceId: string; + removed: boolean; + raw?: Record; +}; + +export type MonobankWebhookPubKeyResult = { + pubKeyPemBytes: Uint8Array; +}; + +export type MonobankWebhookVerifyResult = { + ok: boolean; +}; + +export type MonobankPaymentType = 'debit'; + +export type MonobankInvoiceCreateArgs = { + amountMinor: number; + orderId: string; + redirectUrl: string; + webhookUrl: string; + paymentType?: MonobankPaymentType; + merchantPaymInfo: { + reference: string; + destination: string; + basketOrder: Array<{ + name: string; + qty: number; + sum: number; + total: number; + unit?: string; + }>; + }; +}; + +export type MonobankInvoiceResponse = { + invoiceId: string; + pageUrl: string; + raw: Record; +}; + +type MonobankMerchantPaymInfo = { + reference: string; + destination: string; + basketOrder: Array<{ + name: string; + qty: number; + sum: number; + total: number; + unit?: string; + }>; +}; + +type MonobankMerchantPaymInfoRaw = MonobankMerchantPaymInfo & + Record; + +type BasketOrderItem = MonobankMerchantPaymInfo['basketOrder'][number]; + +function parseBasketOrderOrThrow(raw: unknown): BasketOrderItem[] { + if (!Array.isArray(raw) || raw.length === 0) { + throw new Error('merchantPaymInfo.basketOrder is required'); + } + + const out: BasketOrderItem[] = []; + + for (const it of raw) { + if (!it || typeof it !== 'object') { + throw new Error('basketOrder item must be an object'); + } + + const r = it as Record; + + const name = typeof r.name === 'string' ? r.name.trim() : ''; + if (!name) throw new Error('basketOrder item.name required'); + + const qty = r.qty; + const sum = r.sum; + const total = r.total; + + for (const [k, v] of [ + ['qty', qty], + ['sum', sum], + ['total', total], + ] as const) { + if (typeof v !== 'number' || !Number.isSafeInteger(v)) { + throw new Error(`basketOrder item.${k} must be safe integer`); + } + } + + if ((qty as number) <= 0) + throw new Error('basketOrder item.qty must be > 0'); + if ((sum as number) < 0) + throw new Error('basketOrder item.sum must be >= 0'); + if ((total as number) < 0) + throw new Error('basketOrder item.total must be >= 0'); + + const unit = + typeof r.unit === 'string' && r.unit.trim().length > 0 + ? r.unit.trim() + : undefined; + + out.push({ + name, + qty: qty as number, + sum: sum as number, + total: total as number, + ...(unit ? { unit } : {}), + }); + } + + return out; +} + +type MonobankInvoiceCreateRequest = { + amount: number; + ccy: number; + paymentType: MonobankPaymentType; + merchantPaymInfo: MonobankMerchantPaymInfoRaw; + redirectUrl: string; + webHookUrl: string; + validity?: number; +}; + +type MonobankInvoiceCreateResponse = { + invoiceId?: string; + pageUrl?: string; + paymentUrl?: string; + invoiceUrl?: string; +}; + +type MonobankInvoiceStatusResponse = { + invoiceId?: string; + status?: string; +}; + +type MonobankCancelPaymentRequest = { + invoiceId: string; + extRef: string; + amount?: number; +}; + +type MonobankCancelPaymentResponse = { + invoiceId?: string; + status?: string; +}; + +type MonobankRemoveInvoiceRequest = { + invoiceId: string; +}; + +type MonobankRemoveInvoiceResponse = { + invoiceId?: string; + status?: string; + removed?: boolean; +}; + +export function buildMonobankInvoicePayload( + args: MonobankInvoiceCreateArgs +): MonobankInvoiceCreateRequest { + const paymentType = args.paymentType ?? 'debit'; + if (paymentType !== 'debit') { + throw new Error(`Unsupported paymentType: ${paymentType}`); + } + + if (!Number.isSafeInteger(args.amountMinor) || args.amountMinor <= 0) { + throw new Error('Invalid invoice amount (minor units)'); + } + + const merchantInfo = + args.merchantPaymInfo && typeof args.merchantPaymInfo === 'object' + ? { ...(args.merchantPaymInfo as Record) } + : null; + + if (!merchantInfo) { + throw new Error('merchantPaymInfo is required'); + } + + const reference = + typeof (merchantInfo as { reference?: unknown }).reference === 'string' + ? (merchantInfo as { reference: string }).reference.trim() + : ''; + + const destination = + typeof (merchantInfo as { destination?: unknown }).destination === 'string' + ? (merchantInfo as { destination: string }).destination.trim() + : ''; + + const basketOrder = (merchantInfo as { basketOrder?: unknown }).basketOrder; + + if (!reference || !destination) { + throw new Error('merchantPaymInfo.reference and destination are required'); + } + + const basketOrderValue = parseBasketOrderOrThrow(basketOrder); + + const payload: MonobankInvoiceCreateRequest = { + amount: args.amountMinor, + ccy: MONO_CCY, + paymentType, + merchantPaymInfo: { + ...(merchantInfo as Record), + reference, + destination, + basketOrder: basketOrderValue, + }, + redirectUrl: args.redirectUrl, + webHookUrl: args.webhookUrl, + }; + + return payload; +} + +type MonoRequestArgs = { + method: 'GET' | 'POST'; + path: string; + body?: unknown; + timeoutMs: number; + token?: string; + baseUrl: string; +}; + +type MonoRequestResult = { + ok: true; + data: T; + status: number; + headers?: Headers; +}; + +function normalizeEndpoint(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/$/, ''); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${base}${normalizedPath}`; +} + +function pickStringField( + value: unknown, + keys: readonly string[] +): string | undefined { + if (!value || typeof value !== 'object') return undefined; + for (const key of keys) { + const candidate = (value as Record)[key]; + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim(); + } + } + return undefined; +} + +function parseErrorPayload(text: string): { + monoCode?: string; + monoMessage?: string; + responseSnippet?: string; +} { + const trimmed = text.trim(); + if (!trimmed) return {}; + + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object') { + return { + monoCode: pickStringField(parsed, ['errCode', 'errorCode', 'code']), + monoMessage: pickStringField(parsed, [ + 'error', + 'message', + 'errorDescription', + 'description', + ]), + }; + } + if (typeof parsed === 'string' && parsed.trim()) { + return { responseSnippet: parsed.trim().slice(0, 200) }; + } + } catch { + return { responseSnippet: trimmed.slice(0, 200) }; + } + + return {}; +} + +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const err = error as { name?: unknown; message?: unknown }; + if (err.name === 'AbortError') return true; + if (typeof err.message === 'string') { + const msg = err.message.toLowerCase(); + return msg.includes('abort') || msg.includes('timeout'); + } + return false; +} + +async function requestMono( + args: MonoRequestArgs +): Promise> { + const endpoint = args.path.startsWith('/') ? args.path : `/${args.path}`; + const url = normalizeEndpoint(args.baseUrl, args.path); + const controller = new AbortController(); + let timeoutId: ReturnType | null = null; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort(); + const err = new Error('Request timed out'); + err.name = 'AbortError'; + reject(err); + }, args.timeoutMs); + }); + + const headers: Record = {}; + if (args.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + if (args.token) { + headers['X-Token'] = args.token; + } + + const fetchPromise = fetch(url, { + method: args.method, + headers, + body: args.body === undefined ? undefined : JSON.stringify(args.body), + signal: controller.signal, + }); + void fetchPromise.catch(() => undefined); + + try { + const res = await Promise.race([fetchPromise, timeoutPromise]); + if (timeoutId) clearTimeout(timeoutId); + + const status = res.status; + const text = await res.text(); + + if (!res.ok) { + const parsed = parseErrorPayload(text); + + if (status === 401 || status === 403) { + throw new PspError('PSP_AUTH_FAILED', 'Monobank auth failed', { + endpoint, + method: args.method, + httpStatus: status, + }); + } + + if (status >= 400 && status < 500) { + throw new PspError('PSP_BAD_REQUEST', 'Monobank request rejected', { + endpoint, + method: args.method, + httpStatus: status, + ...(parsed.monoCode ? { monoCode: parsed.monoCode } : {}), + ...(parsed.monoMessage ? { monoMessage: parsed.monoMessage } : {}), + ...(parsed.responseSnippet + ? { responseSnippet: parsed.responseSnippet } + : {}), + }); + } + + throw new PspError('PSP_UNKNOWN', 'Monobank request failed', { + endpoint, + method: args.method, + httpStatus: status, + }); + } + + let data: unknown = null; + if (text.trim().length > 0) { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + + return { ok: true, data: data as T, status, headers: res.headers }; + } catch (error) { + if (timeoutId) clearTimeout(timeoutId); + + if (error instanceof PspError) throw error; + if (isAbortError(error)) { + throw new PspError('PSP_TIMEOUT', 'Monobank request timed out', { + endpoint, + method: args.method, + timeoutMs: args.timeoutMs, + }); + } + + throw new PspError('PSP_UNKNOWN', 'Monobank request failed', { + endpoint, + method: args.method, + }); + } +} + +const PUBKEY_TTL_MS = 5 * 60 * 1000; + +let _cachedWebhookKey: { + key: Uint8Array; + expiresAt: number; +} | null = null; + +function getCachedWebhookKey(): Uint8Array | null { + if (!_cachedWebhookKey) return null; + if (Date.now() >= _cachedWebhookKey.expiresAt) { + _cachedWebhookKey = null; + return null; + } + return _cachedWebhookKey.key; +} + +function cacheWebhookKey(key: Uint8Array): Uint8Array { + _cachedWebhookKey = { + key, + expiresAt: Date.now() + PUBKEY_TTL_MS, + }; + return key; +} + +function parsePageUrl(raw: unknown): string | null { + if (typeof raw === 'string' && raw.trim().length > 0) return raw.trim(); + return null; +} + +function buildMonobankInvoicePayloadFromInput( + args: MonobankInvoiceCreateInput +): MonobankInvoiceCreateRequest { + if (!Number.isSafeInteger(args.amountMinor) || args.amountMinor <= 0) { + throw new Error('Invalid invoice amount (minor units)'); + } + + const merchantInfo = + args.merchantPaymInfo && typeof args.merchantPaymInfo === 'object' + ? { ...(args.merchantPaymInfo as Record) } + : null; + + if (!merchantInfo) { + throw new Error('merchantPaymInfo is required'); + } + + const referenceValue = + typeof (merchantInfo as { reference?: unknown }).reference === 'string' + ? (merchantInfo as { reference: string }).reference.trim() + : ''; + + const destinationValue = + typeof (merchantInfo as { destination?: unknown }).destination === 'string' + ? (merchantInfo as { destination: string }).destination.trim() + : ''; + + const basketOrder = (merchantInfo as { basketOrder?: unknown }).basketOrder; + + if (!referenceValue || !destinationValue) { + throw new Error('merchantPaymInfo.reference and destination are required'); + } + + const canonical = args.reference.trim(); + if (referenceValue !== canonical) { + throw new Error('merchantPaymInfo.reference must match args.reference'); + } + + const basketOrderValue = parseBasketOrderOrThrow(basketOrder); + + const payload: MonobankInvoiceCreateRequest = { + amount: args.amountMinor, + ccy: MONO_CCY, + paymentType: 'debit', + merchantPaymInfo: { + ...(merchantInfo as Record), + reference: referenceValue, + destination: destinationValue, + basketOrder: basketOrderValue, + }, + redirectUrl: args.redirectUrl, + webHookUrl: args.webHookUrl, + }; + + if ( + typeof args.validitySeconds === 'number' && + Number.isFinite(args.validitySeconds) && + args.validitySeconds > 0 + ) { + payload.validity = Math.floor(args.validitySeconds); + } + + return payload; +} + +async function requestCreateInvoice( + payload: MonobankInvoiceCreateRequest +): Promise { + const env = getMonobankEnv(); + + if (!env.paymentsEnabled || !env.token) { + throw new Error('Monobank payments are disabled'); + } + + if (MONO_CURRENCY !== 'UAH') { + throw new Error('Monobank invoice requires UAH currency'); + } + + const res = await requestMono< + MonobankInvoiceCreateResponse & Record + >({ + method: 'POST', + path: '/api/merchant/invoice/create', + body: payload, + timeoutMs: env.invoiceTimeoutMs, + token: env.token, + baseUrl: env.apiBaseUrl, + }); + + if (!res.data || typeof res.data !== 'object') { + throw new Error('Monobank invoice create returned invalid payload'); + } + + const raw = res.data as MonobankInvoiceCreateResponse & + Record; + const invoiceId = typeof raw.invoiceId === 'string' ? raw.invoiceId : ''; + const pageUrl = + parsePageUrl(raw.pageUrl) ?? + parsePageUrl(raw.paymentUrl) ?? + parsePageUrl(raw.invoiceUrl); + + if (!invoiceId || !pageUrl) { + throw new Error('Monobank invoice create missing invoiceId/pageUrl'); + } + + return { invoiceId, pageUrl, raw }; +} + +export async function createInvoice( + args: MonobankInvoiceCreateInput +): Promise { + const payload = buildMonobankInvoicePayloadFromInput(args); + const created = await requestCreateInvoice(payload); + return { + invoiceId: created.invoiceId, + pageUrl: created.pageUrl, + raw: created.raw, + }; +} + +export async function createMonobankInvoice( + args: MonobankInvoiceCreateArgs +): Promise { + const payload = buildMonobankInvoicePayload(args); + return requestCreateInvoice(payload); +} + +export async function getInvoiceStatus( + invoiceId: string +): Promise { + const env = getMonobankEnv(); + + if (!env.paymentsEnabled || !env.token) { + throw new Error('Monobank payments are disabled'); + } + + const res = await requestMono< + MonobankInvoiceStatusResponse & Record + >({ + method: 'GET', + path: `/api/merchant/invoice/status?invoiceId=${encodeURIComponent( + invoiceId + )}`, + timeoutMs: env.invoiceTimeoutMs, + token: env.token, + baseUrl: env.apiBaseUrl, + }); + + if (!res.data || typeof res.data !== 'object') { + throw new Error('Monobank invoice status returned invalid payload'); + } + + const raw = res.data as MonobankInvoiceStatusResponse & + Record; + const normalizedInvoiceId = + typeof raw.invoiceId === 'string' ? raw.invoiceId : ''; + const status = typeof raw.status === 'string' ? raw.status : ''; + + if (!normalizedInvoiceId || !status) { + throw new Error('Monobank invoice status missing invoiceId/status'); + } + + return { invoiceId: normalizedInvoiceId, status, raw }; +} + +export async function cancelInvoicePayment( + args: MonobankCancelPaymentInput +): Promise { + const env = getMonobankEnv(); + + if (!env.paymentsEnabled || !env.token) { + throw new Error('Monobank payments are disabled'); + } + + const payload: MonobankCancelPaymentRequest = { + invoiceId: args.invoiceId, + extRef: args.extRef, + }; + + if ( + typeof args.amountMinor === 'number' && + Number.isFinite(args.amountMinor) && + args.amountMinor > 0 + ) { + payload.amount = Math.floor(args.amountMinor); + } + + const res = await requestMono< + MonobankCancelPaymentResponse & Record + >({ + method: 'POST', + path: '/api/merchant/invoice/cancel', + body: payload, + timeoutMs: env.invoiceTimeoutMs, + token: env.token, + baseUrl: env.apiBaseUrl, + }); + + if (!res.data || typeof res.data !== 'object') { + throw new Error('Monobank cancel payment returned invalid payload'); + } + + const raw = res.data as MonobankCancelPaymentResponse & + Record; + const normalizedInvoiceId = + typeof raw.invoiceId === 'string' ? raw.invoiceId : ''; + const status = typeof raw.status === 'string' ? raw.status : ''; + + if (!normalizedInvoiceId || !status) { + throw new Error('Monobank cancel payment missing invoiceId/status'); + } + + return { invoiceId: normalizedInvoiceId, status, raw }; +} + +export async function removeInvoice( + invoiceId: string +): Promise { + const env = getMonobankEnv(); + + if (!env.paymentsEnabled || !env.token) { + throw new Error('Monobank payments are disabled'); + } + + const payload: MonobankRemoveInvoiceRequest = { invoiceId }; + const res = await requestMono({ + method: 'POST', + path: '/api/merchant/invoice/remove', + body: payload, + timeoutMs: env.invoiceTimeoutMs, + token: env.token, + baseUrl: env.apiBaseUrl, + }); + + if (res.data === null || res.data === undefined) { + return { invoiceId, removed: true }; + } + if (typeof res.data !== 'object') { + throw new Error('Monobank remove invoice returned invalid payload'); + } + + const raw = res.data as MonobankRemoveInvoiceResponse & + Record; + + const removed = + typeof raw.removed === 'boolean' + ? raw.removed + : typeof raw.status === 'string' + ? raw.status === 'removed' + : true; + + return { invoiceId, removed, raw }; +} + +export async function cancelMonobankInvoice(invoiceId: string): Promise { + const env = getMonobankEnv(); + if (!env.paymentsEnabled || !env.token) return; + + try { + await requestMono({ + method: 'POST', + path: '/api/merchant/invoice/cancel', + body: { invoiceId }, + timeoutMs: env.invoiceTimeoutMs, + token: env.token, + baseUrl: env.apiBaseUrl, + }); + } catch (error) { + logError('monobank_invoice_cancel_failed', error, { invoiceId }); + } +} + +function normalizePemPublicKey(raw: string): string { + if (raw.includes('BEGIN PUBLIC KEY')) return raw; + const stripped = raw.replace(/\s+/g, ''); + const chunks = stripped.match(/.{1,64}/g) ?? []; + return `-----BEGIN PUBLIC KEY-----\n${chunks.join('\n')}\n-----END PUBLIC KEY-----`; +} + +export async function fetchWebhookPubKey(options?: { + forceRefresh?: boolean; +}): Promise { + if (!options?.forceRefresh) { + const cached = getCachedWebhookKey(); + if (cached) return cached; + } + + const env = getMonobankEnv(); + if (env.publicKey) { + const pem = normalizePemPublicKey(env.publicKey); + return cacheWebhookKey(Buffer.from(pem)); + } + + if (!env.token || !env.paymentsEnabled) { + throw new Error('Monobank public key unavailable'); + } + + const res = await requestMono({ + method: 'GET', + path: '/api/merchant/pubkey', + timeoutMs: env.invoiceTimeoutMs, + token: env.token, + baseUrl: env.apiBaseUrl, + }); + + let key = ''; + if (typeof res.data === 'string') { + key = res.data.trim(); + } else if (res.data && typeof res.data === 'object') { + const candidate = (res.data as { key?: unknown }).key; + if (typeof candidate === 'string') key = candidate.trim(); + } + + if (!key) throw new Error('Monobank pubkey missing in response'); + + const pem = normalizePemPublicKey(key); + return cacheWebhookKey(Buffer.from(pem)); +} + +export function verifyWebhookSignature( + rawBodyBytes: Uint8Array, + xSignBase64: string | null, + pubKeyPemBytes: Uint8Array +): boolean { + if (!xSignBase64) return false; + + try { + const sig = Buffer.from(xSignBase64, 'base64'); + + const data = Buffer.isBuffer(rawBodyBytes) + ? rawBodyBytes + : Buffer.from(rawBodyBytes); + + const key = Buffer.isBuffer(pubKeyPemBytes) + ? pubKeyPemBytes + : Buffer.from(pubKeyPemBytes); + + return crypto.verify('sha256', data, key, sig); + } catch { + return false; + } +} + +export async function verifyWebhookSignatureWithRefresh(args: { + rawBodyBytes: Uint8Array; + signature: string | null; +}): Promise { + if (!args.signature) return false; + + let key: Uint8Array; + try { + key = await fetchWebhookPubKey(); + } catch { + return false; + } + + if (verifyWebhookSignature(args.rawBodyBytes, args.signature, key)) { + return true; + } + + try { + const refreshed = await fetchWebhookPubKey({ forceRefresh: true }); + return verifyWebhookSignature(args.rawBodyBytes, args.signature, refreshed); + } catch { + return false; + } +} + +export async function getMonobankPublicKey(): Promise { + const key = await fetchWebhookPubKey(); + return Buffer.from(key).toString('utf8'); +} + +export async function verifyMonobankWebhookSignature(args: { + rawBody: string; + signature: string | null; +}): Promise { + const rawBodyBytes = Buffer.from(args.rawBody, 'utf8'); + return verifyWebhookSignatureWithRefresh({ + rawBodyBytes, + signature: args.signature, + }); +} diff --git a/frontend/lib/psp/monobank/merchant-paym-info.ts b/frontend/lib/psp/monobank/merchant-paym-info.ts new file mode 100644 index 00000000..c7013581 --- /dev/null +++ b/frontend/lib/psp/monobank/merchant-paym-info.ts @@ -0,0 +1,227 @@ +import 'server-only'; + +import type { MonobankInvoiceCreateInput } from '@/lib/psp/monobank'; + +type MinorInput = number | bigint | string | null | undefined; + +type MerchantPaymInfoBase = NonNullable< + MonobankInvoiceCreateInput['merchantPaymInfo'] +>; + +export type MonoBasketOrderItem = { + name: string; + qty: number; + sum: number; + total: number; + unit?: string; +}; + +export type MonoMerchantPaymInfo = MerchantPaymInfoBase & { + reference: string; + destination: string; + basketOrder: MonoBasketOrderItem[]; +}; + +export type MonoOrderSnapshot = { + id: string; + currency: string; + totalAmountMinor: MinorInput; + displayLabel?: string | null; +}; + +export type MonoOrderItemSnapshot = { + productId?: string | null; + title?: string | null; + quantity: MinorInput; + unitPriceMinor: MinorInput; + lineTotalMinor: MinorInput; +}; + +export class MonobankMerchantPaymInfoError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} +const ZERO = BigInt(0); + +const MAX_NAME_LEN = 128; + +function normalizeText(value: string, maxLen: number): string { + const trimmed = value.replace(/\s+/g, ' ').trim(); + if (!trimmed) return ''; + if (trimmed.length <= maxLen) return trimmed; + return trimmed.slice(0, maxLen); +} + +function shortId(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed.slice(0, 8); +} + +function parseIntegerStrict( + value: MinorInput, + field: string, + opts?: { allowZero?: boolean } +): bigint { + if (value === null || value === undefined) { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + `${field} is required` + ); + } + + let parsed: bigint; + + if (typeof value === 'bigint') { + parsed = value; + } else if (typeof value === 'number') { + if (!Number.isFinite(value) || !Number.isSafeInteger(value)) { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + `${field} must be a safe integer` + ); + } + parsed = BigInt(value); + } else if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed || !/^-?\d+$/.test(trimmed)) { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + `${field} must be an integer string` + ); + } + parsed = BigInt(trimmed); + } else { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + `${field} must be an integer` + ); + } + + const allowZero = opts?.allowZero ?? false; + if (allowZero) { + if (parsed < ZERO) { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + `${field} must be non-negative` + ); + } + } else if (parsed <= ZERO) { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + `${field} must be positive` + ); + } + + return parsed; +} + +function toSafeNumber(value: bigint, field: string): number { + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + `${field} exceeds MAX_SAFE_INTEGER` + ); + } + return Number(value); +} + +function buildDestination(order: MonoOrderSnapshot): string { + const label = normalizeText(order.displayLabel ?? '', 32); + const fallback = shortId(order.id) || normalizeText(order.id, 32); + return normalizeText(`Оплата замовлення ${label || fallback}`, MAX_NAME_LEN); +} + +function buildItemName(item: MonoOrderItemSnapshot): string { + const title = normalizeText(item.title ?? '', MAX_NAME_LEN); + if (title) return title; + const fallbackId = item.productId ? shortId(item.productId) : ''; + return normalizeText( + fallbackId ? `Item ${fallbackId}` : 'Item', + MAX_NAME_LEN + ); +} + +export function buildMonoMerchantPaymInfoFromSnapshot(args: { + reference: string; + order: MonoOrderSnapshot; + items: MonoOrderItemSnapshot[]; + expectedAmountMinor?: MinorInput; +}): MonoMerchantPaymInfo { + if (!args.reference || !args.reference.trim()) { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + 'reference is required' + ); + } + + const currency = args.order.currency?.toUpperCase?.() ?? ''; + if (currency !== 'UAH') { + throw new MonobankMerchantPaymInfoError( + 'MONO_UAH_ONLY', + 'Monobank requires UAH currency' + ); + } + + const orderTotal = parseIntegerStrict( + args.order.totalAmountMinor, + 'order.totalAmountMinor' + ); + const expected = parseIntegerStrict( + args.expectedAmountMinor ?? args.order.totalAmountMinor, + 'expectedAmountMinor' + ); + + if (expected !== orderTotal) { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + 'Order total mismatch' + ); + } + + let basketSum = ZERO; + const basketOrder: MonoBasketOrderItem[] = args.items.map(item => { + const qty = parseIntegerStrict(item.quantity, 'item.quantity'); + const unitPrice = parseIntegerStrict( + item.unitPriceMinor, + 'item.unitPriceMinor' + ); + const lineTotal = parseIntegerStrict( + item.lineTotalMinor, + 'item.lineTotalMinor' + ); + + if (unitPrice * qty !== lineTotal) { + throw new MonobankMerchantPaymInfoError( + 'MONO_INVALID_SNAPSHOT', + 'Line total mismatch' + ); + } + + basketSum += lineTotal; + + return { + name: buildItemName(item), + qty: toSafeNumber(qty, 'item.quantity'), + sum: toSafeNumber(unitPrice, 'item.unitPriceMinor'), + total: toSafeNumber(lineTotal, 'item.lineTotalMinor'), + unit: 'шт.', + }; + }); + + if (basketSum !== expected) { + throw new MonobankMerchantPaymInfoError( + 'MONO_BASKET_SUM_MISMATCH', + 'Basket total does not match expected amount' + ); + } + + return { + reference: args.reference.trim(), + destination: buildDestination(args.order), + basketOrder, + }; +} diff --git a/frontend/lib/quiz/quiz-answers-redis.ts b/frontend/lib/quiz/quiz-answers-redis.ts index 678c663a..32e12061 100644 --- a/frontend/lib/quiz/quiz-answers-redis.ts +++ b/frontend/lib/quiz/quiz-answers-redis.ts @@ -7,7 +7,7 @@ import { quizQuestionContent, quizQuestions} from '@/db/schema/quiz'; import { getRedisClient } from '@/lib/redis'; -import type { QuizQuestionWithAnswers, AttemptReview} from '@/types/quiz'; +import type { QuizQuestionWithAnswers } from '@/types/quiz'; interface QuizAnswersCache { quizId: string; @@ -283,41 +283,3 @@ export async function clearVerifiedQuestions( console.warn('Failed to clear verified questions:', err); } } - -const ATTEMPT_REVIEW_TTL = 48 * 60 * 60; // 48 hours - -function getAttemptReviewCacheKey(attemptId: string, locale: string): string { - return `quiz:attempt-review:${attemptId}:${locale}`; -} - -export async function getCachedAttemptReview( - attemptId: string, - locale: string -): Promise { - const redis = getRedisClient(); - if (!redis) return null; - - try { - return await redis.get(getAttemptReviewCacheKey(attemptId, locale)); - } catch (err) { - console.warn('Redis attempt review cache read failed:', err); - return null; - } -} - -export async function cacheAttemptReview( - attemptId: string, - locale: string, - data: AttemptReview -): Promise { - const redis = getRedisClient(); - if (!redis) return; - - try { - await redis.set(getAttemptReviewCacheKey(attemptId, locale), data, { - ex: ATTEMPT_REVIEW_TTL, - }); - } catch (err) { - console.warn('Redis attempt review cache write failed:', err); - } -} diff --git a/frontend/lib/services/errors.ts b/frontend/lib/services/errors.ts index 42be4771..73b9cb7a 100644 --- a/frontend/lib/services/errors.ts +++ b/frontend/lib/services/errors.ts @@ -29,11 +29,16 @@ export class OrderNotFoundError extends Error { export class InvalidPayloadError extends Error { code: string; + details?: Record; - constructor(message = 'Invalid payload', opts?: { code?: string }) { + constructor( + message = 'Invalid payload', + opts?: { code?: string; details?: Record } + ) { super(message); this.name = 'InvalidPayloadError'; this.code = opts?.code ?? 'INVALID_PAYLOAD'; + this.details = opts?.details; } } @@ -110,3 +115,33 @@ export class OrderStateInvalidError extends Error { this.details = opts?.details; } } + +export class PspUnavailableError extends Error { + readonly code = 'PSP_UNAVAILABLE' as const; + readonly orderId?: string; + readonly requestId?: string; + + constructor( + message = 'PSP unavailable', + opts?: { orderId?: string; requestId?: string } + ) { + super(message); + this.name = 'PspUnavailableError'; + this.orderId = opts?.orderId; + this.requestId = opts?.requestId; + } +} + +export class PspInvoicePersistError extends Error { + readonly code = 'PSP_INVOICE_PERSIST_FAILED' as const; + readonly orderId?: string; + + constructor( + message = 'Failed to persist PSP invoice', + opts?: { orderId?: string } + ) { + super(message); + this.name = 'PspInvoicePersistError'; + this.orderId = opts?.orderId; + } +} diff --git a/frontend/lib/services/orders/_shared.ts b/frontend/lib/services/orders/_shared.ts index cfe5add1..36a9c7b3 100644 --- a/frontend/lib/services/orders/_shared.ts +++ b/frontend/lib/services/orders/_shared.ts @@ -34,7 +34,8 @@ export function resolvePaymentProvider( ): PaymentProvider { const provider = order.paymentProvider; - if (provider === 'stripe' || provider === 'none') return provider; + if (provider === 'stripe' || provider === 'monobank' || provider === 'none') + return provider; if (order.paymentIntentId) return 'stripe'; if (order.paymentStatus === 'paid') return 'none'; diff --git a/frontend/lib/services/orders/attempt-idempotency.ts b/frontend/lib/services/orders/attempt-idempotency.ts new file mode 100644 index 00000000..b5ea9510 --- /dev/null +++ b/frontend/lib/services/orders/attempt-idempotency.ts @@ -0,0 +1,14 @@ +export function buildStripeAttemptIdempotencyKey( + provider: 'stripe', + orderId: string, + attemptNo: number +): string { + return `pi:${provider}:${orderId}:${attemptNo}`; +} + +export function buildMonobankAttemptIdempotencyKey( + orderId: string, + attemptNo: number +): string { + return `mono:${orderId}:${attemptNo}`; +} diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index adcafc03..b26acdf2 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -276,7 +276,6 @@ function parseVariantList(raw: unknown): string[] { return Array.from(new Set(out)); } } catch { - // Intentionally empty: fall through to delimiter-based parsing } } @@ -365,15 +364,27 @@ export async function createOrderWithItems({ idempotencyKey, userId, locale, + paymentProvider: requestedProvider, }: { items: CheckoutItem[]; idempotencyKey: string; userId?: string | null; locale: string | null | undefined; + paymentProvider?: PaymentProvider; }): Promise { - const currency: Currency = resolveCurrencyFromLocale(locale); - const paymentsEnabled = isPaymentsEnabled(); - const paymentProvider: PaymentProvider = paymentsEnabled ? 'stripe' : 'none'; + const isMonobankRequested = requestedProvider === 'monobank'; + const currency: Currency = isMonobankRequested + ? 'UAH' + : resolveCurrencyFromLocale(locale); + const stripePaymentsEnabled = isPaymentsEnabled(); + const paymentProvider: PaymentProvider = + requestedProvider === 'monobank' + ? 'monobank' + : stripePaymentsEnabled + ? 'stripe' + : 'none'; + const paymentsEnabled = + paymentProvider === 'monobank' ? true : stripePaymentsEnabled; const initialPaymentStatus: PaymentStatus = paymentProvider === 'none' ? 'paid' : 'pending'; diff --git a/frontend/lib/services/orders/monobank-cancel-payment.ts b/frontend/lib/services/orders/monobank-cancel-payment.ts new file mode 100644 index 00000000..2369ac16 --- /dev/null +++ b/frontend/lib/services/orders/monobank-cancel-payment.ts @@ -0,0 +1,569 @@ +import 'server-only'; + +import { and, desc, eq, inArray } from 'drizzle-orm'; + +import { db } from '@/db'; +import { monobankPaymentCancels, orders, paymentAttempts } from '@/db/schema'; +import { getMonobankEnv } from '@/lib/env/monobank'; +import { logError, logInfo, logWarn } from '@/lib/logging'; +import { PspError, removeInvoice } from '@/lib/psp/monobank'; + +import { + InvalidPayloadError, + OrderNotFoundError, + PspUnavailableError, +} from '../errors'; +import { restockOrder } from './restock'; +import { getOrderById } from './summary'; + +type CancelStatus = 'requested' | 'processing' | 'success' | 'failure'; +type CancelRow = typeof monobankPaymentCancels.$inferSelect; +const REQUESTED_POLL_ATTEMPTS = 5; +const REQUESTED_POLL_DELAY_MS = 75; + +type OrderCancelRow = { + id: string; + paymentProvider: string; + paymentStatus: string; + status: string; + inventoryStatus: string; + stockRestored: boolean; + pspChargeId: string | null; +}; + +function invalid(code: string, message: string): InvalidPayloadError { + return new InvalidPayloadError(message, { code }); +} + +function toTrimmedOrNull(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function makeCancelExtRef(orderId: string): string { + return `mono_cancel:${orderId}`; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function isPaidLike(order: Pick): boolean { + return ( + order.paymentStatus === 'paid' || + order.paymentStatus === 'refunded' || + order.status === 'PAID' + ); +} + +function isFinalCanceled(order: Pick): boolean { + return ( + order.status === 'CANCELED' && + order.inventoryStatus === 'released' && + order.stockRestored + ); +} + +async function loadOrderForCancel(orderId: string): Promise { + const [row] = await db + .select({ + id: orders.id, + paymentProvider: orders.paymentProvider, + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + pspChargeId: orders.pspChargeId, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + if (!row) throw new OrderNotFoundError('Order not found.'); + return row; +} + +function readInvoiceFromAttempt(row: { + providerPaymentIntentId: string | null; + metadata: unknown; +}): string | null { + const direct = toTrimmedOrNull(row.providerPaymentIntentId); + if (direct) return direct; + + if (!row.metadata || typeof row.metadata !== 'object' || Array.isArray(row.metadata)) { + return null; + } + + return toTrimmedOrNull((row.metadata as Record).invoiceId); +} + +async function findInvoiceAttempt( + orderId: string, + statuses: string[] +): Promise<{ attemptId: string | null; invoiceId: string | null }> { + const [attempt] = await db + .select({ + id: paymentAttempts.id, + providerPaymentIntentId: paymentAttempts.providerPaymentIntentId, + metadata: paymentAttempts.metadata, + }) + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.orderId, orderId), + eq(paymentAttempts.provider, 'monobank'), + inArray(paymentAttempts.status, statuses) + ) + ) + .orderBy( + desc(paymentAttempts.updatedAt), + desc(paymentAttempts.createdAt), + desc(paymentAttempts.attemptNumber), + desc(paymentAttempts.id) + ) + .limit(1); + + if (!attempt) return { attemptId: null, invoiceId: null }; + + return { + attemptId: attempt.id, + invoiceId: readInvoiceFromAttempt(attempt), + }; +} + +async function resolveInvoiceForCancel(orderId: string, pspChargeId: string | null) { + const direct = toTrimmedOrNull(pspChargeId); + if (direct) return { invoiceId: direct, attemptId: null as string | null }; + + const succeeded = await findInvoiceAttempt(orderId, ['succeeded']); + if (succeeded.invoiceId) return succeeded; + + const active = await findInvoiceAttempt(orderId, ['active', 'creating']); + if (active.invoiceId) return active; + + return { + invoiceId: null, + attemptId: succeeded.attemptId ?? active.attemptId, + }; +} + +async function getCancelByExtRef(extRef: string): Promise { + const rows = await db + .select() + .from(monobankPaymentCancels) + .where(eq(monobankPaymentCancels.extRef, extRef)) + .limit(1); + + return rows[0] ?? null; +} + +async function pollRequestedCancelStatus( + extRef: string +): Promise { + let row = await getCancelByExtRef(extRef); + + for ( + let attempt = 0; + attempt < REQUESTED_POLL_ATTEMPTS && row?.status === 'requested'; + attempt++ + ) { + await sleep(REQUESTED_POLL_DELAY_MS); + row = await getCancelByExtRef(extRef); + } + + return row; +} + +async function insertRequestedCancel(args: { + orderId: string; + extRef: string; + invoiceId: string; + attemptId: string | null; + requestId: string; +}): Promise { + const rows = await db + .insert(monobankPaymentCancels) + .values({ + orderId: args.orderId, + extRef: args.extRef, + invoiceId: args.invoiceId, + attemptId: args.attemptId, + status: 'requested', + requestId: args.requestId, + }) + .onConflictDoNothing({ target: monobankPaymentCancels.extRef }) + .returning(); + + return rows[0] ?? null; +} + +async function updateCancelStatus(args: { + cancelId: string; + status: CancelStatus; + requestId: string; + errorCode?: string | null; + errorMessage?: string | null; + pspResponse?: Record | null; +}): Promise { + const rows = await db + .update(monobankPaymentCancels) + .set({ + status: args.status, + requestId: args.requestId, + errorCode: args.errorCode ?? null, + errorMessage: args.errorMessage ?? null, + pspResponse: args.pspResponse ?? null, + updatedAt: new Date(), + }) + .where(eq(monobankPaymentCancels.id, args.cancelId)) + .returning(); + + return rows[0] ?? null; +} + +async function retryFailedCancel(args: { + extRef: string; + requestId: string; +}): Promise { + const rows = await db + .update(monobankPaymentCancels) + .set({ + status: 'requested', + requestId: args.requestId, + errorCode: null, + errorMessage: null, + pspResponse: null, + updatedAt: new Date(), + }) + .where( + and( + eq(monobankPaymentCancels.extRef, args.extRef), + eq(monobankPaymentCancels.status, 'failure') + ) + ) + .returning(); + + return rows[0] ?? null; +} + +async function finalizeProcessingCancel(args: { + cancelRow: CancelRow; + orderId: string; + requestId: string; +}): Promise<{ + order: Awaited>; + cancel: { id: string | null; extRef: string; status: string; deduped: boolean }; +}> { + try { + await restockOrder(args.orderId, { + reason: 'canceled', + workerId: 'admin-cancel-payment', + }); + } catch (error) { + logError('monobank_cancel_payment_finalize_failed', error, { + code: 'CANCEL_FINALIZE_FAILED', + orderId: args.orderId, + cancelId: args.cancelRow.id, + extRef: args.cancelRow.extRef, + requestId: args.requestId, + }); + + throw error; + } + + const updated = await db + .update(monobankPaymentCancels) + .set({ + status: 'success', + requestId: args.requestId, + updatedAt: new Date(), + }) + .where( + and( + eq(monobankPaymentCancels.id, args.cancelRow.id), + eq(monobankPaymentCancels.status, 'processing') + ) + ) + .returning(); + + const row = updated[0] ?? (await getCancelByExtRef(args.cancelRow.extRef)); + + return { + order: await getOrderById(args.orderId), + cancel: { + id: row?.id ?? args.cancelRow.id, + extRef: args.cancelRow.extRef, + status: row?.status ?? 'success', + deduped: true, + }, + }; +} + +export async function cancelMonobankUnpaidPayment(args: { + orderId: string; + requestId: string; +}): Promise<{ + order: Awaited>; + cancel: { + id: string | null; + extRef: string; + status: string; + deduped: boolean; + }; +}> { + const env = getMonobankEnv(); + if (!env.token || !env.paymentsEnabled) { + throw invalid('CANCEL_DISABLED', 'Cancel payments are disabled.'); + } + + const order = await loadOrderForCancel(args.orderId); + + if (order.paymentProvider !== 'monobank') { + throw invalid( + 'CANCEL_PROVIDER_NOT_MONOBANK', + 'Cancel payment is supported only for Monobank orders.' + ); + } + + if (isPaidLike(order)) { + throw invalid('CANCEL_NOT_ALLOWED', 'Order is already paid/refunded.'); + } + + const extRef = makeCancelExtRef(args.orderId); + + if (isFinalCanceled(order)) { + const existing = await getCancelByExtRef(extRef); + return { + order: await getOrderById(args.orderId), + cancel: { + id: existing?.id ?? null, + extRef, + status: 'success', + deduped: true, + }, + }; + } + + const resolved = await resolveInvoiceForCancel(args.orderId, order.pspChargeId); + if (!resolved.invoiceId) { + throw invalid( + 'CANCEL_MISSING_PROVIDER_REF', + 'Missing Monobank invoice identifier for cancel.' + ); + } + + let cancelRow = await insertRequestedCancel({ + orderId: args.orderId, + extRef, + invoiceId: resolved.invoiceId, + attemptId: resolved.attemptId, + requestId: args.requestId, + }); + + let isLeader = !!cancelRow; + + if (!cancelRow) { + const current = await getCancelByExtRef(extRef); + if (!current) { + throw new PspUnavailableError('Cancel idempotency state unavailable.', { + orderId: args.orderId, + requestId: args.requestId, + }); + } + + if (current.status === 'success') { + return { + order: await getOrderById(args.orderId), + cancel: { + id: current.id, + extRef, + status: 'success', + deduped: true, + }, + }; + } + + if (current.status === 'processing') { + return finalizeProcessingCancel({ + cancelRow: current, + orderId: args.orderId, + requestId: args.requestId, + }); + } + + if (current.status === 'requested') { + const settled = await pollRequestedCancelStatus(extRef); + if (!settled || settled.status === 'requested') { + throw invalid( + 'CANCEL_IN_PROGRESS', + 'Cancel payment is already in progress. Retry shortly.' + ); + } + + if (settled.status === 'success') { + return { + order: await getOrderById(args.orderId), + cancel: { + id: settled.id, + extRef, + status: 'success', + deduped: true, + }, + }; + } + + if (settled.status === 'processing') { + return finalizeProcessingCancel({ + cancelRow: settled, + orderId: args.orderId, + requestId: args.requestId, + }); + } + + return { + order: await getOrderById(args.orderId), + cancel: { + id: settled.id, + extRef, + status: settled.status, + deduped: true, + }, + }; + } + + const retried = await retryFailedCancel({ + extRef, + requestId: args.requestId, + }); + + if (retried) { + cancelRow = retried; + isLeader = true; + } else { + const afterRetry = await getCancelByExtRef(extRef); + if (!afterRetry) { + throw new PspUnavailableError('Cancel state missing after retry.', { + orderId: args.orderId, + requestId: args.requestId, + }); + } + + if (afterRetry.status === 'processing') { + return finalizeProcessingCancel({ + cancelRow: afterRetry, + orderId: args.orderId, + requestId: args.requestId, + }); + } + + return { + order: await getOrderById(args.orderId), + cancel: { + id: afterRetry.id, + extRef, + status: afterRetry.status, + deduped: true, + }, + }; + } + } + + if (!isLeader || !cancelRow) { + throw new PspUnavailableError('Cancel leader election failed.', { + orderId: args.orderId, + requestId: args.requestId, + }); + } + + let pspResponse: Record | null = null; + + try { + const result = await removeInvoice(cancelRow.invoiceId); + pspResponse = + result && typeof result === 'object' && !Array.isArray(result) + ? (result as Record) + : null; + } catch (error) { + const errorCode = error instanceof PspError ? error.code : 'PSP_UNAVAILABLE'; + const errorMessage = error instanceof Error ? error.message : 'PSP unavailable'; + + await updateCancelStatus({ + cancelId: cancelRow.id, + status: 'failure', + requestId: args.requestId, + errorCode, + errorMessage, + pspResponse: null, + }); + + logWarn('monobank_cancel_payment_psp_unavailable', { + code: 'PSP_UNAVAILABLE', + orderId: args.orderId, + cancelId: cancelRow.id, + extRef, + requestId: args.requestId, + pspCode: errorCode, + }); + + throw new PspUnavailableError('Payment provider unavailable.', { + orderId: args.orderId, + requestId: args.requestId, + }); + } + + cancelRow = + (await updateCancelStatus({ + cancelId: cancelRow.id, + status: 'processing', + requestId: args.requestId, + errorCode: null, + errorMessage: null, + pspResponse, + })) ?? cancelRow; + + try { + await restockOrder(args.orderId, { + reason: 'canceled', + workerId: 'admin-cancel-payment', + }); + } catch (error) { + logError('monobank_cancel_payment_finalize_failed', error, { + code: 'CANCEL_FINALIZE_FAILED', + orderId: args.orderId, + cancelId: cancelRow.id, + extRef, + requestId: args.requestId, + }); + + throw error; + } + + const successRow = + (await updateCancelStatus({ + cancelId: cancelRow.id, + status: 'success', + requestId: args.requestId, + errorCode: null, + errorMessage: null, + pspResponse, + })) ?? cancelRow; + + logInfo('monobank_cancel_payment_succeeded', { + code: 'CANCEL_PAYMENT_SUCCEEDED', + orderId: args.orderId, + cancelId: successRow.id, + extRef, + requestId: args.requestId, + }); + + return { + order: await getOrderById(args.orderId), + cancel: { + id: successRow.id, + extRef, + status: successRow.status, + deduped: false, + }, + }; +} diff --git a/frontend/lib/services/orders/monobank-refund.ts b/frontend/lib/services/orders/monobank-refund.ts new file mode 100644 index 00000000..dee7f831 --- /dev/null +++ b/frontend/lib/services/orders/monobank-refund.ts @@ -0,0 +1,428 @@ +import 'server-only'; + +import { and, desc, eq, inArray } from 'drizzle-orm'; + +import { db } from '@/db'; +import { monobankRefunds, orders, paymentAttempts } from '@/db/schema'; +import { getMonobankConfig } from '@/lib/env/monobank'; +import { logWarn } from '@/lib/logging'; +import { cancelInvoicePayment, PspError } from '@/lib/psp/monobank'; + +import { + InvalidPayloadError, + OrderNotFoundError, + PspUnavailableError, +} from '../errors'; +import { getOrderById } from './summary'; + +type MonobankRefundRow = typeof monobankRefunds.$inferSelect; +type RefundStatus = MonobankRefundRow['status']; + +function invalid( + code: string, + message: string, + details?: Record +): InvalidPayloadError { + return new InvalidPayloadError(message, { code, details }); +} + +function toTrimmedOrNull(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function makeMonobankRefundExtRef(orderId: string): string { + return `mono_refund:${orderId}:full`; +} + +function isDedupedRefundStatus(status: RefundStatus): boolean { + return status === 'processing' || status === 'success'; +} + +function isRetryableRefundStatus(status: RefundStatus): boolean { + return status === 'requested' || status === 'failure'; +} + +function mapRefundRow(row: MonobankRefundRow) { + return { + id: row.id, + extRef: row.extRef, + status: row.status, + amountMinor: row.amountMinor, + currency: row.currency, + }; +} + +async function getExistingRefund( + extRef: string +): Promise { + const rows = await db + .select() + .from(monobankRefunds) + .where(eq(monobankRefunds.extRef, extRef)) + .limit(1); + return rows[0] ?? null; +} + +function readAttemptInvoiceId(row: { + providerPaymentIntentId: string | null; + metadata: unknown; +}): string | null { + const direct = toTrimmedOrNull(row.providerPaymentIntentId); + if (direct) return direct; + + if ( + !row.metadata || + typeof row.metadata !== 'object' || + Array.isArray(row.metadata) + ) { + return null; + } + + return toTrimmedOrNull((row.metadata as Record).invoiceId); +} + +async function findInvoiceAttempt( + orderId: string, + statuses: string[] +): Promise<{ invoiceId: string | null; attemptId: string | null } | null> { + const [attemptRow] = await db + .select({ + id: paymentAttempts.id, + providerPaymentIntentId: paymentAttempts.providerPaymentIntentId, + metadata: paymentAttempts.metadata, + }) + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.orderId, orderId), + eq(paymentAttempts.provider, 'monobank'), + inArray(paymentAttempts.status, statuses) + ) + ) + .orderBy( + desc(paymentAttempts.updatedAt), + desc(paymentAttempts.createdAt), + desc(paymentAttempts.attemptNumber), + desc(paymentAttempts.id) + ) + .limit(1); + + if (!attemptRow) return null; + + return { + invoiceId: readAttemptInvoiceId(attemptRow), + attemptId: attemptRow.id, + }; +} + +async function getMonobankInvoiceId(orderId: string): Promise<{ + invoiceId: string | null; + attemptId: string | null; +}> { + const [orderRow] = await db + .select({ + pspChargeId: orders.pspChargeId, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + const fromOrder = toTrimmedOrNull(orderRow?.pspChargeId); + if (fromOrder) { + return { invoiceId: fromOrder, attemptId: null }; + } + + const succeeded = await findInvoiceAttempt(orderId, ['succeeded']); + if (succeeded?.invoiceId) return succeeded; + + const active = await findInvoiceAttempt(orderId, ['active', 'creating']); + if (active?.invoiceId) return active; + + return { + invoiceId: null, + attemptId: succeeded?.attemptId ?? active?.attemptId ?? null, + }; +} + +async function reconcileSuccessFromOrder(args: { + refund: MonobankRefundRow; + orderId: string; +}): Promise { + if (args.refund.status === 'success') return args.refund; + + const [orderRow] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, args.orderId)) + .limit(1); + + if (!orderRow || orderRow.paymentStatus !== 'refunded') { + return args.refund; + } + + const now = new Date(); + const updated = await db + .update(monobankRefunds) + .set({ + status: 'success', + providerModifiedAt: now, + updatedAt: now, + }) + .where(eq(monobankRefunds.id, args.refund.id)) + .returning(); + + return updated[0] ?? args.refund; +} + +export async function requestMonobankFullRefund(args: { + orderId: string; + requestId: string; +}): Promise<{ + order: Awaited>; + refund: { + id: string; + extRef: string; + status: string; + amountMinor: number; + currency: string; + }; + deduped: boolean; +}> { + const { refundEnabled } = getMonobankConfig(); + if (!refundEnabled) { + throw invalid('REFUND_DISABLED', 'Refunds are disabled.'); + } + + const [orderRow] = await db + .select({ + id: orders.id, + paymentProvider: orders.paymentProvider, + paymentStatus: orders.paymentStatus, + currency: orders.currency, + totalAmountMinor: orders.totalAmountMinor, + }) + .from(orders) + .where(eq(orders.id, args.orderId)) + .limit(1); + + if (!orderRow) throw new OrderNotFoundError('Order not found.'); + + if (orderRow.paymentProvider !== 'monobank') { + throw invalid( + 'REFUND_PROVIDER_NOT_MONOBANK', + 'Refund is supported only for Monobank orders' + ); + } + + const amountMinor = orderRow.totalAmountMinor; + if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) { + throw invalid('REFUND_ORDER_MONEY_INVALID', 'Invalid order amount'); + } + + if (orderRow.currency !== 'UAH') { + throw invalid( + 'REFUND_ORDER_CURRENCY_INVALID', + 'Monobank refund requires UAH order currency' + ); + } + + const extRef = makeMonobankRefundExtRef(args.orderId); + const existing = await getExistingRefund(extRef); + let refundRowForPsp: MonobankRefundRow | null = null; + let deduped = false; + + if (existing) { + const reconciled = await reconcileSuccessFromOrder({ + refund: existing, + orderId: args.orderId, + }); + + if (isDedupedRefundStatus(reconciled.status)) { + return { + order: await getOrderById(args.orderId), + refund: mapRefundRow(reconciled), + deduped: true, + }; + } + + if (!isRetryableRefundStatus(reconciled.status)) { + return { + order: await getOrderById(args.orderId), + refund: mapRefundRow(reconciled), + deduped: true, + }; + } + + if (orderRow.paymentStatus !== 'paid') { + throw invalid( + 'REFUND_ORDER_NOT_PAID', + 'Order is not refundable in current state' + ); + } + + const now = new Date(); + const retried = await db + .update(monobankRefunds) + .set({ + status: 'requested', + providerModifiedAt: now, + updatedAt: now, + }) + .where(eq(monobankRefunds.id, reconciled.id)) + .returning(); + + refundRowForPsp = retried[0] ?? reconciled; + deduped = false; + } + + if (!refundRowForPsp && orderRow.paymentStatus !== 'paid') { + throw invalid( + 'REFUND_ORDER_NOT_PAID', + 'Order is not refundable in current state' + ); + } + + const { invoiceId, attemptId } = await getMonobankInvoiceId(args.orderId); + if (!invoiceId) { + throw invalid( + 'REFUND_MISSING_PROVIDER_REF', + 'Missing Monobank invoice identifier for refund' + ); + } + + if (!refundRowForPsp) { + const now = new Date(); + const inserted = await db + .insert(monobankRefunds) + .values({ + provider: 'monobank', + orderId: args.orderId, + attemptId, + extRef, + status: 'requested', + amountMinor, + currency: 'UAH', + providerCreatedAt: now, + providerModifiedAt: now, + }) + .onConflictDoNothing({ target: monobankRefunds.extRef }) + .returning(); + + if (!inserted[0]) { + const conflict = await getExistingRefund(extRef); + if (!conflict) { + logWarn('monobank_refund_idempotency_conflict', { + orderId: args.orderId, + requestId: args.requestId, + extRef, + }); + throw invalid('REFUND_CONFLICT', 'Refund idempotency conflict.', { + orderId: args.orderId, + requestId: args.requestId, + extRef, + }); + } + + const reconciled = await reconcileSuccessFromOrder({ + refund: conflict, + orderId: args.orderId, + }); + + if (isDedupedRefundStatus(reconciled.status)) { + return { + order: await getOrderById(args.orderId), + refund: mapRefundRow(reconciled), + deduped: true, + }; + } + + if (!isRetryableRefundStatus(reconciled.status)) { + return { + order: await getOrderById(args.orderId), + refund: mapRefundRow(reconciled), + deduped: true, + }; + } + + if (orderRow.paymentStatus !== 'paid') { + throw invalid( + 'REFUND_ORDER_NOT_PAID', + 'Order is not refundable in current state' + ); + } + + const now = new Date(); + const retried = await db + .update(monobankRefunds) + .set({ + status: 'requested', + providerModifiedAt: now, + updatedAt: now, + }) + .where(eq(monobankRefunds.id, reconciled.id)) + .returning(); + + refundRowForPsp = retried[0] ?? reconciled; + deduped = false; + } else { + refundRowForPsp = inserted[0]; + deduped = false; + } + } + + if (!refundRowForPsp) { + throw new PspUnavailableError('Refund row not initialized.', { + orderId: args.orderId, + requestId: args.requestId, + }); + } + + try { + await cancelInvoicePayment({ + invoiceId, + extRef, + amountMinor, + }); + } catch (error) { + const now = new Date(); + await db + .update(monobankRefunds) + .set({ + status: 'failure', + providerModifiedAt: now, + updatedAt: now, + }) + .where(eq(monobankRefunds.id, refundRowForPsp.id)); + + logWarn('monobank_refund_psp_unavailable', { + orderId: args.orderId, + attemptId, + code: error instanceof PspError ? error.code : 'PSP_UNAVAILABLE', + requestId: args.requestId, + }); + + throw new PspUnavailableError('Monobank refund unavailable.', { + orderId: args.orderId, + requestId: args.requestId, + }); + } + + const now = new Date(); + const [processing] = await db + .update(monobankRefunds) + .set({ + status: 'processing', + providerModifiedAt: now, + updatedAt: now, + }) + .where(eq(monobankRefunds.id, refundRowForPsp.id)) + .returning(); + + return { + order: await getOrderById(args.orderId), + refund: mapRefundRow(processing ?? refundRowForPsp), + deduped, + }; +} diff --git a/frontend/lib/services/orders/monobank-webhook.ts b/frontend/lib/services/orders/monobank-webhook.ts new file mode 100644 index 00000000..5aec1485 --- /dev/null +++ b/frontend/lib/services/orders/monobank-webhook.ts @@ -0,0 +1,1175 @@ +import 'server-only'; + +import crypto from 'node:crypto'; + +import { and, eq, sql } from 'drizzle-orm'; + +import { db } from '@/db'; +import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; +import { logError, logInfo } from '@/lib/logging'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state'; +import { restockOrder } from '@/lib/services/orders/restock'; +import { isUuidV1toV5 } from '@/lib/utils/uuid'; + +type WebhookMode = 'apply' | 'store' | 'drop'; + +type NormalizedWebhook = { + invoiceId: string; + status: string; + amount: number | null; + ccy: number | null; + reference: string | null; +}; + +type ApplyResult = + | 'applied' + | 'applied_noop' + | 'applied_with_issue' + | 'stored' + | 'dropped' + | 'unmatched' + | 'deduped'; + +type MonobankApplyOutcome = { + appliedResult: ApplyResult; + restockOrderId: string | null; + restockReason: 'failed' | 'refunded' | null; + attemptId: string | null; + orderId: string | null; +}; + +type AttemptRow = Pick< + typeof paymentAttempts.$inferSelect, + | 'id' + | 'orderId' + | 'status' + | 'expectedAmountMinor' + | 'providerPaymentIntentId' + | 'providerModifiedAt' +>; + +type OrderRow = Pick< + typeof orders.$inferSelect, + | 'id' + | 'paymentStatus' + | 'paymentProvider' + | 'status' + | 'currency' + | 'totalAmountMinor' + | 'pspMetadata' +>; + +type PaymentStatusTarget = Parameters< + typeof guardedPaymentStatusUpdate +>[0]['to']; + +const CLAIM_TTL_MS = (() => { + const raw = process.env.MONO_WEBHOOK_CLAIM_TTL_MS; + const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 120_000; +})(); + +const INSTANCE_ID = (() => { + const base = + process.env.VERCEL_DEPLOYMENT_ID ?? process.env.HOSTNAME ?? 'local'; + const suffix = crypto.randomUUID().slice(0, 8); + const value = `${base}:${suffix}`; + return value.length > 64 ? value.slice(0, 64) : value; +})(); + +function toIssueMessage(error: unknown): string { + const msg = + error instanceof Error + ? `${error.name}: ${error.message}` + : typeof error === 'string' + ? error + : 'Unknown error'; + return msg.length > 500 ? msg.slice(0, 500) : msg; +} + +function readDbRows(res: unknown): T[] { + if (Array.isArray(res)) return res as T[]; + const anyRes = res as any; + if (Array.isArray(anyRes?.rows)) return anyRes.rows as T[]; + return []; +} + +function normalizeStatus(raw: unknown): string { + if (typeof raw !== 'string') return ''; + return raw.trim().toLowerCase(); +} + +function normalizeWebhookPayload(raw: Record): { + raw: Record; + normalized: NormalizedWebhook; + providerModifiedAt: Date | null; +} { + const invoiceId = + typeof raw.invoiceId === 'string' ? raw.invoiceId.trim() : ''; + const status = normalizeStatus(raw.status); + + if (!invoiceId || !status) { + throw new InvalidPayloadError('Invalid webhook payload', { + code: 'INVALID_PAYLOAD', + }); + } + + const amount = + typeof raw.amount === 'number' && Number.isFinite(raw.amount) + ? Math.trunc(raw.amount) + : null; + const ccy = + typeof raw.ccy === 'number' && Number.isFinite(raw.ccy) + ? Math.trunc(raw.ccy) + : null; + const reference = + typeof raw.reference === 'string' && raw.reference.trim() + ? raw.reference.trim() + : null; + + const providerModifiedAt = extractProviderModifiedAt(raw); + + return { + raw, + normalized: { + invoiceId, + status, + amount, + ccy, + reference, + }, + providerModifiedAt, + }; +} + +function parseWebhookPayload(rawBody: string): { + raw: Record; + normalized: NormalizedWebhook; + providerModifiedAt: Date | null; +} { + let raw: Record; + try { + raw = JSON.parse(rawBody) as Record; + } catch { + throw new InvalidPayloadError('Invalid JSON payload', { + code: 'INVALID_PAYLOAD', + }); + } + + return normalizeWebhookPayload(raw); +} + +function parseTimestampMs(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + const ms = value < 1e11 ? value * 1000 : value; + return Number.isFinite(ms) ? ms : null; + } + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) return parsed; + } + return null; +} + +function extractProviderModifiedAt(raw: Record): Date | null { + const candidates = [ + raw.modifiedDate, + raw.modifiedAt, + raw.updatedAt, + raw.createdDate, + raw.createdAt, + raw.time, + raw.timestamp, + ]; + + for (const candidate of candidates) { + const ms = parseTimestampMs(candidate); + if (ms !== null) return new Date(ms); + } + + return null; +} + +function buildEventKey(rawSha256: string): string { + return rawSha256; +} + +async function insertEvent(args: { + eventKey: string; + rawSha256: string; + rawPayload: Record; + normalizedPayload: NormalizedWebhook; + providerModifiedAt: Date | null; +}): Promise<{ eventId: string | null; deduped: boolean }> { + const inserted = await db + .insert(monobankEvents) + .values({ + eventKey: args.eventKey, + invoiceId: args.normalizedPayload.invoiceId, + status: args.normalizedPayload.status, + amount: args.normalizedPayload.amount ?? null, + ccy: args.normalizedPayload.ccy ?? null, + reference: args.normalizedPayload.reference ?? null, + rawPayload: args.rawPayload, + normalizedPayload: args.normalizedPayload, + providerModifiedAt: args.providerModifiedAt ?? null, + rawSha256: args.rawSha256, + }) + .onConflictDoNothing() + .returning({ id: monobankEvents.id }); + + const insertedId = inserted[0]?.id ?? null; + if (insertedId) { + return { eventId: insertedId, deduped: false }; + } + + const existing = (await db.execute(sql` + select id + from monobank_events + where event_key = ${args.eventKey} or raw_sha256 = ${args.rawSha256} + limit 1 + `)) as unknown as { rows?: Array<{ id?: string }> }; + + const existingId = existing.rows?.[0]?.id ?? null; + return { eventId: existingId, deduped: true }; +} + +async function claimMonobankEvent(args: { + eventId: string; + instanceId: string; + ttlMs: number; +}): Promise { + const now = new Date(); + const claimExpiresAt = new Date(now.getTime() + args.ttlMs); + + const claimed = (await db.execute(sql` + update monobank_events + set claimed_at = ${now}, + claim_expires_at = ${claimExpiresAt}, + claimed_by = ${args.instanceId} + where id = ${args.eventId}::uuid + and applied_at is null + and (claim_expires_at is null or claim_expires_at < ${now}) + returning id + `)) as unknown as { rows?: Array<{ id?: string }> }; + + return Boolean(claimed.rows?.[0]?.id); +} + +function amountMismatch(args: { + payloadAmount: number | null; + payloadCcy: number | null; + orderCurrency: string; + orderTotal: number; + expectedAmount: number | null; +}): { mismatch: boolean; reason?: string } { + if (args.orderCurrency !== 'UAH') { + return { mismatch: true, reason: 'order_currency_mismatch' }; + } + + if (args.payloadCcy !== null && args.payloadCcy !== 980) { + return { mismatch: true, reason: 'payload_currency_mismatch' }; + } + + const expected = args.expectedAmount ?? args.orderTotal; + if ( + args.payloadAmount !== null && + Number.isFinite(args.payloadAmount) && + args.payloadAmount !== expected + ) { + return { mismatch: true, reason: 'amount_mismatch' }; + } + + if (expected !== args.orderTotal) { + return { mismatch: true, reason: 'expected_amount_mismatch' }; + } + + return { mismatch: false }; +} + +function buildApplyOutcome(args: { + appliedResult: ApplyResult; + restockOrderId?: string | null; + restockReason?: 'failed' | 'refunded' | null; + attemptId?: string | null; + orderId?: string | null; +}): MonobankApplyOutcome { + return { + appliedResult: args.appliedResult, + restockOrderId: args.restockOrderId ?? null, + restockReason: args.restockReason ?? null, + attemptId: args.attemptId ?? null, + orderId: args.orderId ?? null, + }; +} + +function getReferenceAttemptId(reference: string | null): string | null { + return reference && isUuidV1toV5(reference) ? reference : null; +} + +async function fetchAttemptForWebhook(args: { + invoiceId: string; + referenceAttemptId: string | null; +}): Promise { + const attemptRes = (await db.execute(sql` + select + id as "id", + order_id as "orderId", + status as "status", + expected_amount_minor as "expectedAmountMinor", + provider_payment_intent_id as "providerPaymentIntentId", + provider_modified_at as "providerModifiedAt" + from payment_attempts + where provider = 'monobank' + and ( + (${args.referenceAttemptId}::uuid is not null and id = ${args.referenceAttemptId}::uuid) + or provider_payment_intent_id = ${args.invoiceId} + ) + order by case + when (${args.referenceAttemptId}::uuid is not null and id = ${args.referenceAttemptId}::uuid) then 1 + else 0 + end desc + limit 1 + `)) as unknown as { rows?: AttemptRow[] }; + + return attemptRes.rows?.[0] ?? null; +} + +async function fetchOrderForAttempt(orderId: string): Promise { + const orderRes = (await db.execute(sql` + select + id as "id", + payment_status as "paymentStatus", + payment_provider as "paymentProvider", + status as "status", + currency as "currency", + total_amount_minor as "totalAmountMinor", + psp_metadata as "pspMetadata" + from orders + where id = ${orderId}::uuid + limit 1 + `)) as unknown as { rows?: OrderRow[] }; + + return orderRes.rows?.[0] ?? null; +} + +function computeNextProviderModifiedAt( + providerModifiedAt: Date | null, + attemptProviderModifiedAt: Date | null +): Date | null { + if ( + providerModifiedAt && + (!attemptProviderModifiedAt || + providerModifiedAt > attemptProviderModifiedAt) + ) { + return providerModifiedAt; + } + + return attemptProviderModifiedAt; +} + +async function transitionPaymentStatus(args: { + orderId: string; + status: string; + eventId: string; + to: PaymentStatusTarget; +}): Promise<{ ok: boolean; applied: boolean; reason?: string }> { + const res = await guardedPaymentStatusUpdate({ + orderId: args.orderId, + paymentProvider: 'monobank', + to: args.to, + source: 'monobank_webhook', + note: `event:${args.eventId}:${args.status}`, + }); + const ok = + res.applied || (res.currentProvider === 'monobank' && res.from === args.to); + + return { + ok, + applied: res.applied, + reason: ok ? undefined : res.reason, + }; +} + +async function persistEventOutcome(args: { + eventId: string; + now: Date; + appliedResult: ApplyResult; + appliedErrorCode?: string; + appliedErrorMessage?: string; + attemptId?: string; + orderId?: string; +}): Promise { + const patch: { + appliedAt: Date; + appliedResult: ApplyResult; + appliedErrorCode?: string; + appliedErrorMessage?: string; + attemptId?: string; + orderId?: string; + } = { + appliedAt: args.now, + appliedResult: args.appliedResult, + }; + + if (args.appliedErrorCode !== undefined) { + patch.appliedErrorCode = args.appliedErrorCode; + } + if (args.appliedErrorMessage !== undefined) { + patch.appliedErrorMessage = args.appliedErrorMessage; + } + if (args.attemptId !== undefined) { + patch.attemptId = args.attemptId; + } + if (args.orderId !== undefined) { + patch.orderId = args.orderId; + } + + await db + .update(monobankEvents) + .set(patch) + .where(eq(monobankEvents.id, args.eventId)); +} + +function buildMergedMetaSql(normalized: NormalizedWebhook) { + const metadataPatch = { + monobank: { + invoiceId: normalized.invoiceId, + status: normalized.status, + amount: normalized.amount ?? null, + ccy: normalized.ccy ?? null, + reference: normalized.reference ?? null, + }, + }; + + return sql`coalesce(${orders.pspMetadata}, '{}'::jsonb) || ${JSON.stringify( + metadataPatch + )}::jsonb`; +} + +async function atomicMarkPaidOrderAndSucceedAttempt(args: { + now: Date; + orderId: string; + attemptId: string; + invoiceId: string; + mergedMetaSql: ReturnType; + nextProviderModifiedAt: Date | null; +}): Promise { + const res = await db.execute(sql` + with updated_order as ( + update orders + set status = 'PAID', + psp_charge_id = ${args.invoiceId}, + psp_metadata = ${args.mergedMetaSql}, + updated_at = ${args.now} + where id = ${args.orderId}::uuid + and payment_provider = 'monobank' + and exists ( + select 1 + from payment_attempts + where id = ${args.attemptId}::uuid + ) + returning id + ), + 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 + ) + select + (select id from updated_order) as order_id, + (select id from updated_attempt) as attempt_id + `); + + const row = readDbRows<{ order_id?: string; attempt_id?: string }>(res)[0]; + return Boolean(row?.order_id && row?.attempt_id); +} + +async function atomicFinalizeOrderAndAttempt(args: { + now: Date; + orderId: string; + attemptId: string; + pspStatusReason: string; + mergedMetaSql: ReturnType; + attemptStatus: 'failed' | 'canceled'; + lastErrorCode: string; + lastErrorMessage: string; + nextProviderModifiedAt: Date | null; +}): Promise { + const res = await db.execute(sql` + with updated_order as ( + update orders + set psp_status_reason = ${args.pspStatusReason}, + psp_metadata = ${args.mergedMetaSql}, + updated_at = ${args.now} + where id = ${args.orderId}::uuid + and payment_provider = 'monobank' + and exists ( + select 1 + from payment_attempts + where id = ${args.attemptId}::uuid + ) + returning id + ), + updated_attempt as ( + update payment_attempts + set status = ${args.attemptStatus}, + finalized_at = ${args.now}, + updated_at = ${args.now}, + last_error_code = ${args.lastErrorCode}, + last_error_message = ${args.lastErrorMessage}, + provider_modified_at = ${args.nextProviderModifiedAt ?? null} + where id = ${args.attemptId}::uuid + and exists (select 1 from updated_order) + returning id + ) + select + (select id from updated_order) as order_id, + (select id from updated_attempt) as attempt_id + `); + + const row = readDbRows<{ order_id?: string; attempt_id?: string }>(res)[0]; + return Boolean(row?.order_id && row?.attempt_id); +} + +async function applyWebhookToMatchedOrderAttemptEvent(args: { + eventId: string; + now: Date; + normalized: NormalizedWebhook; + providerModifiedAt: Date | null; + attemptRow: AttemptRow; + orderRow: OrderRow; +}): Promise { + const { eventId, now, normalized, providerModifiedAt, attemptRow, orderRow } = + args; + + const status = normalized.status; + const attemptProviderModifiedAt = attemptRow.providerModifiedAt + ? new Date(attemptRow.providerModifiedAt) + : null; + const nextProviderModifiedAt = computeNextProviderModifiedAt( + providerModifiedAt, + attemptProviderModifiedAt + ); + const mergedMetaSql = buildMergedMetaSql(normalized); + + if ( + providerModifiedAt && + attemptProviderModifiedAt && + providerModifiedAt <= attemptProviderModifiedAt + ) { + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'OUT_OF_ORDER', + appliedErrorMessage: 'provider_modified_at older than latest', + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const mismatch = amountMismatch({ + payloadAmount: normalized.amount, + payloadCcy: normalized.ccy, + orderCurrency: orderRow.currency, + orderTotal: Number(orderRow.totalAmountMinor ?? 0), + expectedAmount: + attemptRow.expectedAmountMinor != null + ? Number(attemptRow.expectedAmountMinor) + : null, + }); + + if (mismatch.mismatch) { + const appliedResult: ApplyResult = 'applied_with_issue'; + + if (orderRow.paymentStatus !== 'paid') { + await db + .update(paymentAttempts) + .set({ + status: 'failed', + finalizedAt: now, + updatedAt: now, + lastErrorCode: 'AMOUNT_MISMATCH', + lastErrorMessage: mismatch.reason ?? 'Mismatch', + providerModifiedAt: nextProviderModifiedAt ?? null, + }) + .where(eq(paymentAttempts.id, attemptRow.id)); + + const tr = await transitionPaymentStatus({ + orderId: orderRow.id, + status, + eventId, + to: 'needs_review', + }); + + if (tr.ok) { + await db + .update(orders) + .set({ + failureCode: 'MONO_AMOUNT_MISMATCH', + failureMessage: + mismatch.reason ?? 'Webhook amount/currency mismatch.', + updatedAt: now, + }) + .where( + and( + eq(orders.id, orderRow.id), + eq(orders.paymentProvider, 'monobank' as any) + ) + ); + } else { + // transition blocked, appliedResult already 'applied_with_issue' + } + } + + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'AMOUNT_MISMATCH', + appliedErrorMessage: mismatch.reason ?? 'Mismatch', + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if ( + orderRow.paymentStatus === 'paid' && + (status === 'success' || status === 'processing' || status === 'created') + ) { + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if (orderRow.paymentStatus === 'needs_review') { + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if ( + (orderRow.paymentStatus === 'failed' || + orderRow.paymentStatus === 'refunded') && + status === 'success' + ) { + const appliedResult: ApplyResult = 'applied_with_issue'; + + const tr = await transitionPaymentStatus({ + orderId: orderRow.id, + status, + eventId, + to: 'needs_review', + }); + + if (tr.ok) { + await db + .update(orders) + .set({ + failureCode: 'MONO_OUT_OF_ORDER', + failureMessage: `Out-of-order event: ${orderRow.paymentStatus} -> success`, + updatedAt: now, + }) + .where( + and( + eq(orders.id, orderRow.id), + eq(orders.paymentProvider, 'monobank' as any) + ) + ); + } else { + // transition blocked, appliedResult already 'applied_with_issue' + } + + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'OUT_OF_ORDER', + appliedErrorMessage: `Out-of-order: ${orderRow.paymentStatus} -> success`, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if (status === 'success') { + const tr = await transitionPaymentStatus({ + orderId: orderRow.id, + status, + eventId, + to: 'paid', + }); + + if (!tr.ok) { + const appliedResult: ApplyResult = 'applied_with_issue'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'PAYMENT_STATE_BLOCKED', + appliedErrorMessage: `blocked transition to paid (${tr.reason})`, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const ok = await atomicMarkPaidOrderAndSucceedAttempt({ + now, + orderId: orderRow.id, + attemptId: attemptRow.id, + invoiceId: normalized.invoiceId, + mergedMetaSql, + nextProviderModifiedAt: nextProviderModifiedAt ?? null, + }); + + if (!ok) { + logError('monobank_webhook_atomic_update_failed', undefined, { + eventId, + orderId: orderRow.id, + attemptId: attemptRow.id, + status, + }); + + const appliedResult: ApplyResult = 'applied_with_issue'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'DB_WRITE_FAILED', + appliedErrorMessage: + 'atomic update (paid+succeeded) did not update both rows', + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const appliedResult: ApplyResult = 'applied'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if (status === 'processing' || status === 'created') { + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if (status === 'failure' || status === 'expired' || status === 'reversed') { + const isRefunded = status === 'reversed'; + const nextPaymentStatus: PaymentStatusTarget = isRefunded + ? 'refunded' + : 'failed'; + + const tr = await transitionPaymentStatus({ + orderId: orderRow.id, + status, + eventId, + to: nextPaymentStatus, + }); + + if (!tr.ok) { + const appliedResult: ApplyResult = 'applied_with_issue'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'PAYMENT_STATE_BLOCKED', + appliedErrorMessage: `blocked transition to ${nextPaymentStatus} (${tr.reason})`, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const attemptStatus = isRefunded ? 'canceled' : 'failed'; + const lastErrorMessage = `Monobank status: ${status}`; + + const ok = await atomicFinalizeOrderAndAttempt({ + now, + orderId: orderRow.id, + attemptId: attemptRow.id, + pspStatusReason: status, + mergedMetaSql, + attemptStatus, + lastErrorCode: status, + lastErrorMessage, + nextProviderModifiedAt: nextProviderModifiedAt ?? null, + }); + + if (!ok) { + logError('monobank_webhook_atomic_update_failed', undefined, { + eventId, + orderId: orderRow.id, + attemptId: attemptRow.id, + status, + }); + + const appliedResult: ApplyResult = 'applied_with_issue'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'DB_WRITE_FAILED', + appliedErrorMessage: + 'atomic update (finalize) did not update both rows', + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const appliedResult: ApplyResult = 'applied'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + restockReason: isRefunded ? 'refunded' : 'failed', + restockOrderId: orderRow.id, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + logError('MONO_WEBHOOK_UNKNOWN_STATUS', undefined, { + eventId, + status, + invoiceId: normalized.invoiceId, + orderId: orderRow.id, + attemptId: attemptRow.id, + }); + + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'UNKNOWN_STATUS', + appliedErrorMessage: `Unrecognized Monobank status: ${status}`, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); +} + +async function applyWebhookToOrderAttemptEvent(args: { + eventId: string; + normalized: NormalizedWebhook; + providerModifiedAt: Date | null; +}): Promise { + const now = new Date(); + const referenceAttemptId = getReferenceAttemptId(args.normalized.reference); + const attemptRow = await fetchAttemptForWebhook({ + invoiceId: args.normalized.invoiceId, + referenceAttemptId, + }); + + if (!attemptRow) { + const appliedResult: ApplyResult = 'unmatched'; + await persistEventOutcome({ + eventId: args.eventId, + now, + appliedResult, + appliedErrorCode: 'ATTEMPT_NOT_FOUND', + appliedErrorMessage: 'No matching payment attempt', + }); + + return buildApplyOutcome({ appliedResult }); + } + + const orderRow = await fetchOrderForAttempt(attemptRow.orderId); + if (!orderRow) { + const appliedResult: ApplyResult = 'unmatched'; + await persistEventOutcome({ + eventId: args.eventId, + now, + appliedResult, + appliedErrorCode: 'ORDER_NOT_FOUND', + appliedErrorMessage: 'Order not found for attempt', + attemptId: attemptRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + }); + } + + return applyWebhookToMatchedOrderAttemptEvent({ + eventId: args.eventId, + now, + normalized: args.normalized, + providerModifiedAt: args.providerModifiedAt, + attemptRow, + orderRow, + }); +} + +export async function applyMonoWebhookEvent(args: { + rawBody: string; + requestId: string; + mode: WebhookMode; + rawSha256: string; + parsedPayload?: Record; + eventKey?: string; +}): Promise<{ + deduped: boolean; + appliedResult: ApplyResult; + eventId: string | null; + invoiceId: string; +}> { + const parsed = args.parsedPayload + ? normalizeWebhookPayload(args.parsedPayload) + : parseWebhookPayload(args.rawBody); + + if ( + typeof args.rawSha256 !== 'string' || + !/^[0-9a-f]{64}$/i.test(args.rawSha256) + ) { + throw new InvalidPayloadError('Missing or invalid rawSha256', { + code: 'INVALID_PAYLOAD', + }); + } + + const rawSha256 = args.rawSha256; + const eventKey = args.eventKey ?? buildEventKey(rawSha256); + + const { eventId, deduped } = await insertEvent({ + eventKey, + rawSha256, + rawPayload: parsed.raw, + normalizedPayload: parsed.normalized, + providerModifiedAt: parsed.providerModifiedAt, + }); + + if (!eventId) { + logInfo('monobank_webhook_deduped', { + requestId: args.requestId, + invoiceId: parsed.normalized.invoiceId, + status: parsed.normalized.status, + }); + return { + deduped: true, + appliedResult: 'deduped', + eventId: null, + invoiceId: parsed.normalized.invoiceId, + }; + } + + if (args.mode === 'store' || args.mode === 'drop') { + const now = new Date(); + const appliedResult: ApplyResult = + args.mode === 'drop' ? 'dropped' : 'stored'; + + await db + .update(monobankEvents) + .set({ + appliedAt: now, + appliedResult, + }) + .where(eq(monobankEvents.id, eventId)); + + return { + deduped, + appliedResult, + eventId, + invoiceId: parsed.normalized.invoiceId, + }; + } + + const claimed = await claimMonobankEvent({ + eventId, + instanceId: INSTANCE_ID, + ttlMs: CLAIM_TTL_MS, + }); + + if (!claimed) { + return { + deduped, + appliedResult: deduped ? 'deduped' : 'applied_noop', + eventId, + invoiceId: parsed.normalized.invoiceId, + }; + } + + const outcome = await applyWebhookToOrderAttemptEvent({ + eventId, + normalized: parsed.normalized, + providerModifiedAt: parsed.providerModifiedAt, + }); + + const { appliedResult, restockOrderId, restockReason } = outcome; + + if (restockReason && restockOrderId) { + try { + await restockOrder(restockOrderId, { + reason: restockReason, + workerId: 'monobank_webhook', + }); + } catch (error) { + logError('monobank_webhook_restock_failed', error, { + requestId: args.requestId, + invoiceId: parsed.normalized.invoiceId, + }); + const now = new Date(); + const issueMsg = toIssueMessage(error); + + await db + .update(monobankEvents) + .set({ + appliedResult: 'applied_with_issue', + appliedErrorCode: + sql`coalesce(${monobankEvents.appliedErrorCode}, 'RESTOCK_FAILED')` as any, + appliedErrorMessage: + sql`coalesce(${monobankEvents.appliedErrorMessage}, ${issueMsg})` as any, + }) + .where(eq(monobankEvents.id, eventId)); + + if (outcome.attemptId) { + await db + .update(paymentAttempts) + .set({ + lastErrorCode: + sql`coalesce(${paymentAttempts.lastErrorCode}, 'RESTOCK_FAILED')` as any, + lastErrorMessage: + sql`coalesce(${paymentAttempts.lastErrorMessage}, ${issueMsg})` as any, + updatedAt: now, + }) + .where(eq(paymentAttempts.id, outcome.attemptId)); + } + } + } + + return { + deduped, + appliedResult, + eventId, + invoiceId: parsed.normalized.invoiceId, + }; +} + +export async function handleMonobankWebhook(args: { + rawBodyBytes: Uint8Array; + parsedPayload: Record; + eventKey: string; + requestId: string; + mode: WebhookMode; +}) { + const rawBodyBuffer = Buffer.isBuffer(args.rawBodyBytes) + ? args.rawBodyBytes + : Buffer.from(args.rawBodyBytes); + const rawBody = rawBodyBuffer.toString('utf8'); + const rawSha256 = crypto + .createHash('sha256') + .update(rawBodyBuffer) + .digest('hex'); + + return applyMonoWebhookEvent({ + rawBody, + parsedPayload: args.parsedPayload, + eventKey: args.eventKey, + rawSha256, + requestId: args.requestId, + mode: args.mode, + }); +} diff --git a/frontend/lib/services/orders/monobank.ts b/frontend/lib/services/orders/monobank.ts new file mode 100644 index 00000000..3dc68d76 --- /dev/null +++ b/frontend/lib/services/orders/monobank.ts @@ -0,0 +1,755 @@ +import 'server-only'; + +import { and, eq, inArray, sql } from 'drizzle-orm'; + +import { db } from '@/db'; +import { orderItems, orders, paymentAttempts } from '@/db/schema'; +import { logError, logWarn } from '@/lib/logging'; +import { + cancelMonobankInvoice, + createMonobankInvoice, + MONO_CURRENCY, + type MonobankInvoiceCreateArgs, +} from '@/lib/psp/monobank'; +import { + buildMonoMerchantPaymInfoFromSnapshot, + MonobankMerchantPaymInfoError, +} from '@/lib/psp/monobank/merchant-paym-info'; +import { + InvalidPayloadError, + OrderNotFoundError, + OrderStateInvalidError, + PspInvoicePersistError, + PspUnavailableError, +} from '@/lib/services/errors'; +import { restockOrder } from '@/lib/services/orders/restock'; +import { toAbsoluteUrl } from '@/lib/shop/url'; + +import { buildMonobankAttemptIdempotencyKey } from './attempt-idempotency'; + +type PaymentAttemptRow = typeof paymentAttempts.$inferSelect; + +const DEFAULT_MAX_ATTEMPTS = 2; +const CREATING_STALE_MS = 2 * 60 * 1000; + +function readPageUrlFromMetadata(attempt: PaymentAttemptRow): string | null { + const meta = attempt.metadata as Record | null; + const raw = meta?.pageUrl; + if (typeof raw === 'string' && raw.trim().length > 0) return raw.trim(); + return null; +} + +async function getActiveAttempt( + orderId: string +): Promise { + const rows = await db + .select() + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.orderId, orderId), + eq(paymentAttempts.provider, 'monobank'), + inArray(paymentAttempts.status, ['creating', 'active']) + ) + ) + .limit(1); + + return rows[0] ?? null; +} + +async function getMaxAttemptNumber(orderId: string): Promise { + const rows = await db + .select({ + max: sql`coalesce(max(${paymentAttempts.attemptNumber}), 0)`, + }) + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.orderId, orderId), + eq(paymentAttempts.provider, 'monobank') + ) + ); + + return rows[0]?.max ?? 0; +} + +async function createCreatingAttempt(args: { + orderId: string; + expectedAmountMinor: number; + maxAttempts: number; +}): Promise { + const next = (await getMaxAttemptNumber(args.orderId)) + 1; + if (next > args.maxAttempts) { + throw new InvalidPayloadError('Payment attempts exhausted.', { + code: 'PAYMENT_ATTEMPTS_EXHAUSTED', + }); + } + + const idempotencyKey = buildMonobankAttemptIdempotencyKey(args.orderId, next); + const inserted = await db + .insert(paymentAttempts) + .values({ + orderId: args.orderId, + provider: 'monobank', + status: 'creating', + attemptNumber: next, + idempotencyKey, + currency: MONO_CURRENCY, + expectedAmountMinor: args.expectedAmountMinor, + metadata: {}, + }) + .returning(); + + const row = inserted[0]; + if (!row) throw new Error('Failed to insert payment_attempts row'); + return row; +} + +function isUniqueViolation(error: unknown): boolean { + return ( + !!error && + typeof error === 'object' && + (error as { code?: unknown }).code === '23505' + ); +} + +async function readMonobankInvoiceParams(orderId: string): Promise<{ + amountMinor: number; + currency: string; + items: Array<{ + productId: string; + title: string | null; + quantity: number; + unitPriceMinor: number; + lineTotalMinor: number; + }>; +}> { + const [existing] = await db + .select() + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + if (!existing) throw new OrderNotFoundError('Order not found'); + + if (existing.paymentProvider !== 'monobank') { + throw new InvalidPayloadError('Order is not a Monobank payment.'); + } + + const allowed = ['pending', 'requires_payment']; + if (!allowed.includes(existing.paymentStatus)) { + throw new OrderStateInvalidError( + 'Order is not payable; Monobank invoice creation is not allowed in the current state.', + { + orderId, + field: 'paymentStatus', + rawValue: existing.paymentStatus, + details: { allowed, paymentProvider: existing.paymentProvider }, + } + ); + } + + if (existing.currency !== MONO_CURRENCY) { + throw new OrderStateInvalidError('Order currency is not UAH.', { + orderId, + field: 'currency', + rawValue: existing.currency, + }); + } + + const amountMinor = existing.totalAmountMinor; + if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) { + throw new OrderStateInvalidError( + 'Invalid order total for Monobank invoice creation.', + { orderId, field: 'totalAmountMinor', rawValue: amountMinor } + ); + } + + const items = await db + .select({ + productId: orderItems.productId, + productTitle: orderItems.productTitle, + productSlug: orderItems.productSlug, + productSku: orderItems.productSku, + quantity: orderItems.quantity, + unitPriceMinor: orderItems.unitPriceMinor, + lineTotalMinor: orderItems.lineTotalMinor, + }) + .from(orderItems) + .where(eq(orderItems.orderId, orderId)); + + return { + amountMinor, + currency: existing.currency, + items: items.map(item => ({ + productId: item.productId, + title: item.productTitle ?? item.productSlug ?? item.productSku ?? null, + quantity: item.quantity, + unitPriceMinor: item.unitPriceMinor, + lineTotalMinor: item.lineTotalMinor, + })), + }; +} + +async function markAttemptFailed(args: { + attemptId: string; + errorCode: string; + errorMessage: string; + meta?: Record; +}) { + const now = new Date(); + const metaPatch = args.meta ?? {}; + await db + .update(paymentAttempts) + .set({ + status: 'failed', + finalizedAt: now, + updatedAt: now, + lastErrorCode: args.errorCode, + lastErrorMessage: args.errorMessage, + metadata: + sql`coalesce(${paymentAttempts.metadata}, '{}'::jsonb) || ${JSON.stringify( + metaPatch + )}::jsonb` as any, + }) + .where(eq(paymentAttempts.id, args.attemptId)); +} + +async function cancelOrderAndRelease(orderId: string, reason: string) { + const now = new Date(); + + const updated = await db + .update(orders) + .set({ + status: 'CANCELED', + failureCode: 'PSP_UNAVAILABLE', + failureMessage: reason, + updatedAt: now, + }) + .where( + and( + eq(orders.id, orderId), + eq(orders.paymentProvider, 'monobank'), + inArray(orders.paymentStatus, ['pending', 'requires_payment']) + ) + ) + .returning({ id: orders.id }); + + if (updated[0]?.id) { + await restockOrder(orderId, { reason: 'canceled', workerId: 'monobank' }); + return; + } + + logWarn('monobank_cancel_order_skipped', { + orderId, + reason, + }); +} + +async function finalizeAttemptWithInvoice(args: { + attemptId: string; + orderId: string; + invoiceId: string; + pageUrl: string; + requestId: string; +}) { + const maxRetries = 2; + let lastError: unknown = null; + let fallbackError: unknown = null; + + const now = new Date(); + + const asObj = (v: unknown): Record => { + if (!v || typeof v !== 'object' || Array.isArray(v)) return {}; + return v as Record; + }; + + const mergeMonobankMeta = ( + base: Record + ): Record => { + const mono = asObj(base.monobank); + return { + ...base, + monobank: { + ...mono, + invoiceId: args.invoiceId, + pageUrl: args.pageUrl, + }, + }; + }; + + for (let attempt = 0; attempt < maxRetries; attempt += 1) { + try { + const [attemptRow] = await db + .select({ metadata: paymentAttempts.metadata }) + .from(paymentAttempts) + .where(eq(paymentAttempts.id, args.attemptId)) + .limit(1); + + if (!attemptRow) { + throw new Error('Payment attempt not found during invoice finalize'); + } + + const nextAttemptMeta = { + ...asObj(attemptRow.metadata), + pageUrl: args.pageUrl, + invoiceId: args.invoiceId, + }; + + const updatedAttempt = await db + .update(paymentAttempts) + .set({ + providerPaymentIntentId: args.invoiceId, + metadata: nextAttemptMeta, + updatedAt: now, + }) + .where(eq(paymentAttempts.id, args.attemptId)) + .returning({ id: paymentAttempts.id }); + + if (!updatedAttempt[0]?.id) { + throw new Error('Payment attempt not found during invoice finalize'); + } + + const [orderRow] = await db + .select({ pspMetadata: orders.pspMetadata }) + .from(orders) + .where(eq(orders.id, args.orderId)) + .limit(1); + + if (!orderRow) { + throw new Error('Order not found during invoice finalize'); + } + + const nextOrderMeta = mergeMonobankMeta(asObj(orderRow.pspMetadata)); + + const updatedOrder = await db + .update(orders) + .set({ + pspChargeId: args.invoiceId, + pspMetadata: nextOrderMeta, + updatedAt: now, + }) + .where(eq(orders.id, args.orderId)) + .returning({ id: orders.id }); + + if (!updatedOrder[0]?.id) { + throw new Error('Order not found during invoice finalize'); + } + + return; + } catch (error) { + lastError = error; + } + } + + const lastMsg = + lastError instanceof Error && lastError.message + ? lastError.message + : 'Invoice persistence failed.'; + try { + const patch = { + pageUrl: args.pageUrl, + invoiceId: args.invoiceId, + }; + + const updatedAttempt = await db + .update(paymentAttempts) + .set({ + providerPaymentIntentId: args.invoiceId, + metadata: + sql`coalesce(${paymentAttempts.metadata}, '{}'::jsonb) || ${JSON.stringify( + patch + )}::jsonb` as any, + updatedAt: now, + }) + .where(eq(paymentAttempts.id, args.attemptId)) + .returning({ id: paymentAttempts.id }); + + if (updatedAttempt[0]?.id) { + logWarn('monobank_invoice_persist_partial_attempt_only', { + orderId: args.orderId, + attemptId: args.attemptId, + invoiceId: args.invoiceId, + requestId: args.requestId, + issue: lastMsg, + }); + } + } catch (err) { + fallbackError = err; + } + + logError('monobank_invoice_persist_retry_exhausted', lastError, { + orderId: args.orderId, + attemptId: args.attemptId, + invoiceId: args.invoiceId, + requestId: args.requestId, + fallbackError: + fallbackError instanceof Error + ? `${fallbackError.name}: ${fallbackError.message}` + : fallbackError, + }); + + try { + await markAttemptFailed({ + attemptId: args.attemptId, + errorCode: 'PSP_INVOICE_PERSIST_FAILED', + errorMessage: lastMsg, + meta: { + requestId: args.requestId, + invoiceId: args.invoiceId, + pageUrl: args.pageUrl, + reason: 'persist_retry_exhausted', + }, + }); + } catch (error) { + logError('monobank_attempt_mark_failed', error, { + orderId: args.orderId, + attemptId: args.attemptId, + invoiceId: args.invoiceId, + requestId: args.requestId, + }); + } + + try { + await cancelMonobankInvoice(args.invoiceId); + } catch (error) { + logError('monobank_invoice_cancel_failed', error, { + orderId: args.orderId, + attemptId: args.attemptId, + invoiceId: args.invoiceId, + requestId: args.requestId, + }); + } + + try { + await cancelOrderAndRelease(args.orderId, 'Invoice persistence failed.'); + } catch (error) { + logError('monobank_cancel_order_failed', error, { + orderId: args.orderId, + attemptId: args.attemptId, + invoiceId: args.invoiceId, + requestId: args.requestId, + }); + } + + throw new PspInvoicePersistError('Invoice persistence failed.', { + orderId: args.orderId, + }); +} + +type CreateMonoAttemptAndInvoiceDeps = { + readMonobankInvoiceParams: typeof readMonobankInvoiceParams; + getActiveAttempt: typeof getActiveAttempt; + createCreatingAttempt: typeof createCreatingAttempt; + markAttemptFailed: typeof markAttemptFailed; + cancelOrderAndRelease: typeof cancelOrderAndRelease; + createMonobankInvoice: typeof createMonobankInvoice; + finalizeAttemptWithInvoice: typeof finalizeAttemptWithInvoice; +}; + +async function createMonoAttemptAndInvoiceImpl( + deps: CreateMonoAttemptAndInvoiceDeps, + args: { + orderId: string; + requestId: string; + redirectUrl: string; + webhookUrl: string; + maxAttempts?: number; + } +): Promise<{ + attemptId: string; + attemptNumber: number; + invoiceId: string; + pageUrl: string; + currency: 'UAH'; + totalAmountMinor: number; +}> { + const snapshot = await deps.readMonobankInvoiceParams(args.orderId); + + let existing = await deps.getActiveAttempt(args.orderId); + if (existing) { + const pageUrl = readPageUrlFromMetadata(existing); + if (existing.providerPaymentIntentId && pageUrl) { + return { + invoiceId: existing.providerPaymentIntentId, + pageUrl, + attemptId: existing.id, + attemptNumber: existing.attemptNumber, + currency: MONO_CURRENCY, + totalAmountMinor: snapshot.amountMinor, + }; + } + + const ageMs = + Date.now() - new Date(existing.updatedAt ?? existing.createdAt).getTime(); + + if (existing.status === 'creating' && ageMs < CREATING_STALE_MS) { + throw new InvalidPayloadError( + 'Payment initialization already in progress. Retry shortly.', + { code: 'CHECKOUT_CONFLICT' } + ); + } + + logWarn('monobank_attempt_stale_missing_invoice', { + orderId: args.orderId, + attemptId: existing.id, + status: existing.status, + ageMs, + requestId: args.requestId, + }); + + try { + await deps.markAttemptFailed({ + attemptId: existing.id, + errorCode: 'invoice_missing', + errorMessage: 'Active attempt missing invoice details (stale).', + meta: { requestId: args.requestId, ageMs, status: existing.status }, + }); + } catch (markError) { + logError('monobank_attempt_mark_failed', markError, { + orderId: args.orderId, + attemptId: existing.id, + requestId: args.requestId, + }); + throw new PspUnavailableError('Attempt cleanup failed.', { + orderId: args.orderId, + requestId: args.requestId, + }); + } + + existing = null; + } + + let attempt: PaymentAttemptRow; + try { + attempt = await deps.createCreatingAttempt({ + orderId: args.orderId, + expectedAmountMinor: snapshot.amountMinor, + maxAttempts: args.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + }); + } catch (error) { + if (isUniqueViolation(error)) { + const reused = await deps.getActiveAttempt(args.orderId); + if (reused) { + const pageUrl = readPageUrlFromMetadata(reused); + if (reused.providerPaymentIntentId && pageUrl) { + return { + invoiceId: reused.providerPaymentIntentId, + pageUrl, + attemptId: reused.id, + attemptNumber: reused.attemptNumber, + currency: MONO_CURRENCY, + totalAmountMinor: snapshot.amountMinor, + }; + } + } + } + throw error; + } + + let merchantPaymInfo: MonobankInvoiceCreateArgs['merchantPaymInfo']; + try { + merchantPaymInfo = buildMonoMerchantPaymInfoFromSnapshot({ + reference: attempt.id, + order: { + id: args.orderId, + currency: snapshot.currency, + totalAmountMinor: snapshot.amountMinor, + }, + items: snapshot.items, + expectedAmountMinor: snapshot.amountMinor, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Invalid order snapshot'; + const errorCode = + error instanceof MonobankMerchantPaymInfoError && error.code + ? error.code + : 'MONO_INVALID_SNAPSHOT'; + + try { + await deps.markAttemptFailed({ + attemptId: attempt.id, + errorCode, + errorMessage, + meta: { requestId: args.requestId }, + }); + } catch (markError) { + logError('monobank_attempt_mark_failed', markError, { + orderId: args.orderId, + attemptId: attempt.id, + requestId: args.requestId, + }); + } + + try { + await deps.cancelOrderAndRelease( + args.orderId, + 'Monobank snapshot validation failed.' + ); + } catch (cancelErr) { + logError('monobank_cancel_order_failed', cancelErr, { + orderId: args.orderId, + attemptId: attempt.id, + requestId: args.requestId, + }); + } + + throw error; + } + + let invoice: { invoiceId: string; pageUrl: string }; + try { + const created = await deps.createMonobankInvoice({ + amountMinor: snapshot.amountMinor, + orderId: args.orderId, + redirectUrl: args.redirectUrl, + webhookUrl: args.webhookUrl, + paymentType: 'debit', + merchantPaymInfo, + }); + invoice = { invoiceId: created.invoiceId, pageUrl: created.pageUrl }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Invoice create failed'; + const errorCode = + typeof (error as { code?: unknown }).code === 'string' + ? String((error as { code?: unknown }).code) + : 'PSP_UNAVAILABLE'; + + logWarn('monobank_invoice_create_failed', { + orderId: args.orderId, + attemptId: attempt.id, + code: errorCode, + requestId: args.requestId, + message: errorMessage, + }); + + try { + await deps.markAttemptFailed({ + attemptId: attempt.id, + errorCode, + errorMessage, + meta: { requestId: args.requestId }, + }); + } catch (markError) { + logError('monobank_attempt_mark_failed', markError, { + orderId: args.orderId, + attemptId: attempt.id, + requestId: args.requestId, + }); + } + + let cancelError: unknown = null; + + try { + await deps.cancelOrderAndRelease( + args.orderId, + 'Monobank invoice create failed.' + ); + } catch (err) { + cancelError = err; + + logError('monobank_cancel_order_failed', err, { + orderId: args.orderId, + attemptId: attempt.id, + requestId: args.requestId, + }); + } + + throw new PspUnavailableError('Monobank invoice unavailable.', { + orderId: args.orderId, + requestId: args.requestId, + + attemptId: attempt.id, + + cause: + cancelError instanceof Error + ? `${cancelError.name}: ${cancelError.message}` + : cancelError + ? String(cancelError) + : undefined, + } as any); + } + + await deps.finalizeAttemptWithInvoice({ + attemptId: attempt.id, + orderId: args.orderId, + invoiceId: invoice.invoiceId, + pageUrl: invoice.pageUrl, + requestId: args.requestId, + }); + + return { + invoiceId: invoice.invoiceId, + pageUrl: invoice.pageUrl, + attemptId: attempt.id, + attemptNumber: attempt.attemptNumber, + currency: MONO_CURRENCY, + totalAmountMinor: snapshot.amountMinor, + }; +} + +export async function createMonoAttemptAndInvoice(args: { + orderId: string; + requestId: string; + redirectUrl: string; + webhookUrl: string; + maxAttempts?: number; +}): Promise<{ + attemptId: string; + attemptNumber: number; + invoiceId: string; + pageUrl: string; + currency: 'UAH'; + totalAmountMinor: number; +}> { + return createMonoAttemptAndInvoiceImpl( + { + readMonobankInvoiceParams, + getActiveAttempt, + createCreatingAttempt, + markAttemptFailed, + cancelOrderAndRelease, + createMonobankInvoice, + finalizeAttemptWithInvoice, + }, + args + ); +} + +export const __test__ = { + createMonoAttemptAndInvoiceImpl, + finalizeAttemptWithInvoice, +}; + +export async function createMonobankAttemptAndInvoice(args: { + orderId: string; + statusToken: string; + requestId: string; + maxAttempts?: number; +}): Promise<{ + invoiceId: string; + pageUrl: string; + attemptId: string; + attemptNumber: number; + currency: 'UAH'; + totalAmountMinor: number; +}> { + const redirectUrl = toAbsoluteUrl( + `/shop/checkout/success?orderId=${encodeURIComponent( + args.orderId + )}&statusToken=${encodeURIComponent(args.statusToken)}` + ); + const webhookUrl = toAbsoluteUrl('/api/shop/webhooks/monobank'); + + const result = await createMonoAttemptAndInvoice({ + orderId: args.orderId, + requestId: args.requestId, + redirectUrl, + webhookUrl, + maxAttempts: args.maxAttempts, + }); + + return result; +} diff --git a/frontend/lib/services/orders/monobank/merchant-paym-info.ts b/frontend/lib/services/orders/monobank/merchant-paym-info.ts new file mode 100644 index 00000000..5d135f7c --- /dev/null +++ b/frontend/lib/services/orders/monobank/merchant-paym-info.ts @@ -0,0 +1,70 @@ +import { + buildMonoMerchantPaymInfoFromSnapshot, + MonobankMerchantPaymInfoError, + type MonoBasketOrderItem, + type MonoMerchantPaymInfo, +} from '@/lib/psp/monobank/merchant-paym-info'; +import type { CurrencyCode } from '@/lib/shop/currency'; + +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); + +function safeLineTotalMinor(qty: unknown, unit: unknown): number { + if ( + typeof qty !== 'number' || + typeof unit !== 'number' || + !Number.isFinite(qty) || + !Number.isFinite(unit) || + !Number.isInteger(qty) || + !Number.isInteger(unit) + ) { + return 0; + } + if (qty <= 0 || unit < 0) return 0; + + const total = BigInt(qty) * BigInt(unit); + if (total > MAX_SAFE) return 0; + + return Number(total); +} + +export type MonoMerchantPaymInfoInput = { + reference: string; + destination: string; + currency: CurrencyCode; + expectedAmountMinor: number; + items: Array<{ + name: string; + quantity: number; + unitPriceMinor: number; + }>; +}; + +export type { MonoBasketOrderItem,MonoMerchantPaymInfo }; +export { MonobankMerchantPaymInfoError as MonoMerchantPaymInfoError }; + +export function buildMonoMerchantPaymInfo( + input: MonoMerchantPaymInfoInput +): MonoMerchantPaymInfo { + const mapped = buildMonoMerchantPaymInfoFromSnapshot({ + reference: input.reference, + order: { + id: input.reference, + currency: input.currency, + totalAmountMinor: input.expectedAmountMinor, + displayLabel: input.destination, + }, + items: input.items.map(item => ({ + title: item.name, + quantity: item.quantity, + unitPriceMinor: item.unitPriceMinor, + lineTotalMinor: safeLineTotalMinor(item.quantity, item.unitPriceMinor), + })), + expectedAmountMinor: input.expectedAmountMinor, + }); + + const destination = input.destination.trim(); + return { + ...mapped, + destination: destination || mapped.destination, + }; +} diff --git a/frontend/lib/services/orders/payment-attempts.ts b/frontend/lib/services/orders/payment-attempts.ts index a6ab6f69..1808125c 100644 --- a/frontend/lib/services/orders/payment-attempts.ts +++ b/frontend/lib/services/orders/payment-attempts.ts @@ -10,6 +10,8 @@ import { OrderStateInvalidError } from '@/lib/services/errors'; import { setOrderPaymentIntent } from '@/lib/services/orders'; import { readStripePaymentIntentParams } from '@/lib/services/orders/payment-intent'; +import { buildStripeAttemptIdempotencyKey } from './attempt-idempotency'; + export type PaymentProvider = 'stripe'; export type PaymentAttemptStatus = | 'active' @@ -80,7 +82,11 @@ async function createActiveAttempt( throw new PaymentAttemptsExhaustedError(orderId, provider); } - const idempotencyKey = `pi:${provider}:${orderId}:${next}`; + const idempotencyKey = buildStripeAttemptIdempotencyKey( + provider, + orderId, + next + ); try { const inserted = await db @@ -150,7 +156,11 @@ async function upsertBackfillAttemptForExistingPI(args: { throw new PaymentAttemptsExhaustedError(orderId, provider); } - const idempotencyKey = `pi:${provider}:${orderId}:${next}`; + const idempotencyKey = buildStripeAttemptIdempotencyKey( + provider, + orderId, + next + ); try { const inserted = await db diff --git a/frontend/lib/services/orders/payment-state.ts b/frontend/lib/services/orders/payment-state.ts index 4f6edcca..28a4c4da 100644 --- a/frontend/lib/services/orders/payment-state.ts +++ b/frontend/lib/services/orders/payment-state.ts @@ -9,6 +9,7 @@ export type PaymentTransitionSource = | 'checkout' | 'payment_intent' | 'stripe_webhook' + | 'monobank_webhook' | 'admin' | 'janitor' | 'system'; @@ -19,6 +20,14 @@ const ALLOWED_FROM_STRIPE: Record = { paid: ['pending', 'requires_payment'], failed: ['pending', 'requires_payment'], refunded: ['paid', 'pending', 'requires_payment'], + needs_review: [ + 'pending', + 'requires_payment', + 'paid', + 'failed', + 'refunded', + 'needs_review', + ], }; const ALLOWED_FROM_NONE: Record = { @@ -27,6 +36,7 @@ const ALLOWED_FROM_NONE: Record = { paid: ['paid'], failed: ['paid', 'failed'], refunded: [], + needs_review: [], }; function allowedFrom( @@ -82,10 +92,14 @@ export type GuardedPaymentUpdateResult = currentProvider?: PaymentProvider; }; -async function getCurrentState(orderId: string): Promise<{ +type CurrentPaymentState = { paymentStatus: PaymentStatus; paymentProvider: PaymentProvider; -} | null> { +}; + +async function getCurrentState( + orderId: string +): Promise { const row = await db .select({ paymentStatus: orders.paymentStatus, @@ -105,7 +119,10 @@ export async function guardedPaymentStatusUpdate( if ( paymentProvider === 'none' && - (to === 'pending' || to === 'requires_payment' || to === 'refunded') + (to === 'pending' || + to === 'requires_payment' || + to === 'refunded' || + to === 'needs_review') ) { const current = await getCurrentState(orderId); if (!current) return { applied: false, reason: 'NOT_FOUND' }; diff --git a/frontend/lib/services/orders/summary.ts b/frontend/lib/services/orders/summary.ts index 246115e4..b9a34ffe 100644 --- a/frontend/lib/services/orders/summary.ts +++ b/frontend/lib/services/orders/summary.ts @@ -1,7 +1,7 @@ import { eq, sql } from 'drizzle-orm'; import { db } from '@/db'; -import { orderItems, orders, products } from '@/db/schema/shop'; +import { orderItems, orders, paymentAttempts, products } from '@/db/schema/shop'; import { fromCents, fromDbMoney } from '@/lib/shop/money'; import { type OrderDetail, type OrderSummaryWithMinor } from '@/lib/types/shop'; @@ -159,6 +159,62 @@ export async function getOrderSummary( return getOrderById(id); } +type OrderAttemptSummary = { + status: string; + providerRef: string | null; + checkoutUrl: string | null; +}; + +function readAttemptCheckoutUrl(row: { + checkoutUrl: string | null; + metadata: unknown; +}): string | null { + if (row.checkoutUrl && row.checkoutUrl.trim()) return row.checkoutUrl.trim(); + + const meta = + row.metadata && typeof row.metadata === 'object' && !Array.isArray(row.metadata) + ? (row.metadata as Record) + : null; + const fromMeta = meta?.pageUrl; + if (typeof fromMeta === 'string' && fromMeta.trim()) return fromMeta.trim(); + + return null; +} + +export async function getOrderAttemptSummary( + orderId: string +): Promise { + const rows = await db + .select({ + status: paymentAttempts.status, + providerRef: paymentAttempts.providerPaymentIntentId, + checkoutUrl: paymentAttempts.checkoutUrl, + metadata: paymentAttempts.metadata, + }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .orderBy( + sql`case when ${paymentAttempts.status} in ('creating','active') then 0 else 1 end`, + sql`${paymentAttempts.updatedAt} desc`, + sql`${paymentAttempts.createdAt} desc`, + sql`${paymentAttempts.attemptNumber} desc`, + sql`${paymentAttempts.id} desc` + ) + .limit(1); + + const row = rows[0]; + if (!row) return null; + + return { + status: row.status, + providerRef: row.providerRef ?? null, + checkoutUrl: readAttemptCheckoutUrl({ + checkoutUrl: row.checkoutUrl ?? null, + metadata: row.metadata, + }), + }; +} + export async function getOrderByIdempotencyKey( dbClient: DbClient, key: string diff --git a/frontend/lib/services/products/admin/queries.ts b/frontend/lib/services/products/admin/queries.ts index eae146bd..d2df6453 100644 --- a/frontend/lib/services/products/admin/queries.ts +++ b/frontend/lib/services/products/admin/queries.ts @@ -42,7 +42,6 @@ export async function getAdminProductPrices( return rows.map(r => ({ currency: r.currency as CurrencyCode, - // Defensive: some DB drivers return NUMERIC/DECIMAL as string/unknown at runtime; enforce safe integer minor-units here. priceMinor: assertMoneyMinorInt( r.priceMinor, `${String(r.currency)} priceMinor` diff --git a/frontend/lib/shop/payments.ts b/frontend/lib/shop/payments.ts index 1f8fbe71..7c1a3c7a 100644 --- a/frontend/lib/shop/payments.ts +++ b/frontend/lib/shop/payments.ts @@ -4,10 +4,11 @@ export const paymentStatusValues = [ 'paid', 'failed', 'refunded', + 'needs_review', ] as const; export type PaymentStatus = (typeof paymentStatusValues)[number]; -export const paymentProviderValues = ['stripe', 'none'] as const; +export const paymentProviderValues = ['stripe', 'monobank', 'none'] as const; export type PaymentProvider = (typeof paymentProviderValues)[number]; diff --git a/frontend/lib/shop/status-token.ts b/frontend/lib/shop/status-token.ts new file mode 100644 index 00000000..706a3f15 --- /dev/null +++ b/frontend/lib/shop/status-token.ts @@ -0,0 +1,118 @@ +import crypto from 'node:crypto'; + +type TokenPayload = { + v: 1; + orderId: string; + iat: number; + exp: number; + nonce: string; +}; + +const DEFAULT_TTL_SECONDS = 45 * 60; + +function getSecret(): string { + const raw = process.env.SHOP_STATUS_TOKEN_SECRET ?? ''; + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error('SHOP_STATUS_TOKEN_SECRET is not configured'); + } + return trimmed; +} + +function base64UrlEncode(buf: Buffer): string { + return buf + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +function base64UrlDecode(input: string): Buffer { + const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); + const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)); + return Buffer.from(normalized + pad, 'base64'); +} + +function signPayload(payload: TokenPayload, secret: string): string { + const body = base64UrlEncode(Buffer.from(JSON.stringify(payload))); + const sig = crypto.createHmac('sha256', secret).update(body).digest(); + return `${body}.${base64UrlEncode(sig)}`; +} + +function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +export function createStatusToken(args: { + orderId: string; + ttlSeconds?: number; + nowMs?: number; +}): 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 payload: TokenPayload = { + v: 1, + orderId: args.orderId, + iat, + exp, + nonce: crypto.randomUUID(), + }; + + return signPayload(payload, secret); +} + +export function verifyStatusToken(args: { + token: string; + orderId: string; + nowMs?: number; +}): { ok: true; payload: TokenPayload } | { ok: false; reason: string } { + const secret = (() => { + try { + return getSecret(); + } catch { + return null; + } + })(); + if (!secret) return { ok: false, reason: 'missing_secret' }; + + const parts = args.token.split('.'); + if (parts.length !== 2) return { ok: false, reason: 'invalid_format' }; + + const [body, sig] = parts; + if (!body || !sig) return { ok: false, reason: 'invalid_format' }; + + const expectedSig = base64UrlEncode( + crypto.createHmac('sha256', secret).update(body).digest() + ); + if (!safeEqual(sig, expectedSig)) { + return { ok: false, reason: 'invalid_signature' }; + } + + let payload: TokenPayload; + try { + payload = JSON.parse(base64UrlDecode(body).toString('utf-8')) as TokenPayload; + } catch { + return { ok: false, reason: 'invalid_payload' }; + } + + if (!payload || payload.v !== 1) return { ok: false, reason: 'invalid_payload' }; + if (payload.orderId !== args.orderId) { + return { ok: false, reason: 'order_mismatch' }; + } + + const now = Math.floor((args.nowMs ?? Date.now()) / 1000); + if (!Number.isFinite(payload.exp) || now > payload.exp) { + return { ok: false, reason: 'expired' }; + } + + if (!Number.isFinite(payload.iat) || payload.iat > now + 60) { + return { ok: false, reason: 'invalid_iat' }; + } + + return { ok: true, payload }; +} diff --git a/frontend/lib/shop/url.ts b/frontend/lib/shop/url.ts new file mode 100644 index 00000000..43a1ef67 --- /dev/null +++ b/frontend/lib/shop/url.ts @@ -0,0 +1,52 @@ +import 'server-only'; + +import { getRuntimeEnv, getServerEnv } from '@/lib/env'; + +function toUrl(value: string, label: string): URL { + try { + return new URL(value); + } catch { + throw new Error(`Invalid ${label} value.`); + } +} + +export function resolveShopBaseUrl(): URL { + const env = getServerEnv(); + const raw = + env.SHOP_BASE_URL ?? env.APP_ORIGIN ?? env.NEXT_PUBLIC_SITE_URL ?? ''; + + if (!raw) { + throw new Error( + 'SHOP_BASE_URL, APP_ORIGIN, or NEXT_PUBLIC_SITE_URL must be set.' + ); + } + + const url = toUrl(raw, 'shop base URL'); + if (getRuntimeEnv().NODE_ENV === 'production' && url.protocol !== 'https:') { + throw new Error('Shop base URL must be https in production.'); + } + + return url; +} + +export function toAbsoluteUrl(pathOrUrl: string): string { + const trimmed = pathOrUrl.trim(); + if (!trimmed) { + throw new Error('Absolute URL requires a non-empty path.'); + } + + if (/^https?:\/\//i.test(trimmed)) { + const url = toUrl(trimmed, 'provided URL'); + if ( + getRuntimeEnv().NODE_ENV === 'production' && + url.protocol !== 'https:' + ) { + throw new Error('Provided URL must be https in production.'); + } + return url.toString(); + } + + const base = resolveShopBaseUrl(); + const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return new URL(path, base).toString(); +} diff --git a/frontend/lib/tests/shop/admin-api-killswitch.test.ts b/frontend/lib/tests/shop/admin-api-killswitch.test.ts index ce7c3dbb..d3c76bb6 100644 --- a/frontend/lib/tests/shop/admin-api-killswitch.test.ts +++ b/frontend/lib/tests/shop/admin-api-killswitch.test.ts @@ -58,6 +58,13 @@ const cases: RouteCase[] = [ kind: 'dynamic-id', id: TEST_ORDER_ID, }, + { + name: 'admin/orders/[id]/cancel-payment', + importPath: '@/app/api/shop/admin/orders/[id]/cancel-payment/route', + path: (id: string) => `/api/shop/admin/orders/${id}/cancel-payment`, + kind: 'dynamic-id', + id: TEST_ORDER_ID, + }, { name: 'admin/orders/[id]/refund', importPath: '@/app/api/shop/admin/orders/[id]/refund/route', @@ -158,7 +165,7 @@ describe('P0-7.1 Admin API kill-switch coverage (production)', () => { } const path = c.path(c.id); - const ctx = { params: { id: c.id } }; + const ctx = { params: Promise.resolve({ id: c.id }) }; await runAllMutationMethods(mod, path, ctx); } diff --git a/frontend/lib/tests/shop/admin-csrf-contract.test.ts b/frontend/lib/tests/shop/admin-csrf-contract.test.ts index 3312e906..1639a9f7 100644 --- a/frontend/lib/tests/shop/admin-csrf-contract.test.ts +++ b/frontend/lib/tests/shop/admin-csrf-contract.test.ts @@ -1,6 +1,9 @@ import { NextRequest } from 'next/server'; import { describe, expect, it, vi } from 'vitest'; +import { POST as postCancelPayment } from '@/app/api/shop/admin/orders/[id]/cancel-payment/route'; +import { PATCH as patchStatus } from '@/app/api/shop/admin/products/[id]/status/route'; + vi.mock('@/lib/auth/admin', () => { class AdminApiDisabledError extends Error { code = 'ADMIN_API_DISABLED'; @@ -19,8 +22,6 @@ vi.mock('@/lib/auth/admin', () => { }; }); -import { PATCH as patchStatus } from '@/app/api/shop/admin/products/[id]/status/route'; - describe('P0-SEC: admin CSRF required for mutating endpoints', () => { it('admin status toggle: missing CSRF => 403 CSRF_MISSING', async () => { const prev = process.env.CSRF_SECRET; @@ -49,4 +50,32 @@ describe('P0-SEC: admin CSRF required for mutating endpoints', () => { } } }); + it('admin cancel-payment: missing CSRF => 403 CSRF_MISSING', async () => { + const prev = process.env.CSRF_SECRET; + try { + process.env.CSRF_SECRET = 'test_csrf_secret'; + + const req = new NextRequest( + new Request('http://localhost/api/shop/admin/orders/x/cancel-payment', { + method: 'POST', + headers: { + origin: 'http://localhost:3000', + 'content-type': 'application/json', + }, + body: JSON.stringify({}), + }) + ); + + const res = await postCancelPayment(req, { + params: Promise.resolve({ id: '11111111-1111-1111-1111-111111111111' }), + }); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.code).toBe('CSRF_MISSING'); + } finally { + if (prev === undefined) delete process.env.CSRF_SECRET; + else process.env.CSRF_SECRET = prev; + } + }); }); diff --git a/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts b/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts index 31f3d279..207c4681 100644 --- a/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts +++ b/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts @@ -248,8 +248,6 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = await db.delete(products).where(eq((products as any).id, productId)); } catch (err) { - // Do not swallow cleanup failures: they can leave residual rows and cause flaky follow-up tests. - // In CI we fail fast so flakes are visible. if (process.env.CI) throw err; console.warn('checkout concurrency cleanup failed', err); diff --git a/frontend/lib/tests/shop/checkout-currency-policy.test.ts b/frontend/lib/tests/shop/checkout-currency-policy.test.ts index 56f2ebd8..07dd07ed 100644 --- a/frontend/lib/tests/shop/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-currency-policy.test.ts @@ -3,6 +3,8 @@ import { inArray } from 'drizzle-orm'; import { NextRequest } from 'next/server'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { inventoryMoves, orderItems, orders, productPrices, products } from '@/db/schema'; + process.env.STRIPE_PAYMENTS_ENABLED = 'false'; process.env.STRIPE_SECRET_KEY = ''; process.env.STRIPE_WEBHOOK_SECRET = ''; @@ -12,7 +14,7 @@ vi.mock('@/lib/auth', async () => { await vi.importActual('@/lib/auth'); return { ...actual, - getCurrentUser: async () => null, // guest + getCurrentUser: async () => null, }; }); @@ -57,7 +59,6 @@ vi.mock('@/lib/logging', async () => { }); import { db } from '@/db'; -import { orders, productPrices, products } from '@/db/schema'; let POST: (req: NextRequest) => Promise; @@ -79,12 +80,28 @@ beforeAll(async () => { afterAll(async () => { if (createdOrderIds.length) { + await db + .delete(inventoryMoves) + .where(inArray(inventoryMoves.orderId, createdOrderIds)); + await db + .delete(orderItems) + .where(inArray(orderItems.orderId, createdOrderIds)); + await db.delete(orders).where(inArray(orders.id, createdOrderIds)); } + if (createdProductIds.length) { + await db + .delete(inventoryMoves) + .where(inArray(inventoryMoves.productId, createdProductIds)); + await db + .delete(orderItems) + .where(inArray(orderItems.productId, createdProductIds)); + await db .delete(productPrices) .where(inArray(productPrices.productId, createdProductIds)); + await db.delete(products).where(inArray(products.id, createdProductIds)); } }); diff --git a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts new file mode 100644 index 00000000..08fe9a33 --- /dev/null +++ b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts @@ -0,0 +1,525 @@ +import crypto from 'crypto'; +import { and, eq, sql } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { db } from '@/db'; +import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { toDbMoney } from '@/lib/shop/money'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +const createMonobankInvoiceMock = vi.fn(async (args: any) => { + const orderId = + typeof args?.orderId === 'string' ? args.orderId : crypto.randomUUID(); + + const invoiceId = `inv_${orderId}`; + return { + invoiceId, + pageUrl: `https://pay.test/${invoiceId}`, + raw: {}, + }; +}); + +vi.mock('@/lib/psp/monobank', () => ({ + MONO_CURRENCY: 'UAH', + createMonobankInvoice: (args: any) => createMonobankInvoiceMock(args), + cancelMonobankInvoice: vi.fn(async () => {}), +})); + +let __seedTemplateProductId: string | null = null; + +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; +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'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + 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(); +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; + else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + + if (__prevShopBaseUrl === undefined) delete process.env.SHOP_BASE_URL; + else process.env.SHOP_BASE_URL = __prevShopBaseUrl; + + 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(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function readRows(res: any): T[] { + if (Array.isArray(res)) return res as T[]; + if (Array.isArray(res?.rows)) return res.rows as T[]; + return []; +} + +type ColumnInfo = { + column_name: string; + is_nullable: 'YES' | 'NO'; + column_default: string | null; + data_type: string; + udt_name: string; + is_identity?: 'YES' | 'NO'; + is_generated?: 'ALWAYS' | 'NEVER'; +}; + +function qIdent(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +async function getFirstEnumLabel(typeName: string): Promise { + const res = await db.execute(sql` + select e.enumlabel as label + from pg_type t + join pg_enum e on e.enumtypid = t.oid + where t.typname = ${typeName} + order by e.enumsortorder asc + limit 1 + `); + const rows = readRows<{ label?: unknown }>(res); + const label = rows[0]?.label; + if (typeof label !== 'string' || !label.trim()) { + throw new Error(`Unable to resolve enum label for type "${typeName}".`); + } + return label; +} + +async function seedTemplateProductIfMissing(): Promise { + const [existing] = await db + .select() + .from(products) + .where(eq(products.isActive as any, true)) + .limit(1); + + if (existing) return existing; + + const productId = crypto.randomUUID(); + const slug = `t-seed-${crypto.randomUUID()}`; + const sku = `t-seed-${crypto.randomUUID()}`; + const now = new Date(); + + __seedTemplateProductId = productId; + + const infoRes = await db.execute(sql` + select + column_name, + is_nullable, + column_default, + data_type, + udt_name, + is_identity, + is_generated + from information_schema.columns + where table_schema = 'public' and table_name = 'products' + order by ordinal_position asc + `); + const cols = readRows(infoRes); + if (!cols.length) throw new Error('Unable to introspect products columns.'); + + const preferred: Record = { + id: productId, + slug, + sku, + title: `Seed ${slug}`, + stock: 9999, + is_active: true, + created_at: now, + updated_at: now, + }; + + const insertCols: string[] = []; + const insertVals: any[] = []; + + for (const c of cols) { + const col = c.column_name; + const hasPreferred = Object.prototype.hasOwnProperty.call(preferred, col); + const isGenerated = c.is_generated === 'ALWAYS'; + const isIdentity = c.is_identity === 'YES'; + const requiredNoDefault = + c.is_nullable === 'NO' && + (c.column_default === null || c.column_default === undefined); + + if (isGenerated || isIdentity) continue; + if (!hasPreferred && !requiredNoDefault) continue; + + insertCols.push(col); + + if (hasPreferred) { + insertVals.push(sql`${preferred[col]}`); + continue; + } + + if (c.data_type === 'USER-DEFINED') { + const enumLabel = await getFirstEnumLabel(c.udt_name); + insertVals.push(sql`${enumLabel}::${sql.raw(qIdent(c.udt_name))}`); + continue; + } + + switch (c.data_type) { + case 'boolean': + insertVals.push(sql`false`); + break; + case 'smallint': + case 'integer': + case 'bigint': + case 'numeric': + case 'real': + case 'double precision': + insertVals.push(sql`0`); + break; + case 'uuid': + insertVals.push(sql`${crypto.randomUUID()}::uuid`); + break; + case 'jsonb': + insertVals.push(sql`${JSON.stringify({})}::jsonb`); + break; + case 'json': + insertVals.push(sql`${JSON.stringify({})}::json`); + break; + case 'date': + insertVals.push(sql`${now.toISOString().slice(0, 10)}`); + break; + case 'timestamp with time zone': + case 'timestamp without time zone': + case 'timestamp': + insertVals.push(sql`${now}`); + break; + default: + insertVals.push(sql`${`seed_${col}_${crypto.randomUUID()}`}`); + break; + } + } + + const colSql = insertCols.map(c => sql.raw(qIdent(c))); + await db.execute(sql` + insert into "products" (${sql.join(colSql, sql`, `)}) + values (${sql.join(insertVals, sql`, `)}) + `); + + await db.insert(productPrices).values([ + { + productId, + currency: 'UAH', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + createdAt: now, + updatedAt: now, + } as any, + { + productId, + currency: 'USD', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + createdAt: now, + updatedAt: now, + } as any, + ]); + + const [seeded] = await db + .select() + .from(products) + .where(eq(products.id as any, productId)) + .limit(1); + if (!seeded) throw new Error('Failed to seed template product.'); + + return seeded; +} + +async function createIsolatedProduct(args: { + stock: number; + prices: Array<{ currency: 'USD' | 'UAH'; priceMinor: number }>; +}) { + const tpl = await seedTemplateProductIfMissing(); + + const productId = crypto.randomUUID(); + const slug = `t-mono-contract-${crypto.randomUUID()}`; + const sku = `t-mono-contract-${crypto.randomUUID()}`; + const now = new Date(); + + await db.insert(products).values({ + ...(tpl as any), + id: productId, + slug, + sku, + title: `Test ${slug}`, + stock: args.stock, + isActive: true, + createdAt: now, + updatedAt: now, + } as any); + + await db.insert(productPrices).values( + args.prices.map(price => ({ + productId, + currency: price.currency, + priceMinor: price.priceMinor, + originalPriceMinor: null, + price: toDbMoney(price.priceMinor), + originalPrice: null, + createdAt: now, + updatedAt: now, + })) as any + ); + + return { productId }; +} + +async function cleanupOrder(orderId: string) { + await db.execute( + sql`delete from inventory_moves where order_id = ${orderId}::uuid` + ); + await db.execute( + sql`delete from order_items where order_id = ${orderId}::uuid` + ); + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function cleanupProduct(productId: string) { + await db.execute( + sql`delete from inventory_moves where product_id = ${productId}::uuid` + ); + await db.execute( + sql`delete from order_items where product_id = ${productId}::uuid` + ); + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + await db.delete(products).where(eq(products.id, productId)); +} + +function warnCleanup(step: string, err: unknown) { + console.warn('[checkout-monobank-idempotency-contract.test] cleanup failed', { + step, + err: err instanceof Error ? { name: err.name, message: err.message } : err, + }); +} + +afterAll(async () => { + if (!__seedTemplateProductId) return; + try { + await cleanupProduct(__seedTemplateProductId); + } catch (e) { warnCleanup('cleanupSeedTemplateProduct', e); } + __seedTemplateProductId = null; +}); + +async function postCheckout(idemKey: string, productId: string) { + const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { + POST: (req: NextRequest) => Promise; + }; + + const req = new NextRequest('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-language': 'uk-UA', + 'idempotency-key': idemKey, + 'x-request-id': `mono-test-${idemKey}`, + 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), + origin: 'http://localhost:3000', + }, + + body: JSON.stringify({ + items: [{ productId, quantity: 1 }], + paymentProvider: 'monobank', + }), + }); + + return mod.POST(req); +} + +describe.sequential('checkout monobank contract', () => { + it('idempotency: same key+payload returns same order/page/attempt and does not duplicate invoice', async () => { + const { productId } = await createIsolatedProduct({ + stock: 3, + prices: [{ currency: 'UAH', priceMinor: 1000 }], + }); + const idemKey = crypto.randomUUID(); + let orderId: string | null = null; + + try { + const res1 = await postCheckout(idemKey, productId); + if (res1.status !== 201) { + const bodyText = await res1 + .clone() + .text() + .catch(() => ''); + + const [dbOrder] = await db + .select({ + id: orders.id, + status: orders.status, + paymentStatus: orders.paymentStatus, + inventoryStatus: orders.inventoryStatus, + failureCode: orders.failureCode, + failureMessage: orders.failureMessage, + currency: orders.currency, + totalAmountMinor: orders.totalAmountMinor, + pspChargeId: orders.pspChargeId, + pspMetadata: orders.pspMetadata, + }) + .from(orders) + .where(eq(orders.idempotencyKey, idemKey)) + .limit(1); + + const attempts = dbOrder?.id + ? await db + .select({ + id: paymentAttempts.id, + status: paymentAttempts.status, + attemptNumber: paymentAttempts.attemptNumber, + providerPaymentIntentId: + paymentAttempts.providerPaymentIntentId, + lastErrorCode: paymentAttempts.lastErrorCode, + lastErrorMessage: paymentAttempts.lastErrorMessage, + metadata: paymentAttempts.metadata, + expectedAmountMinor: paymentAttempts.expectedAmountMinor, + currency: paymentAttempts.currency, + createdAt: paymentAttempts.createdAt, + updatedAt: paymentAttempts.updatedAt, + finalizedAt: paymentAttempts.finalizedAt, + }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, dbOrder.id)) + : []; + + throw new Error( + `checkout failed: status=${res1.status}\n` + + `body=${bodyText}\n` + + `order=${JSON.stringify(dbOrder ?? null, null, 2)}\n` + + `attempts=${JSON.stringify(attempts ?? [], null, 2)}\n` + ); + } + + const res2 = await postCheckout(idemKey, productId); + + expect(res1.status).toBe(201); + expect(res2.status).toBe(200); + + const json1: any = await res1.json(); + orderId = json1.orderId; + + const json2: any = await res2.json(); + + expect(json1.success).toBe(true); + expect(json2.success).toBe(true); + expect(typeof json1.orderId).toBe('string'); + expect(typeof json1.attemptId).toBe('string'); + expect(typeof json1.pageUrl).toBe('string'); + expect(typeof json1.totalAmountMinor).toBe('number'); + expect(json1.orderId).toBeTruthy(); + expect(json1.orderId).toBe(json2.orderId); + expect(json1.attemptId).toBeTruthy(); + expect(json1.attemptId).toBe(json2.attemptId); + expect(json1.pageUrl).toBeTruthy(); + expect(json1.pageUrl).toBe(json2.pageUrl); + expect(json1.provider).toBe('mono'); + expect(json2.provider).toBe('mono'); + expect(json1.currency).toBe('UAH'); + expect(json2.currency).toBe('UAH'); + expect(json1.totalAmountMinor).toBeGreaterThan(0); + expect(json1.totalAmountMinor).toBe(json2.totalAmountMinor); + + const [dbOrder] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idemKey)) + .limit(1); + expect(dbOrder?.id).toBe(orderId); + + const attemptRows = await db + .select({ id: paymentAttempts.id }) + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.orderId, orderId as string), + eq(paymentAttempts.provider, 'monobank') + ) + ); + expect(attemptRows.length).toBe(1); + + expect(createMonobankInvoiceMock).toHaveBeenCalledTimes(1); + } finally { + if (orderId) await cleanupOrder(orderId).catch(() => {}); + await cleanupProduct(productId).catch(() => {}); + } + }, 20_000); + + it('missing UAH price -> 422 PRICE_CONFIG_ERROR for monobank checkout', async () => { + const { productId } = await createIsolatedProduct({ + stock: 2, + prices: [{ currency: 'USD', priceMinor: 1000 }], + }); + const idemKey = crypto.randomUUID(); + + try { + const res = await postCheckout(idemKey, productId); + expect(res.status).toBe(422); + const json: any = await res.json(); + expect(json.code).toBe('PRICE_CONFIG_ERROR'); + expect(createMonobankInvoiceMock).not.toHaveBeenCalled(); + } finally { + await cleanupProduct(productId).catch(() => {}); + } + }, 20_000); +}); diff --git a/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts new file mode 100644 index 00000000..5cfc7b04 --- /dev/null +++ b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts @@ -0,0 +1,136 @@ +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { POST } from '@/app/api/shop/checkout/route'; +import { createOrderWithItems } from '@/lib/services/orders'; + + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/env/monobank', () => ({ + isMonobankEnabled: () => true, +})); + +vi.mock('@/lib/services/orders', async () => { + const actual = await vi.importActual('@/lib/services/orders'); + return { + ...actual, + createOrderWithItems: vi.fn(), + restockOrder: vi.fn(), + }; +}); + +type MockedFn = ReturnType; + +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; + +beforeAll(() => { + process.env.RATE_LIMIT_DISABLED = '1'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevStripePaymentsEnabled === undefined) + delete process.env.STRIPE_PAYMENTS_ENABLED; + else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function makeMonobankCheckoutReq(params: { + idempotencyKey?: string; + body: Record; +}) { + const headers = new Headers({ + 'content-type': 'application/json', + 'accept-language': 'uk-UA', + origin: 'http://localhost:3000', + }); + + if (params.idempotencyKey) { + headers.set('idempotency-key', params.idempotencyKey); + } + + return new NextRequest( + new Request('http://localhost:3000/api/shop/checkout', { + method: 'POST', + headers, + body: JSON.stringify(params.body), + }) + ); +} + +describe('checkout monobank parse/validation', () => { + it('rejects monobank checkout without idempotency key', async () => { + const res = await POST( + makeMonobankCheckoutReq({ + body: { + paymentProvider: 'monobank', + items: [{ productId: '11111111-1111-4111-8111-111111111111', quantity: 1 }], + }, + }) + ); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.code).toBe('INVALID_REQUEST'); + expect(createOrderWithItems).not.toHaveBeenCalled(); + }); + + it('ignores client currency/amount fields for monobank payload validation', async () => { + const createOrderWithItemsMock = createOrderWithItems as unknown as MockedFn; + + createOrderWithItemsMock.mockResolvedValueOnce({ + order: { + id: 'order_monobank_parse_1', + currency: 'UAH', + totalAmount: 10, + paymentStatus: 'paid', + paymentProvider: 'none', + paymentIntentId: null, + }, + isNew: true, + totalCents: 1000, + }); + + const idem = 'mono_idem_validation_0001'; + const res = await POST( + makeMonobankCheckoutReq({ + idempotencyKey: idem, + body: { + paymentProvider: 'monobank', + items: [{ productId: '11111111-1111-4111-8111-111111111111', quantity: 1 }], + currency: 'USD', + amount: 999999, + amountMinor: 999999, + totalAmount: 999999, + totalAmountMinor: 999999, + }, + }) + ); + + expect(res.status).toBe(201); + expect(createOrderWithItems).toHaveBeenCalledTimes(1); + + const args = createOrderWithItemsMock.mock.calls[0]?.[0]; + expect(args).toMatchObject({ + idempotencyKey: idem, + paymentProvider: 'monobank', + }); + expect(Array.isArray(args?.items)).toBe(true); + }); +}); diff --git a/frontend/lib/tests/shop/checkout-no-payments.test.ts b/frontend/lib/tests/shop/checkout-no-payments.test.ts index fe9747cc..51ab95a0 100644 --- a/frontend/lib/tests/shop/checkout-no-payments.test.ts +++ b/frontend/lib/tests/shop/checkout-no-payments.test.ts @@ -1,4 +1,3 @@ -// frontend/lib/tests/checkout-no-payments.test.ts import crypto from 'crypto'; import { eq, sql } from 'drizzle-orm'; import { NextRequest } from 'next/server'; diff --git a/frontend/lib/tests/shop/monobank-adapter.test.ts b/frontend/lib/tests/shop/monobank-adapter.test.ts new file mode 100644 index 00000000..295ba23f --- /dev/null +++ b/frontend/lib/tests/shop/monobank-adapter.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMonobankInvoicePayload } from '@/lib/psp/monobank'; +import { MONO_CCY } from '@/lib/psp/monobank'; + +describe('monobank invoice adapter', () => { + it('uses ccy=980 and paymentType=debit', () => { + const payload = buildMonobankInvoicePayload({ + amountMinor: 1500, + orderId: 'order_test', + redirectUrl: + 'https://example.test/shop/checkout/success?orderId=order_test', + webhookUrl: 'https://example.test/api/shop/webhooks/monobank', + paymentType: 'debit', + merchantPaymInfo: { + reference: 'attempt-1', + destination: 'Оплата замовлення 1', + basketOrder: [ + { + name: 'Item', + qty: 1, + sum: 1500, + total: 1500, + unit: 'шт.', + }, + ], + }, + }); + + expect(payload.ccy).toBe(MONO_CCY); + expect(payload.paymentType).toBe('debit'); + }); + + it('rejects non-debit paymentType', () => { + expect(() => + buildMonobankInvoicePayload({ + amountMinor: 1500, + orderId: 'order_test', + redirectUrl: + 'https://example.test/shop/checkout/success?orderId=order_test', + webhookUrl: 'https://example.test/api/shop/webhooks/monobank', + paymentType: 'hold' as any, + merchantPaymInfo: { + reference: 'attempt-1', + destination: 'Оплата замовлення 1', + basketOrder: [ + { + name: 'Item', + qty: 1, + sum: 1500, + total: 1500, + unit: 'шт.', + }, + ], + }, + }) + ).toThrow(); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-api-methods.test.ts b/frontend/lib/tests/shop/monobank-api-methods.test.ts new file mode 100644 index 00000000..e9c27f7f --- /dev/null +++ b/frontend/lib/tests/shop/monobank-api-methods.test.ts @@ -0,0 +1,295 @@ +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', + 'MONO_API_BASE', + 'MONO_INVOICE_TIMEOUT_MS', +]; + +const previousEnv: Record = {}; +const originalFetch = globalThis.fetch; + +function rememberEnv() { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + } +} + +function restoreEnv() { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +function makeResponse(status: number, body: string) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => body, + }; +} + +async function expectPspError( + fn: () => Promise, + code: string, + meta?: Record +) { + try { + await fn(); + throw new Error('expected error'); + } catch (error) { + const { PspError } = await import('@/lib/psp/monobank'); + expect(error).toBeInstanceOf(PspError); + const err = error as InstanceType; + expect(err.code).toBe(code); + if (meta) { + expect(err.safeMeta).toMatchObject(meta); + } + } +} + +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'; + process.env.MONO_INVOICE_TIMEOUT_MS = '5000'; + delete process.env.MONO_PUBLIC_KEY; + resetEnvCache(); + vi.resetModules(); +}); + +afterEach(() => { + restoreEnv(); + resetEnvCache(); + vi.restoreAllMocks(); + globalThis.fetch = originalFetch; +}); + +describe('monobank api methods', () => { + const createArgs = { + amountMinor: 1234, + validitySeconds: 600, + reference: 'attempt-1', + redirectUrl: 'https://shop.test/redirect', + webHookUrl: 'https://shop.test/api/shop/webhooks/monobank', + merchantPaymInfo: { + reference: 'attempt-1', + destination: 'Оплата замовлення 123', + basketOrder: [ + { + name: 'Item', + qty: 1, + sum: 1234, + total: 1234, + unit: 'шт.', + }, + ], + }, + }; + + it('createInvoice returns normalized response on 2xx', async () => { + const body = JSON.stringify({ + invoiceId: 'inv_1', + pageUrl: 'https://pay.example.test/i/inv_1', + }); + const fetchMock = vi.fn(async () => makeResponse(200, body)); + globalThis.fetch = fetchMock as any; + + const { createInvoice } = await import('@/lib/psp/monobank'); + const result = await createInvoice(createArgs); + expect(result.invoiceId).toBe('inv_1'); + expect(result.pageUrl).toBe('https://pay.example.test/i/inv_1'); + }); + + it('createInvoice maps 400 to PSP_BAD_REQUEST', async () => { + const body = JSON.stringify({ errorCode: 'X', message: 'bad' }); + const fetchMock = vi.fn(async () => makeResponse(400, body)); + globalThis.fetch = fetchMock as any; + + const { createInvoice } = await import('@/lib/psp/monobank'); + await expectPspError(() => createInvoice(createArgs), 'PSP_BAD_REQUEST', { + httpStatus: 400, + monoCode: 'X', + }); + }); + + it('createInvoice maps 401 to PSP_AUTH_FAILED', async () => { + const fetchMock = vi.fn(async () => makeResponse(401, 'unauthorized')); + globalThis.fetch = fetchMock as any; + + const { createInvoice } = await import('@/lib/psp/monobank'); + await expectPspError(() => createInvoice(createArgs), 'PSP_AUTH_FAILED', { + httpStatus: 401, + }); + }); + + it('createInvoice maps 500 to PSP_UNKNOWN', async () => { + const fetchMock = vi.fn(async () => makeResponse(500, 'error')); + globalThis.fetch = fetchMock as any; + + const { createInvoice } = await import('@/lib/psp/monobank'); + await expectPspError(() => createInvoice(createArgs), 'PSP_UNKNOWN', { + httpStatus: 500, + }); + }); + + it('getInvoiceStatus returns normalized response on 2xx', async () => { + const body = JSON.stringify({ invoiceId: 'inv_2', status: 'created' }); + const fetchMock = vi.fn(async () => makeResponse(200, body)); + globalThis.fetch = fetchMock as any; + + const { getInvoiceStatus } = await import('@/lib/psp/monobank'); + const result = await getInvoiceStatus('inv_2'); + expect(result.invoiceId).toBe('inv_2'); + expect(result.status).toBe('created'); + }); + + it('getInvoiceStatus maps 400 to PSP_BAD_REQUEST', async () => { + const body = JSON.stringify({ errorCode: 'X', message: 'bad' }); + const fetchMock = vi.fn(async () => makeResponse(400, body)); + globalThis.fetch = fetchMock as any; + + const { getInvoiceStatus } = await import('@/lib/psp/monobank'); + await expectPspError(() => getInvoiceStatus('inv_2'), 'PSP_BAD_REQUEST', { + httpStatus: 400, + monoCode: 'X', + }); + }); + + it('getInvoiceStatus maps 401 to PSP_AUTH_FAILED', async () => { + const fetchMock = vi.fn(async () => makeResponse(401, 'unauthorized')); + globalThis.fetch = fetchMock as any; + + const { getInvoiceStatus } = await import('@/lib/psp/monobank'); + await expectPspError(() => getInvoiceStatus('inv_2'), 'PSP_AUTH_FAILED', { + httpStatus: 401, + }); + }); + + it('getInvoiceStatus maps 500 to PSP_UNKNOWN', async () => { + const fetchMock = vi.fn(async () => makeResponse(500, 'error')); + globalThis.fetch = fetchMock as any; + + const { getInvoiceStatus } = await import('@/lib/psp/monobank'); + await expectPspError(() => getInvoiceStatus('inv_2'), 'PSP_UNKNOWN', { + httpStatus: 500, + }); + }); + + it('cancelInvoicePayment returns normalized response on 2xx', async () => { + const body = JSON.stringify({ invoiceId: 'inv_3', status: 'canceled' }); + const fetchMock = vi.fn(async () => makeResponse(200, body)); + globalThis.fetch = fetchMock as any; + + const { cancelInvoicePayment } = await import('@/lib/psp/monobank'); + const result = await cancelInvoicePayment({ + invoiceId: 'inv_3', + extRef: 'ext_3', + amountMinor: 500, + }); + expect(result.invoiceId).toBe('inv_3'); + expect(result.status).toBe('canceled'); + }); + + it('cancelInvoicePayment maps 400 to PSP_BAD_REQUEST', async () => { + const body = JSON.stringify({ errorCode: 'X', message: 'bad' }); + const fetchMock = vi.fn(async () => makeResponse(400, body)); + globalThis.fetch = fetchMock as any; + + const { cancelInvoicePayment } = await import('@/lib/psp/monobank'); + await expectPspError( + () => + cancelInvoicePayment({ + invoiceId: 'inv_3', + extRef: 'ext_3', + amountMinor: 500, + }), + 'PSP_BAD_REQUEST', + { httpStatus: 400, monoCode: 'X' } + ); + }); + + it('cancelInvoicePayment maps 401 to PSP_AUTH_FAILED', async () => { + const fetchMock = vi.fn(async () => makeResponse(401, 'unauthorized')); + globalThis.fetch = fetchMock as any; + + const { cancelInvoicePayment } = await import('@/lib/psp/monobank'); + await expectPspError( + () => + cancelInvoicePayment({ + invoiceId: 'inv_3', + extRef: 'ext_3', + }), + 'PSP_AUTH_FAILED', + { httpStatus: 401 } + ); + }); + + it('cancelInvoicePayment maps 500 to PSP_UNKNOWN', async () => { + const fetchMock = vi.fn(async () => makeResponse(500, 'error')); + globalThis.fetch = fetchMock as any; + + const { cancelInvoicePayment } = await import('@/lib/psp/monobank'); + await expectPspError( + () => + cancelInvoicePayment({ + invoiceId: 'inv_3', + extRef: 'ext_3', + }), + 'PSP_UNKNOWN', + { httpStatus: 500 } + ); + }); + + it('removeInvoice returns normalized response on 2xx', async () => { + const fetchMock = vi.fn(async () => makeResponse(200, '')); + globalThis.fetch = fetchMock as any; + + const { removeInvoice } = await import('@/lib/psp/monobank'); + const result = await removeInvoice('inv_4'); + expect(result.invoiceId).toBe('inv_4'); + expect(result.removed).toBe(true); + }); + + it('removeInvoice maps 400 to PSP_BAD_REQUEST', async () => { + const body = JSON.stringify({ errorCode: 'X', message: 'bad' }); + const fetchMock = vi.fn(async () => makeResponse(400, body)); + globalThis.fetch = fetchMock as any; + + const { removeInvoice } = await import('@/lib/psp/monobank'); + await expectPspError(() => removeInvoice('inv_4'), 'PSP_BAD_REQUEST', { + httpStatus: 400, + monoCode: 'X', + }); + }); + + it('removeInvoice maps 401 to PSP_AUTH_FAILED', async () => { + const fetchMock = vi.fn(async () => makeResponse(401, 'unauthorized')); + globalThis.fetch = fetchMock as any; + + const { removeInvoice } = await import('@/lib/psp/monobank'); + await expectPspError(() => removeInvoice('inv_4'), 'PSP_AUTH_FAILED', { + httpStatus: 401, + }); + }); + + it('removeInvoice maps 500 to PSP_UNKNOWN', async () => { + const fetchMock = vi.fn(async () => makeResponse(500, 'error')); + globalThis.fetch = fetchMock as any; + + const { removeInvoice } = await import('@/lib/psp/monobank'); + await expectPspError(() => removeInvoice('inv_4'), 'PSP_UNKNOWN', { + httpStatus: 500, + }); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-attempt-invoice.test.ts b/frontend/lib/tests/shop/monobank-attempt-invoice.test.ts new file mode 100644 index 00000000..7a7af6f7 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-attempt-invoice.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/logging', () => ({ + logWarn: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), +})); + +import { + PspInvoicePersistError, + PspUnavailableError, +} from '@/lib/services/errors'; +import { __test__ } from '@/lib/services/orders/monobank'; + +describe('createMonoAttemptAndInvoice (unit, no DB)', () => { + const baseArgs = { + orderId: 'order_1', + requestId: 'req_1', + redirectUrl: 'https://shop.test/redirect', + webhookUrl: 'https://shop.test/api/shop/webhooks/monobank', + maxAttempts: 3, + }; + + function snapshot() { + return { + amountMinor: 1234, + currency: 'UAH', + items: [ + { + productId: 'p1', + title: 'Item', + quantity: 1, + unitPriceMinor: 1234, + lineTotalMinor: 1234, + }, + ], + }; + } + + it('success: creates invoice and finalizes attempt', async () => { + const deps = { + getActiveAttempt: vi.fn(async () => null), + createCreatingAttempt: vi.fn(async () => ({ + id: 'attempt_1', + attemptNumber: 1, + providerPaymentIntentId: null, + metadata: null, + })), + readMonobankInvoiceParams: vi.fn(async () => snapshot()), + createMonobankInvoice: vi.fn(async () => ({ + invoiceId: 'inv_1', + pageUrl: 'https://pay.test/inv_1', + raw: {}, + })), + markAttemptFailed: vi.fn(async () => undefined), + cancelOrderAndRelease: vi.fn(async () => undefined), + finalizeAttemptWithInvoice: vi.fn(async () => undefined), + }; + + const res = await __test__.createMonoAttemptAndInvoiceImpl( + deps as any, + baseArgs + ); + + expect(res.attemptId).toBe('attempt_1'); + expect(res.invoiceId).toBe('inv_1'); + expect(res.pageUrl).toBe('https://pay.test/inv_1'); + + expect(deps.createMonobankInvoice).toHaveBeenCalledTimes(1); + const call = (deps.createMonobankInvoice as any).mock.calls[0][0]; + expect(call.redirectUrl).toBe(baseArgs.redirectUrl); + expect(call.webhookUrl).toBe(baseArgs.webhookUrl); + expect(call.merchantPaymInfo.reference).toBe('attempt_1'); + + expect(deps.finalizeAttemptWithInvoice).toHaveBeenCalledTimes(1); + }); + + it('idempotency: returns existing invoice without PSP call', async () => { + const deps = { + getActiveAttempt: vi.fn(async () => ({ + id: 'attempt_1', + attemptNumber: 1, + providerPaymentIntentId: 'inv_1', + metadata: { pageUrl: 'https://pay.test/inv_1' }, + })), + createCreatingAttempt: vi.fn(), + readMonobankInvoiceParams: vi.fn(async () => snapshot()), + createMonobankInvoice: vi.fn(), + markAttemptFailed: vi.fn(), + cancelOrderAndRelease: vi.fn(), + finalizeAttemptWithInvoice: vi.fn(), + }; + + const res = await __test__.createMonoAttemptAndInvoiceImpl( + deps as any, + baseArgs + ); + + expect(res.attemptId).toBe('attempt_1'); + expect(res.invoiceId).toBe('inv_1'); + expect(res.pageUrl).toBe('https://pay.test/inv_1'); + expect(deps.createMonobankInvoice).not.toHaveBeenCalled(); + expect(deps.createCreatingAttempt).not.toHaveBeenCalled(); + }); + + it('idempotency: second call reuses first invoice (PSP called once)', async () => { + const state = { + attempt: null as null | { + id: string; + attemptNumber: number; + providerPaymentIntentId: string | null; + metadata: Record | null; + }, + }; + + const deps = { + getActiveAttempt: vi.fn(async () => state.attempt), + createCreatingAttempt: vi.fn(async () => { + state.attempt = { + id: 'attempt_1', + attemptNumber: 1, + providerPaymentIntentId: null, + metadata: null, + }; + return state.attempt; + }), + readMonobankInvoiceParams: vi.fn(async () => snapshot()), + createMonobankInvoice: vi.fn(async () => ({ + invoiceId: 'inv_1', + pageUrl: 'https://pay.test/inv_1', + raw: {}, + })), + markAttemptFailed: vi.fn(async () => undefined), + cancelOrderAndRelease: vi.fn(async () => undefined), + finalizeAttemptWithInvoice: vi.fn(async args => { + if (state.attempt && state.attempt.id === args.attemptId) { + state.attempt.providerPaymentIntentId = args.invoiceId; + state.attempt.metadata = { pageUrl: args.pageUrl }; + } + }), + }; + + const first = await __test__.createMonoAttemptAndInvoiceImpl( + deps as any, + baseArgs + ); + const second = await __test__.createMonoAttemptAndInvoiceImpl( + deps as any, + baseArgs + ); + + expect(first.pageUrl).toBe('https://pay.test/inv_1'); + expect(second.pageUrl).toBe('https://pay.test/inv_1'); + expect(first.invoiceId).toBe('inv_1'); + expect(second.invoiceId).toBe('inv_1'); + expect(deps.createMonobankInvoice).toHaveBeenCalledTimes(1); + expect(deps.createCreatingAttempt).toHaveBeenCalledTimes(1); + }); + + it('fail-closed: PSP error -> attempt failed + order canceled + 503 error type', async () => { + const deps = { + getActiveAttempt: vi.fn(async () => null), + createCreatingAttempt: vi.fn(async () => ({ + id: 'attempt_1', + attemptNumber: 1, + providerPaymentIntentId: null, + metadata: null, + })), + readMonobankInvoiceParams: vi.fn(async () => snapshot()), + createMonobankInvoice: vi.fn(async () => { + const err: any = new Error('timeout'); + err.code = 'PSP_TIMEOUT'; + throw err; + }), + markAttemptFailed: vi.fn(async () => undefined), + cancelOrderAndRelease: vi.fn(async () => undefined), + finalizeAttemptWithInvoice: vi.fn(async () => undefined), + }; + + await expect( + __test__.createMonoAttemptAndInvoiceImpl(deps as any, baseArgs) + ).rejects.toBeInstanceOf(PspUnavailableError); + + expect(deps.markAttemptFailed).toHaveBeenCalledTimes(1); + expect(deps.cancelOrderAndRelease).toHaveBeenCalledTimes(1); + }); + + it('tx2 failure after PSP success: propagates persist error', async () => { + const deps = { + getActiveAttempt: vi.fn(async () => null), + createCreatingAttempt: vi.fn(async () => ({ + id: 'attempt_1', + attemptNumber: 1, + providerPaymentIntentId: null, + metadata: null, + })), + readMonobankInvoiceParams: vi.fn(async () => snapshot()), + createMonobankInvoice: vi.fn(async () => ({ + invoiceId: 'inv_1', + pageUrl: 'https://pay.test/inv_1', + raw: {}, + })), + markAttemptFailed: vi.fn(async () => undefined), + cancelOrderAndRelease: vi.fn(async () => undefined), + finalizeAttemptWithInvoice: vi.fn(async () => { + throw new PspInvoicePersistError('persist failed', { + orderId: 'order_1', + }); + }), + }; + + await expect( + __test__.createMonoAttemptAndInvoiceImpl(deps as any, baseArgs) + ).rejects.toBeInstanceOf(PspInvoicePersistError); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-attempt-lifecycle-d.test.ts b/frontend/lib/tests/shop/monobank-attempt-lifecycle-d.test.ts new file mode 100644 index 00000000..6e6273e5 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-attempt-lifecycle-d.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { __test__ } from '@/lib/services/orders/monobank'; + +describe('monobank attempt lifecycle (D)', () => { + it('stale creating without pageUrl: marks attempt failed and does not cancel order; allows new attempt', async () => { + const createMonoAttemptAndInvoiceImpl = + __test__.createMonoAttemptAndInvoiceImpl; + type Deps = Parameters[0]; + + const deps = { + readMonobankInvoiceParams: vi.fn(async () => ({ + amountMinor: 12345, + currency: 'UAH', + items: [ + { + productId: 'prod_1', + title: 'Test item', + quantity: 1, + unitPriceMinor: 12345, + lineTotalMinor: 12345, + }, + ], + })), + + getActiveAttempt: vi.fn(async (orderId: string) => { + void orderId; + return { + id: 'attempt-old', + provider: 'monobank', + status: 'creating', + providerPaymentIntentId: null, + metadata: {}, + createdAt: new Date(0), + updatedAt: new Date(0), + }; + }), + + createCreatingAttempt: vi.fn(async (args: unknown) => { + void args; + return { + id: 'attempt-new', + provider: 'monobank', + status: 'creating', + providerPaymentIntentId: null, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + }), + + markAttemptFailed: vi.fn(async () => undefined), + cancelOrderAndRelease: vi.fn(async () => undefined), + + createMonobankInvoice: vi.fn(async () => ({ + invoiceId: 'inv_1', + pageUrl: 'https://pay.example/1', + raw: {}, + })), + + finalizeAttemptWithInvoice: vi.fn(async () => undefined), + } as unknown as Deps; + + const input = { + orderId: 'order_1', + requestId: 'req_1', + redirectUrl: 'https://example/success', + webhookUrl: 'https://example/webhook', + }; + + const res = await createMonoAttemptAndInvoiceImpl(deps, input); + + expect(deps.markAttemptFailed).toHaveBeenCalledTimes(1); + expect(deps.markAttemptFailed).toHaveBeenCalledWith( + expect.objectContaining({ attemptId: 'attempt-old' }) + ); + + expect(deps.cancelOrderAndRelease).not.toHaveBeenCalled(); + + expect(deps.createCreatingAttempt).toHaveBeenCalledTimes(1); + expect(deps.createMonobankInvoice).toHaveBeenCalledTimes(1); + expect(deps.createMonobankInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: 'order_1', + amountMinor: 12345, + paymentType: 'debit', + redirectUrl: 'https://example/success', + webhookUrl: 'https://example/webhook', + merchantPaymInfo: expect.objectContaining({ + reference: 'attempt-new', + destination: expect.stringContaining('order_1'), + basketOrder: expect.arrayContaining([ + expect.objectContaining({ + name: 'Test item', + qty: 1, + sum: 12345, + total: 12345, + }), + ]), + }), + }) + ); + + expect(deps.finalizeAttemptWithInvoice).toHaveBeenCalledTimes(1); + expect(deps.finalizeAttemptWithInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + attemptId: 'attempt-new', + orderId: 'order_1', + invoiceId: 'inv_1', + pageUrl: 'https://pay.example/1', + requestId: 'req_1', + }) + ); + + expect(res.attemptId).toBe('attempt-new'); + expect(res.invoiceId).toBe('inv_1'); + expect(res.pageUrl).toBe('https://pay.example/1'); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-cancel-payment-route-f5.test.ts b/frontend/lib/tests/shop/monobank-cancel-payment-route-f5.test.ts new file mode 100644 index 00000000..40d9df78 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-cancel-payment-route-f5.test.ts @@ -0,0 +1,627 @@ +import crypto from 'crypto'; +import { and, eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { + inventoryMoves, + monobankPaymentCancels, + orders, + paymentAttempts, + products, +} from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/auth/admin', () => ({ + requireAdminApi: vi.fn(async () => {}), + AdminApiDisabledError: class AdminApiDisabledError extends Error {}, + AdminUnauthorizedError: class AdminUnauthorizedError extends Error { + code = 'ADMIN_UNAUTHORIZED'; + }, + AdminForbiddenError: class AdminForbiddenError extends Error { + code = 'ADMIN_FORBIDDEN'; + }, +})); + +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: vi.fn(() => null), +})); + +const removeInvoiceMock = vi.fn(); + +vi.mock('@/lib/psp/monobank', () => ({ + removeInvoice: removeInvoiceMock, + PspError: class PspError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } + }, +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +let postRoute: typeof import('@/app/api/shop/admin/orders/[id]/cancel-payment/route').POST; + +const __prevAppOrigin = process.env.APP_ORIGIN; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; + +beforeAll(async () => { + process.env.APP_ORIGIN = 'http://localhost:3000'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; + resetEnvCache(); + + ({ POST: postRoute } = await import( + '@/app/api/shop/admin/orders/[id]/cancel-payment/route' + )); +}); + +afterAll(() => { + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; + else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; + + resetEnvCache(); +}); + +beforeEach(() => { + removeInvoiceMock.mockReset(); + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; + resetEnvCache(); +}); + +async function insertProductWithReservedStock(orderId: string) { + const productId = crypto.randomUUID(); + await db.insert(products).values({ + id: productId, + slug: `f5-${productId}`, + title: 'F5 Product', + imageUrl: 'https://example.test/p.png', + price: toDbMoney(1000), + currency: 'USD', + stock: 9, + } as any); + + await db.insert(inventoryMoves).values({ + moveKey: `reserve:${orderId}:${productId}`, + orderId, + productId, + type: 'reserve', + quantity: 1, + } as any); + + return { productId }; +} + +async function insertOrder(args: { + orderId: string; + paymentStatus: 'pending' | 'requires_payment' | 'paid' | 'failed' | 'refunded'; + status: 'INVENTORY_RESERVED' | 'PAID' | 'CANCELED' | 'INVENTORY_FAILED'; + inventoryStatus: 'reserved' | 'released' | 'none'; + stockRestored?: boolean; + pspChargeId?: string | null; +}) { + await db.insert(orders).values({ + id: args.orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: args.paymentStatus, + status: args.status, + inventoryStatus: args.inventoryStatus, + stockRestored: args.stockRestored ?? false, + pspChargeId: args.pspChargeId ?? null, + idempotencyKey: crypto.randomUUID(), + } as any); +} + +async function insertAttempt(args: { + orderId: string; + status: 'creating' | 'active' | 'succeeded' | 'failed' | 'canceled'; + attemptNumber: number; + invoiceId: string | null; + metadata?: Record; + updatedAt?: Date; +}) { + await db.insert(paymentAttempts).values({ + id: crypto.randomUUID(), + orderId: args.orderId, + provider: 'monobank', + status: args.status, + attemptNumber: args.attemptNumber, + currency: 'UAH', + expectedAmountMinor: 1000, + idempotencyKey: crypto.randomUUID(), + providerPaymentIntentId: args.invoiceId, + metadata: args.metadata ?? {}, + createdAt: args.updatedAt + ? new Date(args.updatedAt.getTime() - 1_000) + : undefined, + updatedAt: args.updatedAt ?? undefined, + } as any); +} + +async function cleanup(orderId: string) { + await db + .delete(monobankPaymentCancels) + .where(eq(monobankPaymentCancels.orderId, orderId)); + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + + const moves = await db + .select({ productId: inventoryMoves.productId }) + .from(inventoryMoves) + .where(eq(inventoryMoves.orderId, orderId)); + + await db.delete(inventoryMoves).where(eq(inventoryMoves.orderId, orderId)); + + for (const move of moves) { + await db.delete(products).where(eq(products.id, move.productId)); + } + + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function waitForCancelStatus( + extRef: string, + status: 'requested' | 'processing' | 'success' | 'failure', + timeoutMs = 3000 +) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const [row] = await db + .select({ status: monobankPaymentCancels.status }) + .from(monobankPaymentCancels) + .where(eq(monobankPaymentCancels.extRef, extRef)) + .limit(1); + + if (row?.status === status) { + return; + } + + await new Promise(resolve => setTimeout(resolve, 25)); + } + + throw new Error(`Timed out waiting for cancel status=${status}`); +} + +function makeReq(orderId: string) { + return new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/cancel-payment`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); +} + +describe.sequential('monobank cancel payment route (F5)', () => { + it( + 'happy path unpaid order: PSP once, order canceled, inventory released', + async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + + await insertOrder({ + orderId, + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + pspChargeId: invoiceId, + }); + const { productId } = await insertProductWithReservedStock(orderId); + + removeInvoiceMock.mockResolvedValue({ + invoiceId, + removed: true, + }); + + try { + const res = await postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + + expect(res.status).toBe(200); + const json: any = await res.json(); + expect(json.success).toBe(true); + expect(json.cancel.extRef).toBe(`mono_cancel:${orderId}`); + expect(json.cancel.status).toBe('success'); + expect(json.cancel.deduped).toBe(false); + + expect(removeInvoiceMock).toHaveBeenCalledTimes(1); + expect(removeInvoiceMock).toHaveBeenCalledWith(invoiceId); + + const [cancelRow] = await db + .select({ + id: monobankPaymentCancels.id, + status: monobankPaymentCancels.status, + extRef: monobankPaymentCancels.extRef, + }) + .from(monobankPaymentCancels) + .where(eq(monobankPaymentCancels.orderId, orderId)) + .limit(1); + + expect(cancelRow?.status).toBe('success'); + expect(cancelRow?.extRef).toBe(`mono_cancel:${orderId}`); + + const [orderRow] = await db + .select({ + status: orders.status, + inventoryStatus: orders.inventoryStatus, + paymentStatus: orders.paymentStatus, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(orderRow?.status).toBe('CANCELED'); + expect(orderRow?.inventoryStatus).toBe('released'); + expect(orderRow?.paymentStatus).toBe('failed'); + expect(orderRow?.stockRestored).toBe(true); + + const [product] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, productId)) + .limit(1); + + expect(product?.stock).toBe(10); + } finally { + await cleanup(orderId); + } + }, + 15000 + ); + + it( + 'idempotency sequential: second call deduped=true, PSP once, one release move', + async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + + await insertOrder({ + orderId, + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + pspChargeId: invoiceId, + }); + await insertProductWithReservedStock(orderId); + + removeInvoiceMock.mockResolvedValue({ + invoiceId, + removed: true, + }); + + try { + const res1 = await postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res1.status).toBe(200); + + const res2 = await postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res2.status).toBe(200); + + const json2: any = await res2.json(); + expect(json2.cancel.deduped).toBe(true); + + expect(removeInvoiceMock).toHaveBeenCalledTimes(1); + + const releaseMoves = await db + .select({ id: inventoryMoves.id }) + .from(inventoryMoves) + .where( + and( + eq(inventoryMoves.orderId, orderId), + eq(inventoryMoves.type, 'release') + ) + ); + expect(releaseMoves).toHaveLength(1); + } finally { + await cleanup(orderId); + } + }, + 15000 + ); + + it('paid guard: 409 CANCEL_NOT_ALLOWED, PSP not called', async () => { + const orderId = crypto.randomUUID(); + + await insertOrder({ + orderId, + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'released', + stockRestored: true, + pspChargeId: `inv_${crypto.randomUUID()}`, + }); + + try { + const res = await postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(409); + + const json: any = await res.json(); + expect(json.code).toBe('CANCEL_NOT_ALLOWED'); + expect(removeInvoiceMock).not.toHaveBeenCalled(); + } finally { + await cleanup(orderId); + } + }); + + it( + 'PSP failure then retry: first 503+failure, second 200 success', + async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + + await insertOrder({ + orderId, + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + pspChargeId: invoiceId, + }); + await insertProductWithReservedStock(orderId); + + removeInvoiceMock + .mockRejectedValueOnce(new Error('psp down')) + .mockResolvedValueOnce({ invoiceId, removed: true }); + + try { + const res1 = await postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res1.status).toBe(503); + const json1: any = await res1.json(); + expect(json1.code).toBe('PSP_UNAVAILABLE'); + + const [rowAfterFail] = await db + .select({ + status: monobankPaymentCancels.status, + }) + .from(monobankPaymentCancels) + .where(eq(monobankPaymentCancels.orderId, orderId)) + .limit(1); + expect(rowAfterFail?.status).toBe('failure'); + + const [orderAfterFail] = await db + .select({ + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(orderAfterFail?.status).toBe('INVENTORY_RESERVED'); + expect(orderAfterFail?.inventoryStatus).toBe('reserved'); + expect(orderAfterFail?.stockRestored).toBe(false); + + const res2 = await postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res2.status).toBe(200); + const json2: any = await res2.json(); + expect(json2.cancel.status).toBe('success'); + expect(json2.cancel.deduped).toBe(false); + + expect(removeInvoiceMock).toHaveBeenCalledTimes(2); + } finally { + await cleanup(orderId); + } + }, + 15000 + ); + + it('service gate disabled: 409 CANCEL_DISABLED, PSP not called', async () => { + const orderId = crypto.randomUUID(); + + await insertOrder({ + orderId, + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + pspChargeId: `inv_${crypto.randomUUID()}`, + }); + await insertProductWithReservedStock(orderId); + + process.env.PAYMENTS_ENABLED = 'false'; + resetEnvCache(); + + try { + const res = await postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(409); + const json: any = await res.json(); + expect(json.code).toBe('CANCEL_DISABLED'); + expect(removeInvoiceMock).not.toHaveBeenCalled(); + } finally { + process.env.PAYMENTS_ENABLED = 'true'; + resetEnvCache(); + await cleanup(orderId); + } + }); + + it('invoice selection prefers succeeded attempt over newer failed attempt', async () => { + const orderId = crypto.randomUUID(); + const goodInvoice = `inv_good_${crypto.randomUUID()}`; + const badInvoice = `inv_bad_${crypto.randomUUID()}`; + const now = Date.now(); + + await insertOrder({ + orderId, + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + pspChargeId: null, + }); + await insertProductWithReservedStock(orderId); + + await insertAttempt({ + orderId, + status: 'succeeded', + attemptNumber: 1, + invoiceId: goodInvoice, + updatedAt: new Date(now - 60_000), + }); + + await insertAttempt({ + orderId, + status: 'failed', + attemptNumber: 2, + invoiceId: badInvoice, + updatedAt: new Date(now), + }); + + removeInvoiceMock.mockResolvedValue({ + invoiceId: goodInvoice, + removed: true, + }); + + try { + const res = await postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(200); + + expect(removeInvoiceMock).toHaveBeenCalledTimes(1); + expect(removeInvoiceMock).toHaveBeenCalledWith(goodInvoice); + } finally { + await cleanup(orderId); + } + }); + + it( + 'concurrency: two parallel POSTs perform single PSP call', + async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + + await insertOrder({ + orderId, + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + pspChargeId: invoiceId, + }); + await insertProductWithReservedStock(orderId); + + removeInvoiceMock.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + return { invoiceId, removed: true }; + }); + + try { + const [res1, res2] = await Promise.all([ + postRoute(makeReq(orderId), { params: Promise.resolve({ id: orderId }) }), + postRoute(makeReq(orderId), { params: Promise.resolve({ id: orderId }) }), + ]); + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + + const json1: any = await res1.json(); + const json2: any = await res2.json(); + + expect(removeInvoiceMock).toHaveBeenCalledTimes(1); + + const dedupedValues = [json1.cancel.deduped, json2.cancel.deduped].sort(); + expect(dedupedValues).toEqual([false, true]); + + const releaseMoves = await db + .select({ id: inventoryMoves.id }) + .from(inventoryMoves) + .where( + and( + eq(inventoryMoves.orderId, orderId), + eq(inventoryMoves.type, 'release') + ) + ); + expect(releaseMoves).toHaveLength(1); + + const [cancelRow] = await db + .select({ status: monobankPaymentCancels.status }) + .from(monobankPaymentCancels) + .where(eq(monobankPaymentCancels.orderId, orderId)) + .limit(1); + expect(cancelRow?.status).toBe('success'); + } finally { + await cleanup(orderId); + } + }, + 20000 + ); + + it( + 'follower in requested state returns 409 CANCEL_IN_PROGRESS while leader is in-flight', + async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + + await insertOrder({ + orderId, + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + pspChargeId: invoiceId, + }); + await insertProductWithReservedStock(orderId); + + removeInvoiceMock.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 2000)); + return { invoiceId, removed: true }; + }); + + const leaderPromise = postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + + try { + await waitForCancelStatus(`mono_cancel:${orderId}`, 'requested'); + + const followerRes = await postRoute(makeReq(orderId), { + params: Promise.resolve({ id: orderId }), + }); + + expect(followerRes.status).toBe(409); + const followerJson: any = await followerRes.json(); + expect(followerJson.code).toBe('CANCEL_IN_PROGRESS'); + + const leaderRes = await leaderPromise; + expect(leaderRes.status).toBe(200); + + expect(removeInvoiceMock).toHaveBeenCalledTimes(1); + } finally { + await cleanup(orderId); + } + }, + 15000 + ); +}); + diff --git a/frontend/lib/tests/shop/monobank-env.test.ts b/frontend/lib/tests/shop/monobank-env.test.ts new file mode 100644 index 00000000..43296cf7 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-env.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +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', + 'MONO_INVOICE_VALIDITY_SECONDS', + 'MONO_TIME_SKEW_TOLERANCE_SEC', + 'MONO_PUBLIC_KEY', + 'MONO_API_BASE', + 'MONO_INVOICE_TIMEOUT_MS', + 'SHOP_BASE_URL', + 'NEXT_PUBLIC_SITE_URL', + 'APP_ORIGIN', +]; + +const previousEnv: Record = {}; + +beforeEach(() => { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + delete process.env[key]; + } + + process.env.DATABASE_URL = 'https://db.example.test'; + resetEnvCache(); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetEnvCache(); +}); + +describe('monobank env config', () => { + it('uses defaults when MONO_* values are missing', () => { + const config = getMonobankConfig(); + + expect(config.webhookMode).toBe('apply'); + expect(config.refundEnabled).toBe(false); + expect(config.invoiceValiditySeconds).toBe(86400); + expect(config.timeSkewToleranceSec).toBe(300); + expect(config.baseUrlSource).toBe('unknown'); + }); + + it('reports baseUrlSource when SHOP_BASE_URL is set', () => { + process.env.SHOP_BASE_URL = 'https://shop.example.test'; + resetEnvCache(); + + const config = getMonobankConfig(); + expect(config.baseUrlSource).toBe('shop_base_url'); + }); + + it('throws when monobank token is missing', () => { + expect(() => requireMonobankToken()).toThrow( + 'MONO_MERCHANT_TOKEN is required' + ); + }); + + it('returns token when MONO_MERCHANT_TOKEN is set', () => { + process.env.MONO_MERCHANT_TOKEN = 'mono_token'; + resetEnvCache(); + + expect(requireMonobankToken()).toBe('mono_token'); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-finalize-fallback.test.ts b/frontend/lib/tests/shop/monobank-finalize-fallback.test.ts new file mode 100644 index 00000000..101de021 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-finalize-fallback.test.ts @@ -0,0 +1,122 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders, paymentAttempts } from '@/db/schema'; +import { PspInvoicePersistError } from '@/lib/services/errors'; +import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; +import { __test__ } from '@/lib/services/orders/monobank'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/services/orders/restock', () => ({ + restockOrder: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/lib/psp/monobank', async () => { + const actual = await vi.importActual('@/lib/psp/monobank'); + return { + ...actual, + cancelMonobankInvoice: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +async function cleanup(orderId: string) { + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +describe.sequential('monobank finalizeAttemptWithInvoice fallback', () => { + it('rejects with PspInvoicePersistError, but persists payment_attempt and cancels invoice (fallback)', async () => { + const orderId = crypto.randomUUID(); + const attemptId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + const pageUrl = `https://pay.example.test/${crypto.randomUUID()}`; + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + pspMetadata: {}, + } as any); + + await db.insert(paymentAttempts).values({ + id: attemptId, + orderId, + provider: 'monobank', + status: 'creating', + attemptNumber: 1, + currency: 'UAH', + expectedAmountMinor: 1000, + idempotencyKey: buildMonobankAttemptIdempotencyKey(orderId, 1), + metadata: {}, + } as any); + + const originalUpdate = (db as any).update.bind(db); + const spy = vi + .spyOn(db as any, 'update') + .mockImplementation((table: any) => { + if (table === orders) { + throw new Error('forced_orders_update_fail'); + } + return originalUpdate(table); + }); + + try { + await expect( + __test__.finalizeAttemptWithInvoice({ + attemptId, + orderId, + invoiceId, + pageUrl, + requestId: 'req_finalize_fallback', + }) + ).rejects.toBeInstanceOf(PspInvoicePersistError); + + const [attempt] = await db + .select({ + providerPaymentIntentId: paymentAttempts.providerPaymentIntentId, + metadata: paymentAttempts.metadata, + status: paymentAttempts.status, + lastErrorCode: paymentAttempts.lastErrorCode, + }) + .from(paymentAttempts) + .where(eq(paymentAttempts.id, attemptId)) + .limit(1); + + expect(attempt?.providerPaymentIntentId).toBe(invoiceId); + const meta = (attempt?.metadata ?? {}) as Record; + expect(meta.invoiceId).toBe(invoiceId); + expect(meta.pageUrl).toBe(pageUrl); + expect(attempt?.status).toBe('failed'); + expect(attempt?.lastErrorCode).toBe('PSP_INVOICE_PERSIST_FAILED'); + + const { cancelMonobankInvoice } = await import('@/lib/psp/monobank'); + expect(cancelMonobankInvoice).toHaveBeenCalledTimes(1); + expect(cancelMonobankInvoice).toHaveBeenCalledWith(invoiceId); + } finally { + spy.mockRestore(); + await cleanup(orderId); + } + }, 15000); +}); diff --git a/frontend/lib/tests/shop/monobank-http-client.test.ts b/frontend/lib/tests/shop/monobank-http-client.test.ts new file mode 100644 index 00000000..f4eba8de --- /dev/null +++ b/frontend/lib/tests/shop/monobank-http-client.test.ts @@ -0,0 +1,125 @@ +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', + 'MONO_API_BASE', + 'MONO_INVOICE_TIMEOUT_MS', +]; + +const previousEnv: Record = {}; +const originalFetch = globalThis.fetch; + +function rememberEnv() { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + } +} + +function restoreEnv() { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +function makeResponse(status: number, body: string) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => body, + }; +} + +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'; + process.env.MONO_INVOICE_TIMEOUT_MS = '25'; + delete process.env.MONO_PUBLIC_KEY; + resetEnvCache(); + vi.resetModules(); +}); + +afterEach(() => { + restoreEnv(); + resetEnvCache(); + vi.restoreAllMocks(); + globalThis.fetch = originalFetch; +}); + +describe('monobank http client error mapping', () => { + it('maps timeout to PSP_TIMEOUT', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(() => new Promise(() => {})); + globalThis.fetch = fetchMock as any; + + const { fetchWebhookPubKey, PspError } = await import('@/lib/psp/monobank'); + + const p = fetchWebhookPubKey().then( + () => null, + e => e + ); + + await vi.advanceTimersByTimeAsync(25); + const error = await p; + + expect(error).toBeInstanceOf(PspError); + const err = error as InstanceType; + expect(err.code).toBe('PSP_TIMEOUT'); + expect(err.safeMeta).toMatchObject({ + endpoint: '/api/merchant/pubkey', + method: 'GET', + timeoutMs: 25, + }); + + vi.useRealTimers(); + }); + + it('maps 401 to PSP_AUTH_FAILED', async () => { + const fetchMock = vi.fn(async () => makeResponse(401, 'unauthorized')); + globalThis.fetch = fetchMock as any; + + const { fetchWebhookPubKey, PspError } = await import('@/lib/psp/monobank'); + + try { + await fetchWebhookPubKey(); + throw new Error('expected auth error'); + } catch (error) { + expect(error).toBeInstanceOf(PspError); + const err = error as InstanceType; + expect(err.code).toBe('PSP_AUTH_FAILED'); + expect(err.safeMeta).toMatchObject({ httpStatus: 401 }); + } + }); + + it('maps 400 to PSP_BAD_REQUEST with monoCode', async () => { + const body = JSON.stringify({ errorCode: 'X', message: 'bad' }); + const fetchMock = vi.fn(async () => makeResponse(400, body)); + globalThis.fetch = fetchMock as any; + + const { fetchWebhookPubKey, PspError } = await import('@/lib/psp/monobank'); + + try { + await fetchWebhookPubKey(); + throw new Error('expected bad request'); + } catch (error) { + expect(error).toBeInstanceOf(PspError); + const err = error as InstanceType; + expect(err.code).toBe('PSP_BAD_REQUEST'); + expect(err.safeMeta).toMatchObject({ + httpStatus: 400, + monoCode: 'X', + monoMessage: 'bad', + }); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-merchant-paym-info.test.ts b/frontend/lib/tests/shop/monobank-merchant-paym-info.test.ts new file mode 100644 index 00000000..5c574b9d --- /dev/null +++ b/frontend/lib/tests/shop/monobank-merchant-paym-info.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildMonoMerchantPaymInfo, + MonoMerchantPaymInfoError, +} from '@/lib/services/orders/monobank/merchant-paym-info'; + +function expectCode(fn: () => unknown, code: string) { + try { + fn(); + throw new Error('expected error'); + } catch (error) { + expect(error).toBeInstanceOf(MonoMerchantPaymInfoError); + const err = error as MonoMerchantPaymInfoError; + expect(err.code).toBe(code); + } +} + +describe('buildMonoMerchantPaymInfo', () => { + it('builds merchantPaymInfo for a valid snapshot', () => { + const result = buildMonoMerchantPaymInfo({ + reference: 'attempt-1', + destination: 'Оплата замовлення 123', + currency: 'UAH', + expectedAmountMinor: 3000, + items: [ + { name: 'Hat', quantity: 1, unitPriceMinor: 1000 }, + { name: 'Shirt', quantity: 2, unitPriceMinor: 1000 }, + ], + }); + + expect(result.reference).toBe('attempt-1'); + expect(result.destination).toBe('Оплата замовлення 123'); + expect(result.basketOrder).toHaveLength(2); + expect(result.basketOrder[0]?.sum).toBe(1000); + expect(result.basketOrder[1]?.sum).toBe(1000); + expect(result.basketOrder[0]?.total).toBe(1000); + expect(result.basketOrder[1]?.total).toBe(2000); + const total = result.basketOrder.reduce((acc, item) => acc + item.total, 0); + + expect(total).toBe(3000); + }); + + it('throws for basket sum mismatch', () => { + expectCode( + () => + buildMonoMerchantPaymInfo({ + reference: 'attempt-1', + destination: 'Оплата замовлення 123', + currency: 'UAH', + expectedAmountMinor: 1500, + items: [{ name: 'Hat', quantity: 1, unitPriceMinor: 1000 }], + }), + 'MONO_BASKET_SUM_MISMATCH' + ); + }); + + it('throws for non-UAH currency', () => { + expectCode( + () => + buildMonoMerchantPaymInfo({ + reference: 'attempt-1', + destination: 'Оплата замовлення 123', + currency: 'USD', + expectedAmountMinor: 1000, + items: [{ name: 'Hat', quantity: 1, unitPriceMinor: 1000 }], + }), + 'MONO_UAH_ONLY' + ); + }); + + it('throws for invalid qty', () => { + expectCode( + () => + buildMonoMerchantPaymInfo({ + reference: 'attempt-1', + destination: 'Оплата замовлення 123', + currency: 'UAH', + expectedAmountMinor: 1000, + items: [{ name: 'Hat', quantity: 0, unitPriceMinor: 1000 }], + }), + 'MONO_INVALID_SNAPSHOT' + ); + }); + + it('throws for invalid unit price', () => { + expectCode( + () => + buildMonoMerchantPaymInfo({ + reference: 'attempt-1', + destination: 'Оплата замовлення 123', + currency: 'UAH', + expectedAmountMinor: 1000, + items: [{ name: 'Hat', quantity: 1, unitPriceMinor: -5 }], + }), + 'MONO_INVALID_SNAPSHOT' + ); + }); + + it('throws for non-integer unit price', () => { + expectCode( + () => + buildMonoMerchantPaymInfo({ + reference: 'attempt-1', + destination: 'Оплата замовлення 123', + currency: 'UAH', + expectedAmountMinor: 1000, + items: [{ name: 'Hat', quantity: 1, unitPriceMinor: 10.5 }], + }), + 'MONO_INVALID_SNAPSHOT' + ); + }); + + it('throws for non-integer expected amount', () => { + expectCode( + () => + buildMonoMerchantPaymInfo({ + reference: 'attempt-1', + destination: 'Оплата замовлення 123', + currency: 'UAH', + expectedAmountMinor: 1000.5, + items: [{ name: 'Hat', quantity: 1, unitPriceMinor: 1000 }], + }), + 'MONO_INVALID_SNAPSHOT' + ); + }); + + it('throws for empty reference', () => { + expectCode( + () => + buildMonoMerchantPaymInfo({ + reference: ' ', + destination: 'Оплата замовлення 123', + currency: 'UAH', + expectedAmountMinor: 1000, + items: [{ name: 'Hat', quantity: 1, unitPriceMinor: 1000 }], + }), + 'MONO_INVALID_SNAPSHOT' + ); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-payments-disabled.test.ts b/frontend/lib/tests/shop/monobank-payments-disabled.test.ts new file mode 100644 index 00000000..389a99b7 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-payments-disabled.test.ts @@ -0,0 +1,179 @@ +import crypto from 'crypto'; +import { eq, sql } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { toDbMoney } from '@/lib/shop/money'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +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'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; + process.env.PAYMENTS_ENABLED = 'false'; + 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(); +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevMonoMerchantToken === undefined) + delete process.env.MONO_MERCHANT_TOKEN; + else process.env.MONO_MERCHANT_TOKEN = __prevMonoMerchantToken; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + + 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(); +}); + +async function createIsolatedProduct(stock: number) { + const [tpl] = await db + .select() + .from(products) + .where(eq(products.isActive as any, true)) + .limit(1); + + if (!tpl) { + throw new Error('No template product found to clone.'); + } + + const productId = crypto.randomUUID(); + const slug = `t-mono-${crypto.randomUUID()}`; + const sku = `t-mono-${crypto.randomUUID()}`; + const now = new Date(); + + await db.insert(products).values({ + ...(tpl as any), + id: productId, + slug, + sku, + title: `Test ${slug}`, + stock, + isActive: true, + createdAt: now, + updatedAt: now, + } as any); + + await db.insert(productPrices).values({ + productId, + currency: 'UAH', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + createdAt: now, + updatedAt: now, + } as any); + + return { productId }; +} + +async function cleanupOrder(orderId: string) { + await db.execute( + sql`delete from inventory_moves where order_id = ${orderId}::uuid` + ); + await db.execute( + sql`delete from order_items where order_id = ${orderId}::uuid` + ); + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +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 postCheckout(idemKey: string, productId: string) { + const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { + POST: (req: NextRequest) => Promise; + }; + + const req = new NextRequest('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-language': 'uk-UA', + 'idempotency-key': idemKey, + 'x-request-id': 'mono-req-disabled', + 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + items: [{ productId, quantity: 1 }], + paymentProvider: 'monobank', + }), + }); + + return mod.POST(req); +} + +describe.sequential('monobank payments disabled', () => { + it('returns 503 PSP_UNAVAILABLE before PSP call', async () => { + const { productId } = await createIsolatedProduct(2); + const idemKey = crypto.randomUUID(); + let orderId: string | null = null; + + try { + const res = await postCheckout(idemKey, productId); + expect(res.status).toBe(503); + const json: any = await res.json(); + expect(json.code).toBe('PSP_UNAVAILABLE'); + + const [row] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idemKey)) + .limit(1); + + expect(row).toBeUndefined(); + orderId = null; + } finally { + if (orderId) { + await cleanupOrder(orderId); + } + await cleanupProduct(productId); + } + }, 20_000); +}); diff --git a/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts b/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts new file mode 100644 index 00000000..733c9384 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts @@ -0,0 +1,273 @@ +import crypto from 'crypto'; +import { eq, sql } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { toDbMoney } from '@/lib/shop/money'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { isUuidV1toV5 } from '@/lib/utils/uuid'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +vi.mock('@/lib/psp/monobank', () => ({ + MONO_CURRENCY: 'UAH', + createMonobankInvoice: vi.fn(async () => { + throw new Error('MONO_TIMEOUT'); + }), + cancelMonobankInvoice: vi.fn(async () => {}), +})); + +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +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; + +beforeAll(() => { + process.env.RATE_LIMIT_DISABLED = '1'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + process.env.SHOP_BASE_URL = 'http://localhost:3000'; + process.env.SHOP_STATUS_TOKEN_SECRET = + 'test_status_token_secret_test_status_token_secret'; + resetEnvCache(); +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; + else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + if (__prevShopBaseUrl === undefined) delete process.env.SHOP_BASE_URL; + else process.env.SHOP_BASE_URL = __prevShopBaseUrl; + if (__prevStatusSecret === undefined) + delete process.env.SHOP_STATUS_TOKEN_SECRET; + else process.env.SHOP_STATUS_TOKEN_SECRET = __prevStatusSecret; + resetEnvCache(); +}); + +function warnCleanup(step: string, err: unknown) { + console.warn('[monobank-psp-unavailable.test] cleanup failed', { + step, + err: err instanceof Error ? { name: err.name, message: err.message } : err, + }); +} + +async function createIsolatedProduct(stock: number) { + const [tpl] = await db + .select() + .from(products) + .where(eq(products.isActive as any, true)) + .limit(1); + + if (!tpl) { + throw new Error('No template product found to clone.'); + } + + const productId = crypto.randomUUID(); + const slug = `t-mono-${crypto.randomUUID()}`; + const sku = `t-mono-${crypto.randomUUID()}`; + const now = new Date(); + + await db.insert(products).values({ + ...(tpl as any), + id: productId, + slug, + sku, + title: `Test ${slug}`, + stock, + isActive: true, + createdAt: now, + updatedAt: now, + } as any); + + await db.insert(productPrices).values({ + productId, + currency: 'UAH', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + createdAt: now, + updatedAt: now, + } as any); + + return { productId }; +} + +async function cleanupOrder(orderId: string) { + if (!isUuidV1toV5(orderId)) + throw new Error(`cleanupOrder: invalid uuid: ${orderId}`); + + await db.execute( + sql`delete from inventory_moves where order_id = ${orderId}::uuid` + ); + await db.execute( + sql`delete from order_items where order_id = ${orderId}::uuid` + ); + await db.execute( + sql`delete from payment_attempts where order_id = ${orderId}::uuid` + ); + await db.execute(sql`delete from orders where id = ${orderId}::uuid`); +} + +async function archiveProduct(productId: string) { + if (!isUuidV1toV5(productId)) + throw new Error(`archiveProduct: invalid uuid: ${productId}`); + const TEST_ARCHIVE_PREFIX = '[TEST-ARCHIVED] '; + await db + .update(products) + .set({ + isActive: false, + stock: 0, + title: sql` + case + when ${products.title} like ${TEST_ARCHIVE_PREFIX + '%'} then ${products.title} + else ${TEST_ARCHIVE_PREFIX} || ${products.title} + end + `, + updatedAt: new Date(), + } as any) + .where(eq(products.id, productId)); +} + +async function postCheckout(idemKey: string, productId: string) { + const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { + POST: (req: NextRequest) => Promise; + }; + + const req = new NextRequest('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-language': 'uk-UA', + 'idempotency-key': idemKey, + 'x-request-id': 'mono-req-1', + 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + items: [{ productId, quantity: 1 }], + paymentProvider: 'monobank', + }), + }); + + return mod.POST(req); +} + +describe.sequential('monobank PSP_UNAVAILABLE invariant', () => { + it('cancel+restock on invoice/create failure', async () => { + const { productId } = await createIsolatedProduct(2); + const idemKey = crypto.randomUUID(); + let orderId: string | null = null; + + try { + const res = await postCheckout(idemKey, productId); + expect(res.status).toBe(503); + const json: any = await res.json(); + expect(json.code).toBe('PSP_UNAVAILABLE'); + + const [row] = await db + .select({ + id: orders.id, + currency: orders.currency, + status: orders.status, + paymentStatus: orders.paymentStatus, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + failureCode: orders.failureCode, + }) + .from(orders) + .where(eq(orders.idempotencyKey, idemKey)) + .limit(1); + + expect(row).toBeTruthy(); + orderId = row!.id; + expect(row!.currency).toBe('UAH'); + expect(row!.status).toBe('CANCELED'); + expect(row!.failureCode).toBe('PSP_UNAVAILABLE'); + expect(row!.paymentStatus).toBe('failed'); + expect(row!.inventoryStatus).toBe('released'); + expect(row!.stockRestored).toBe(true); + + const moves = (await db.execute( + sql`select type from inventory_moves where order_id = ${orderId}::uuid` + )) as unknown as { rows?: Array<{ type: unknown }> }; + + const moveTypes = (moves.rows ?? []).map(r => String(r.type ?? '')); + expect(moveTypes).toContain('reserve'); + expect(moveTypes).toContain('release'); + + const [attempt] = await db + .select({ + status: paymentAttempts.status, + lastErrorCode: paymentAttempts.lastErrorCode, + metadata: paymentAttempts.metadata, + currency: paymentAttempts.currency, + expectedAmountMinor: paymentAttempts.expectedAmountMinor, + finalizedAt: paymentAttempts.finalizedAt, + }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .limit(1); + + expect(attempt).toBeTruthy(); + expect(attempt!.status).toBe('failed'); + expect(attempt!.lastErrorCode).toBe('PSP_UNAVAILABLE'); + expect((attempt!.metadata as Record)?.requestId).toBe( + 'mono-req-1' + ); + expect(attempt!.currency).toBe('UAH'); + expect(attempt!.expectedAmountMinor).toBeGreaterThan(0); + expect(attempt!.finalizedAt).not.toBeNull(); + } finally { + try { + if (!orderId) { + const [row] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idemKey)) + .limit(1); + orderId = row?.id ?? null; + } + + if (orderId) { + await cleanupOrder(orderId); + } + } catch (e) { + warnCleanup('cleanupOrder', e); + } + + try { + await archiveProduct(productId); + } catch (e) { + warnCleanup('archiveProduct', e); + } + } + }, 20_000); +}); diff --git a/frontend/lib/tests/shop/monobank-refund-disabled.test.ts b/frontend/lib/tests/shop/monobank-refund-disabled.test.ts new file mode 100644 index 00000000..9919ae58 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-refund-disabled.test.ts @@ -0,0 +1,107 @@ +import crypto from 'crypto'; +import { eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/auth/admin', () => ({ + requireAdminApi: vi.fn(async () => {}), + AdminApiDisabledError: class AdminApiDisabledError extends Error {}, + AdminUnauthorizedError: class AdminUnauthorizedError extends Error { + code = 'UNAUTHORIZED'; + }, + AdminForbiddenError: class AdminForbiddenError extends Error { + code = 'FORBIDDEN'; + }, +})); + +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: vi.fn(() => null), +})); + +vi.mock('@/lib/services/orders', () => ({ + refundOrder: vi.fn(async () => { + throw new Error('refundOrder should not be called'); + }), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + }; +}); + +const __prevRefundEnabled = process.env.MONO_REFUND_ENABLED; +const __prevAppOrigin = process.env.APP_ORIGIN; +beforeAll(() => { + process.env.MONO_REFUND_ENABLED = 'false'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + resetEnvCache(); +}); + +afterAll(() => { + if (__prevRefundEnabled === undefined) delete process.env.MONO_REFUND_ENABLED; + else process.env.MONO_REFUND_ENABLED = __prevRefundEnabled; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + + resetEnvCache(); +}); + +async function insertOrder(orderId: string) { + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'released', + idempotencyKey: crypto.randomUUID(), + } as any); +} + +async function deleteOrder(orderId: string) { + await db.delete(orders).where(eq(orders.id, orderId)); +} + +describe.sequential('monobank refund disabled guard', () => { + it('returns 409 REFUND_DISABLED for monobank orders', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + + try { + const { POST } = + await import('@/app/api/shop/admin/orders/[id]/refund/route'); + const req = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { + origin: 'http://localhost:3000', + }, + } + ); + + const res = await POST(req, { + params: Promise.resolve({ id: orderId }), + }); + + expect(res.status).toBe(409); + const json: any = await res.json(); + expect(json.code).toBe('REFUND_DISABLED'); + expect(json.message).toBe('Refunds are disabled.'); + } finally { + await deleteOrder(orderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts b/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts new file mode 100644 index 00000000..b0010f64 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts @@ -0,0 +1,514 @@ +import crypto from 'crypto'; +import { and, eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { monobankRefunds, orders, paymentAttempts } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/auth/admin', () => ({ + requireAdminApi: vi.fn(async () => {}), + AdminApiDisabledError: class AdminApiDisabledError extends Error {}, + AdminUnauthorizedError: class AdminUnauthorizedError extends Error { + code = 'ADMIN_UNAUTHORIZED'; + }, + AdminForbiddenError: class AdminForbiddenError extends Error { + code = 'ADMIN_FORBIDDEN'; + }, +})); + +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: vi.fn(() => null), +})); + +vi.mock('@/lib/services/orders', () => ({ + refundOrder: vi.fn(async () => { + throw new Error('refundOrder should not be called for monobank'); + }), +})); + +const cancelInvoicePaymentMock = vi.fn(); + +vi.mock('@/lib/psp/monobank', () => ({ + cancelInvoicePayment: cancelInvoicePaymentMock, + PspError: class PspError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } + }, +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + }; +}); + +const __prevRefundEnabled = process.env.MONO_REFUND_ENABLED; +const __prevAppOrigin = process.env.APP_ORIGIN; + +beforeAll(() => { + process.env.MONO_REFUND_ENABLED = 'true'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + resetEnvCache(); +}); + +afterAll(() => { + if (__prevRefundEnabled === undefined) delete process.env.MONO_REFUND_ENABLED; + else process.env.MONO_REFUND_ENABLED = __prevRefundEnabled; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + + resetEnvCache(); +}); + +beforeEach(() => { + cancelInvoicePaymentMock.mockReset(); +}); + +async function insertOrder(args: { + orderId: string; + orderPspChargeId?: string | null; +}) { + await db.insert(orders).values({ + id: args.orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'released', + pspChargeId: args.orderPspChargeId ?? null, + idempotencyKey: crypto.randomUUID(), + } as any); +} + +async function insertAttempt(args: { + orderId: string; + providerPaymentIntentId?: string | null; + metadata?: Record; + status?: 'creating' | 'active' | 'succeeded' | 'failed' | 'canceled'; + attemptNumber?: number; + updatedAt?: Date; +}) { + await db.insert(paymentAttempts).values({ + id: crypto.randomUUID(), + orderId: args.orderId, + provider: 'monobank', + status: args.status ?? 'succeeded', + attemptNumber: args.attemptNumber ?? 1, + currency: 'UAH', + expectedAmountMinor: 1000, + idempotencyKey: crypto.randomUUID(), + providerPaymentIntentId: args.providerPaymentIntentId ?? null, + metadata: args.metadata ?? {}, + createdAt: args.updatedAt + ? new Date(args.updatedAt.getTime() - 1_000) + : undefined, + updatedAt: args.updatedAt ?? undefined, + } as any); +} + +async function insertOrderAndAttempt(args: { + orderId: string; + providerPaymentIntentId?: string | null; + metadata?: Record; + orderPspChargeId?: string | null; + status?: 'creating' | 'active' | 'succeeded' | 'failed' | 'canceled'; + attemptNumber?: number; + updatedAt?: Date; +}) { + await insertOrder({ + orderId: args.orderId, + orderPspChargeId: args.orderPspChargeId, + }); + await insertAttempt({ + orderId: args.orderId, + providerPaymentIntentId: args.providerPaymentIntentId, + metadata: args.metadata, + status: args.status, + attemptNumber: args.attemptNumber, + updatedAt: args.updatedAt, + }); +} + +async function cleanupOrder(orderId: string) { + await db.delete(monobankRefunds).where(eq(monobankRefunds.orderId, orderId)); + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +describe.sequential('monobank admin refund route (F4)', () => { + it('creates processing refund once and dedupes extRef on retry', async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + await insertOrderAndAttempt({ + orderId, + providerPaymentIntentId: invoiceId, + }); + cancelInvoicePaymentMock.mockResolvedValue({ + invoiceId, + status: 'processing', + }); + + try { + const { POST } = + await import('@/app/api/shop/admin/orders/[id]/refund/route'); + + const req1 = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + + const res1 = await POST(req1, { + params: Promise.resolve({ id: orderId }), + }); + expect(res1.status).toBe(200); + const json1: any = await res1.json(); + expect(json1.success).toBe(true); + expect(json1.order.id).toBe(orderId); + expect(json1.order.paymentStatus).toBe('paid'); + expect(json1.refund.status).toBe('processing'); + expect(json1.refund.extRef).toBe(`mono_refund:${orderId}:full`); + expect(json1.refund.deduped).toBe(false); + + const req2 = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + + const res2 = await POST(req2, { + params: Promise.resolve({ id: orderId }), + }); + expect(res2.status).toBe(200); + const json2: any = await res2.json(); + expect(json2.success).toBe(true); + expect(json2.refund.extRef).toBe(`mono_refund:${orderId}:full`); + expect(json2.refund.status).toBe('processing'); + expect(json2.refund.deduped).toBe(true); + + expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(1); + expect(cancelInvoicePaymentMock).toHaveBeenCalledWith({ + invoiceId, + extRef: `mono_refund:${orderId}:full`, + amountMinor: 1000, + }); + + const rows = await db + .select({ + id: monobankRefunds.id, + status: monobankRefunds.status, + extRef: monobankRefunds.extRef, + }) + .from(monobankRefunds) + .where(eq(monobankRefunds.orderId, orderId)); + + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe('processing'); + expect(rows[0]?.extRef).toBe(`mono_refund:${orderId}:full`); + } finally { + await cleanupOrder(orderId); + } + }, 15000); + + it('treats requested as retryable, then dedupes once processing', async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + await insertOrderAndAttempt({ + orderId, + providerPaymentIntentId: invoiceId, + }); + + await db.insert(monobankRefunds).values({ + id: crypto.randomUUID(), + provider: 'monobank', + orderId, + extRef: `mono_refund:${orderId}:full`, + status: 'requested', + amountMinor: 1000, + currency: 'UAH', + providerCreatedAt: new Date(), + providerModifiedAt: new Date(), + } as any); + + cancelInvoicePaymentMock.mockResolvedValue({ + invoiceId, + status: 'processing', + }); + + try { + const { POST } = + await import('@/app/api/shop/admin/orders/[id]/refund/route'); + + const req1 = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + const res1 = await POST(req1, { + params: Promise.resolve({ id: orderId }), + }); + expect(res1.status).toBe(200); + const json1: any = await res1.json(); + expect(json1.refund.status).toBe('processing'); + expect(json1.refund.deduped).toBe(false); + + const req2 = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + const res2 = await POST(req2, { + params: Promise.resolve({ id: orderId }), + }); + expect(res2.status).toBe(200); + const json2: any = await res2.json(); + expect(json2.refund.status).toBe('processing'); + expect(json2.refund.deduped).toBe(true); + expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(1); + } finally { + await cleanupOrder(orderId); + } + }); + + it('returns PSP_UNAVAILABLE and marks refund failure when PSP call fails', async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + await insertOrderAndAttempt({ + orderId, + providerPaymentIntentId: invoiceId, + }); + + cancelInvoicePaymentMock + .mockRejectedValueOnce(new Error('Monobank cancel failed')) + .mockResolvedValueOnce({ + invoiceId, + status: 'processing', + }); + + try { + const { POST } = + await import('@/app/api/shop/admin/orders/[id]/refund/route'); + + const req = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + + const res = await POST(req, { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(503); + const json: any = await res.json(); + expect(json.code).toBe('PSP_UNAVAILABLE'); + + const [refundRow] = await db + .select({ + status: monobankRefunds.status, + extRef: monobankRefunds.extRef, + }) + .from(monobankRefunds) + .where( + and( + eq(monobankRefunds.orderId, orderId), + eq(monobankRefunds.extRef, `mono_refund:${orderId}:full`) + ) + ) + .limit(1); + + expect(refundRow?.status).toBe('failure'); + + const retryReq = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + + const retryRes = await POST(retryReq, { + params: Promise.resolve({ id: orderId }), + }); + + expect(retryRes.status).toBe(200); + const retryJson: any = await retryRes.json(); + expect(retryJson.success).toBe(true); + expect(retryJson.refund.extRef).toBe(`mono_refund:${orderId}:full`); + expect(retryJson.refund.status).toBe('processing'); + expect(retryJson.refund.deduped).toBe(false); + + const [orderRow] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(orderRow?.paymentStatus).toBe('paid'); + expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(2); + } finally { + await cleanupOrder(orderId); + } + }); + + it('returns 409 REFUND_DISABLED when gate is off and does not call PSP', async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + await insertOrderAndAttempt({ + orderId, + providerPaymentIntentId: invoiceId, + }); + + const prev = process.env.MONO_REFUND_ENABLED; + process.env.MONO_REFUND_ENABLED = 'false'; + resetEnvCache(); + + try { + const { POST } = + await import('@/app/api/shop/admin/orders/[id]/refund/route'); + + const req = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + + const res = await POST(req, { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(409); + const json: any = await res.json(); + expect(json.code).toBe('REFUND_DISABLED'); + expect(cancelInvoicePaymentMock).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete process.env.MONO_REFUND_ENABLED; + else process.env.MONO_REFUND_ENABLED = prev; + resetEnvCache(); + await cleanupOrder(orderId); + } + }); + + it('falls back to metadata.invoiceId when providerPaymentIntentId is absent', async () => { + const orderId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + await insertOrderAndAttempt({ + orderId, + providerPaymentIntentId: null, + metadata: { invoiceId }, + orderPspChargeId: null, + }); + cancelInvoicePaymentMock.mockResolvedValue({ + invoiceId, + status: 'processing', + }); + + try { + const { POST } = + await import('@/app/api/shop/admin/orders/[id]/refund/route'); + + const req = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + + const res = await POST(req, { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(200); + const json: any = await res.json(); + expect(json.success).toBe(true); + expect(json.refund.status).toBe('processing'); + + expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(1); + expect(cancelInvoicePaymentMock).toHaveBeenCalledWith({ + invoiceId, + extRef: `mono_refund:${orderId}:full`, + amountMinor: 1000, + }); + } finally { + await cleanupOrder(orderId); + } + }); + + it('prefers succeeded attempt invoice id over newer failed attempt', async () => { + const orderId = crypto.randomUUID(); + const now = Date.now(); + const goodInvoiceId = `inv_good_${crypto.randomUUID()}`; + const badInvoiceId = `inv_bad_${crypto.randomUUID()}`; + + await insertOrder({ orderId }); + + await insertAttempt({ + orderId, + providerPaymentIntentId: goodInvoiceId, + status: 'succeeded', + attemptNumber: 1, + updatedAt: new Date(now - 60_000), + }); + + await insertAttempt({ + orderId, + providerPaymentIntentId: badInvoiceId, + status: 'failed', + attemptNumber: 2, + updatedAt: new Date(now), + }); + + cancelInvoicePaymentMock.mockResolvedValue({ + invoiceId: goodInvoiceId, + status: 'processing', + }); + + try { + const { POST } = + await import('@/app/api/shop/admin/orders/[id]/refund/route'); + + const req = new NextRequest( + `http://localhost/api/shop/admin/orders/${orderId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + + const res = await POST(req, { + params: Promise.resolve({ id: orderId }), + }); + expect(res.status).toBe(200); + + expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(1); + expect(cancelInvoicePaymentMock).toHaveBeenCalledWith({ + invoiceId: goodInvoiceId, + extRef: `mono_refund:${orderId}:full`, + amountMinor: 1000, + }); + } finally { + await cleanupOrder(orderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-tx2-compensation.test.ts b/frontend/lib/tests/shop/monobank-tx2-compensation.test.ts new file mode 100644 index 00000000..f9770cc3 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-tx2-compensation.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { orders, paymentAttempts } from '@/db/schema'; +import { cancelMonobankInvoice } from '@/lib/psp/monobank'; +import { PspInvoicePersistError } from '@/lib/services/errors'; +import { restockOrder } from '@/lib/services/orders/restock'; + +const updateCalls: Array<{ table: unknown; values: Record }> = + []; + +const selectMock = vi.fn(() => ({ + from: () => ({ + where: () => ({ + limit: async () => [{ metadata: {} }], + }), + }), +})); + +function makeUpdateQuery(rows: T[]) { + const p = Promise.resolve(rows); + return Object.assign(p, { returning: async () => rows }); +} + +const updateMock = vi.fn((table: unknown) => ({ + set: (values: Record) => { + updateCalls.push({ table, values }); + + if (table === orders && 'pspChargeId' in values) { + throw new Error('UPDATE_FAIL'); + } + + const rows = + table === paymentAttempts + ? [{ id: 'attempt-1' }] + : table === orders && (values as any).status === 'CANCELED' + ? [{ id: 'order-1' }] + : []; + + return { + where: () => makeUpdateQuery(rows), + }; + }, +})); + +const transactionMock = vi.fn(async (fn: any) => { + return await fn({ select: selectMock, update: updateMock }); +}); + +const dbMock = { + transaction: transactionMock, + select: selectMock, + update: updateMock, +}; + +vi.mock('@/db', () => ({ + db: dbMock, +})); + +vi.mock('@/lib/logging', () => ({ + logWarn: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), +})); + +vi.mock('@/lib/psp/monobank', () => ({ + MONO_CURRENCY: 'UAH', + createMonobankInvoice: vi.fn(async () => ({ + invoiceId: 'inv-mock', + pageUrl: 'https://pay.test/inv-mock', + raw: {}, + })), + cancelMonobankInvoice: vi.fn(async () => {}), +})); + +vi.mock('@/lib/services/orders/restock', () => ({ + restockOrder: vi.fn(async () => {}), +})); + +beforeEach(() => { + updateCalls.length = 0; + vi.clearAllMocks(); +}); + +describe('finalizeAttemptWithInvoice compensation', () => { + it('cancels invoice + order + restocks when Tx#2 persistence fails', async () => { + const { __test__ } = await import('@/lib/services/orders/monobank'); + + await expect( + __test__.finalizeAttemptWithInvoice({ + attemptId: 'attempt-1', + orderId: 'order-1', + invoiceId: 'inv-1', + pageUrl: 'https://pay.test/inv-1', + requestId: 'req-1', + }) + ).rejects.toBeInstanceOf(PspInvoicePersistError); + + expect(cancelMonobankInvoice).toHaveBeenCalledTimes(1); + expect(cancelMonobankInvoice).toHaveBeenCalledWith('inv-1'); + + expect(restockOrder).toHaveBeenCalledTimes(1); + expect(restockOrder).toHaveBeenCalledWith('order-1', { + reason: 'canceled', + workerId: 'monobank', + }); + + const canceledOrderUpdate = updateCalls.find( + c => c.table === orders && (c.values as any).status === 'CANCELED' + ); + expect(canceledOrderUpdate).toBeTruthy(); + + const failedAttemptUpdate = updateCalls.find( + c => c.table === paymentAttempts && (c.values as any).status === 'failed' + ); + expect(failedAttemptUpdate).toBeTruthy(); + expect((failedAttemptUpdate?.values as any).lastErrorCode).toBe( + 'PSP_INVOICE_PERSIST_FAILED' + ); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts b/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts new file mode 100644 index 00000000..587802d9 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts @@ -0,0 +1,426 @@ +import crypto from 'node:crypto'; + +import { sql } from 'drizzle-orm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; + +function readRows(res: unknown): T[] { + if (Array.isArray(res)) return res as T[]; + const anyRes = res as any; + if (Array.isArray(anyRes?.rows)) return anyRes.rows as T[]; + return []; +} + +const enumLabelCache = new Map(); + +async function getEnumLabelsByColumn( + tableName: string, + columnName: string +): Promise { + const cacheKey = `${tableName}.${columnName}`; + const cached = enumLabelCache.get(cacheKey); + if (cached) return cached; + + const typeRes = await db.execute(sql` + select udt_name as type_name + from information_schema.columns + where table_schema = 'public' + and table_name = ${tableName} + and column_name = ${columnName} + limit 1 + `); + const typeRow = readRows<{ type_name?: string }>(typeRes)[0]; + const typeName = typeRow?.type_name; + if (!typeName) throw new Error(`Cannot resolve enum type for ${cacheKey}`); + + const labelsRes = await db.execute(sql` + select e.enumlabel as label + from pg_type t + join pg_enum e on e.enumtypid = t.oid + where t.typname = ${typeName} + order by e.enumsortorder + `); + const labels = readRows<{ label?: string }>(labelsRes) + .map(r => r.label) + .filter((x): x is string => typeof x === 'string' && x.length > 0); + + if (labels.length === 0) { + throw new Error(`Enum ${typeName} has no labels (for ${cacheKey})`); + } + + enumLabelCache.set(cacheKey, labels); + return labels; +} + +async function pickEnumLabelByColumn( + tableName: string, + columnName: string, + preferred?: string[] +): Promise { + const labels = await getEnumLabelsByColumn(tableName, columnName); + if (preferred?.length) { + const found = preferred.find(p => labels.includes(p)); + if (found) return found; + } + return labels[0]!; +} + +vi.mock('@/lib/services/orders/payment-state', () => { + return { + guardedPaymentStatusUpdate: vi.fn(), + }; +}); + +vi.mock('@/lib/logging', () => { + return { + logInfo: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), + }; +}); + +import { logError } from '@/lib/logging'; +import { applyMonoWebhookEvent } from '@/lib/services/orders/monobank-webhook'; +import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state'; + +function sha256Hex(buf: Buffer): string { + return crypto.createHash('sha256').update(buf).digest('hex'); +} + +function uuid(): string { + return crypto.randomUUID(); +} + +async function insertOrder(args: { + orderId: string; + currency: 'UAH' | 'USD'; + totalAmountMinor: number; + paymentProvider: 'monobank' | 'stripe'; + paymentStatus: string; + status?: string; +}) { + const idemKey = `test_${args.orderId}`; + const statusLabel = + args.status ?? + (await pickEnumLabelByColumn('orders', 'status', [ + 'RESERVING', + 'CREATED', + 'NEW', + 'PENDING', + ])); + await db.execute(sql` + insert into orders ( + id, + user_id, + idempotency_key, + currency, + total_amount, + total_amount_minor, + payment_provider, + payment_status, + status, + psp_metadata, + created_at, + updated_at + ) + values ( + ${args.orderId}::uuid, + null, + ${idemKey}, + ${args.currency}, + (${args.totalAmountMinor}::numeric / 100), + ${args.totalAmountMinor}, + ${args.paymentProvider}, + ${args.paymentStatus}, + ${statusLabel}, + '{}'::jsonb, + now(), + now() + ) + `); +} + +async function insertAttempt(args: { + attemptId: string; + orderId: string; + status?: string; + expectedAmountMinor: number; + invoiceId: string; + providerModifiedAt: Date | null; +}) { + const attemptStatus = + args.status ?? + (await pickEnumLabelByColumn('payment_attempts', 'status', [ + 'pending', + 'created', + 'requires_action', + ])); + + const attemptNumberRes = await db.execute(sql` + select coalesce(max(attempt_number), 0)::int + 1 as n + from payment_attempts + where order_id = ${args.orderId}::uuid + `); + const attemptNumber = readRows<{ n?: number }>(attemptNumberRes)[0]?.n ?? 1; + const idempotencyKey = `test:${args.attemptId}`; + await db.execute(sql` + insert into payment_attempts ( + id, + order_id, + provider, + attempt_number, + status, + idempotency_key, + expected_amount_minor, + provider_payment_intent_id, + provider_modified_at, + created_at, + updated_at + ) + values ( + ${args.attemptId}::uuid, + ${args.orderId}::uuid, + 'monobank', + ${attemptNumber}, + ${attemptStatus}, + ${idempotencyKey}, + ${args.expectedAmountMinor}, + ${args.invoiceId}, + ${args.providerModifiedAt ?? null}, + now(), + now() + ) + `); +} + +async function fetchEventByRawSha256(rawSha256: string) { + const res = (await db.execute(sql` + select + id, + invoice_id, + status, + applied_result, + applied_error_code, + applied_error_message, + attempt_id, + order_id, + raw_sha256 + from monobank_events + where raw_sha256 = ${rawSha256} + limit 1 + `)) as unknown as { rows?: any[] }; + + return res.rows?.[0] ?? null; +} + +async function cleanup(args: { + orderId: string; + attemptId: string; + rawSha256: string; +}) { + await db.execute( + sql`delete from monobank_events where raw_sha256 = ${args.rawSha256}` + ); + await db.execute( + sql`delete from payment_attempts where id = ${args.attemptId}::uuid` + ); + await db.execute(sql`delete from orders where id = ${args.orderId}::uuid`); +} + +describe('monobank-webhook apply outcomes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('P1#2: amount mismatch -> persistEventOutcome uses applied_with_issue even when transition is blocked', async () => { + ( + guardedPaymentStatusUpdate as unknown as ReturnType + ).mockResolvedValue({ + applied: false, + currentProvider: 'monobank', + from: 'pending', + reason: 'blocked_for_test', + }); + + const orderId = uuid(); + const attemptId = uuid(); + const invoiceId = 'inv_' + uuid().replace(/-/g, '').slice(0, 24); + + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 100, + paymentProvider: 'monobank', + paymentStatus: 'pending', + }); + + await insertAttempt({ + attemptId, + orderId, + status: 'pending', + expectedAmountMinor: 100, + invoiceId, + providerModifiedAt: null, + }); + + const payload = { + invoiceId, + status: 'success', + amount: 101, + ccy: 980, + reference: attemptId, + }; + + const rawBody = JSON.stringify(payload); + const rawSha256 = sha256Hex(Buffer.from(rawBody, 'utf8')); + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload as any, + requestId: 'test_amount_mismatch', + mode: 'apply', + rawSha256, + eventKey: rawSha256, + }); + + expect(res.appliedResult).toBe('applied_with_issue'); + + const ev = await fetchEventByRawSha256(rawSha256); + expect(ev).not.toBeNull(); + expect(ev.applied_result).toBe('applied_with_issue'); + expect(ev.applied_error_code).toBe('AMOUNT_MISMATCH'); + expect(ev.attempt_id).toBe(attemptId); + expect(ev.order_id).toBe(orderId); + } finally { + await cleanup({ orderId, attemptId, rawSha256 }); + } + }); + + it('P1#2: provider_modified_at out-of-order -> applied_noop + OUT_OF_ORDER', async () => { + const orderId = uuid(); + const attemptId = uuid(); + const invoiceId = 'inv_' + uuid().replace(/-/g, '').slice(0, 24); + + const attemptModifiedAt = new Date(); + const payloadModifiedAt = new Date(attemptModifiedAt.getTime() - 60_000); + + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 100, + paymentProvider: 'monobank', + paymentStatus: 'pending', + }); + + await insertAttempt({ + attemptId, + orderId, + status: 'pending', + expectedAmountMinor: 100, + invoiceId, + providerModifiedAt: attemptModifiedAt, + }); + + const payload = { + invoiceId, + status: 'success', + amount: 100, + ccy: 980, + reference: attemptId, + modifiedAt: payloadModifiedAt.toISOString(), + }; + + const rawBody = JSON.stringify(payload); + const rawSha256 = sha256Hex(Buffer.from(rawBody, 'utf8')); + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload as any, + requestId: 'test_out_of_order', + mode: 'apply', + rawSha256, + eventKey: rawSha256, + }); + + expect(res.appliedResult).toBe('applied_noop'); + + const ev = await fetchEventByRawSha256(rawSha256); + expect(ev).not.toBeNull(); + expect(ev.applied_result).toBe('applied_noop'); + expect(ev.applied_error_code).toBe('OUT_OF_ORDER'); + expect(ev.attempt_id).toBe(attemptId); + expect(ev.order_id).toBe(orderId); + } finally { + await cleanup({ orderId, attemptId, rawSha256 }); + } + }); + + it('P1#3: unknown status -> applied_noop + UNKNOWN_STATUS + operational log', async () => { + const orderId = uuid(); + const attemptId = uuid(); + const invoiceId = 'inv_' + uuid().replace(/-/g, '').slice(0, 24); + + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 100, + paymentProvider: 'monobank', + paymentStatus: 'pending', + }); + + await insertAttempt({ + attemptId, + orderId, + status: 'pending', + expectedAmountMinor: 100, + invoiceId, + providerModifiedAt: null, + }); + + const payload = { + invoiceId, + status: 'totally_new_status', + amount: 100, + ccy: 980, + reference: attemptId, + }; + + const rawBody = JSON.stringify(payload); + const rawSha256 = sha256Hex(Buffer.from(rawBody, 'utf8')); + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload as any, + requestId: 'test_unknown_status', + mode: 'apply', + rawSha256, + eventKey: rawSha256, + }); + + expect(res.appliedResult).toBe('applied_noop'); + + const ev = await fetchEventByRawSha256(rawSha256); + expect(ev).not.toBeNull(); + expect(ev.applied_result).toBe('applied_noop'); + expect(ev.applied_error_code).toBe('UNKNOWN_STATUS'); + + expect(logError).toHaveBeenCalled(); + const calls = (logError as any).mock.calls as any[][]; + const found = calls.find(c => c?.[0] === 'MONO_WEBHOOK_UNKNOWN_STATUS'); + expect(found).toBeTruthy(); + + const meta = found?.[2]; + expect(meta?.eventId).toBeTruthy(); + expect(meta?.status).toBe('totally_new_status'); + expect(meta?.invoiceId).toBe(invoiceId); + expect(meta?.orderId).toBe(orderId); + expect(meta?.attemptId).toBe(attemptId); + } finally { + await cleanup({ orderId, attemptId, rawSha256 }); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts new file mode 100644 index 00000000..72417955 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts @@ -0,0 +1,423 @@ +import crypto from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; +import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; +import { applyMonoWebhookEvent } from '@/lib/services/orders/monobank-webhook'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/services/orders/restock', () => ({ + restockOrder: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const sha256HexUtf8 = (s: string) => + crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex'); + +async function insertOrderAndAttempt(args: { + invoiceId: string; + amountMinor: number; +}) { + const orderId = crypto.randomUUID(); + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: args.amountMinor, + totalAmount: toDbMoney(args.amountMinor), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + } as any); + + const attemptId = crypto.randomUUID(); + await db.insert(paymentAttempts).values({ + id: attemptId, + orderId, + provider: 'monobank', + status: 'active', + attemptNumber: 1, + currency: 'UAH', + expectedAmountMinor: args.amountMinor, + idempotencyKey: buildMonobankAttemptIdempotencyKey(orderId, 1), + providerPaymentIntentId: args.invoiceId, + } as any); + + return { orderId, attemptId }; +} + +async function cleanup(orderId: string, invoiceId: string) { + await db + .delete(monobankEvents) + .where(eq(monobankEvents.invoiceId, invoiceId)); + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +describe.sequential('monobank webhook apply (persist-first)', () => { + it('out-of-order: expired -> success becomes needs_review (not paid)', async () => { + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + }); + const expiredBody = JSON.stringify({ + invoiceId, + status: 'expired', + amount: 1000, + ccy: 980, + }); + const successBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + }); + + try { + await applyMonoWebhookEvent({ + rawBody: expiredBody, + rawSha256: sha256HexUtf8(expiredBody), + requestId: 'req_ooo_1', + mode: 'apply', + }); + + const second = await applyMonoWebhookEvent({ + rawBody: successBody, + rawSha256: sha256HexUtf8(successBody), + requestId: 'req_ooo_2', + mode: 'apply', + }); + + expect(second.appliedResult).toBe('applied_with_issue'); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('needs_review'); + } finally { + await cleanup(orderId, invoiceId); + } + }, 15000); + it('dedupes identical events and applies once', async () => { + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + }); + + const rawBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + }); + + try { + const first = await applyMonoWebhookEvent({ + rawBody, + rawSha256: sha256HexUtf8(rawBody), + requestId: 'req_dedupe_1', + mode: 'apply', + }); + const second = await applyMonoWebhookEvent({ + rawBody, + rawSha256: sha256HexUtf8(rawBody), + requestId: 'req_dedupe_2', + mode: 'apply', + }); + + expect(first.deduped).toBe(false); + expect(second.deduped).toBe(true); + + const events = await db + .select({ id: monobankEvents.id }) + .from(monobankEvents) + .where(eq(monobankEvents.invoiceId, invoiceId)); + expect(events.length).toBe(1); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('paid'); + } finally { + await cleanup(orderId, invoiceId); + } + }); + + it('claim/lease allows only one apply when called concurrently', async () => { + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + }); + + const rawBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + }); + + try { + const [first, second] = await Promise.all([ + applyMonoWebhookEvent({ + rawBody, + rawSha256: sha256HexUtf8(rawBody), + requestId: 'req_claim_1', + mode: 'apply', + }), + applyMonoWebhookEvent({ + rawBody, + rawSha256: sha256HexUtf8(rawBody), + requestId: 'req_claim_2', + mode: 'apply', + }), + ]); + + const appliedCount = [first, second].filter( + res => res.appliedResult === 'applied' + ).length; + expect(appliedCount).toBe(1); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('paid'); + + const [event] = await db + .select({ + claimedAt: monobankEvents.claimedAt, + claimExpiresAt: monobankEvents.claimExpiresAt, + claimedBy: monobankEvents.claimedBy, + appliedAt: monobankEvents.appliedAt, + }) + .from(monobankEvents) + .where(eq(monobankEvents.invoiceId, invoiceId)) + .limit(1); + expect(event?.claimedAt).toBeTruthy(); + expect(event?.claimExpiresAt).toBeTruthy(); + expect(event?.claimedBy).toBeTruthy(); + expect(event?.appliedAt).toBeTruthy(); + } finally { + await cleanup(orderId, invoiceId); + } + }); + + it('paid does not block: later expired event is applied_with_issue (transition blocked)', async () => { + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + }); + + const paidBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + }); + const failedBody = JSON.stringify({ + invoiceId, + status: 'expired', + amount: 1000, + ccy: 980, + }); + + try { + await applyMonoWebhookEvent({ + rawBody: paidBody, + rawSha256: sha256HexUtf8(paidBody), + requestId: 'req_paid', + mode: 'apply', + }); + + const second = await applyMonoWebhookEvent({ + rawBody: failedBody, + rawSha256: sha256HexUtf8(failedBody), + requestId: 'req_failed', + mode: 'apply', + }); + + expect(second.appliedResult).toBe('applied_with_issue'); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('paid'); + + const [event] = await db + .select({ + appliedResult: monobankEvents.appliedResult, + appliedErrorCode: monobankEvents.appliedErrorCode, + }) + .from(monobankEvents) + .where( + and( + eq(monobankEvents.invoiceId, invoiceId), + eq(monobankEvents.status, 'expired') + ) + ) + .limit(1); + expect(event?.appliedResult).toBe('applied_with_issue'); + expect(event?.appliedErrorCode).toBe('PAYMENT_STATE_BLOCKED'); + } finally { + await cleanup(orderId, invoiceId); + } + }); + + it('providerModifiedAt ordering ignores older success events', async () => { + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + }); + + const now = Date.now(); + const paidBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + modifiedDate: now, + }); + const olderSuccessBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + modifiedDate: now - 60_000, + }); + + try { + await applyMonoWebhookEvent({ + rawBody: paidBody, + rawSha256: sha256HexUtf8(paidBody), + requestId: 'req_paid_ordering', + mode: 'apply', + }); + + const second = await applyMonoWebhookEvent({ + rawBody: olderSuccessBody, + rawSha256: sha256HexUtf8(olderSuccessBody), + requestId: 'req_old_success', + mode: 'apply', + }); + + expect(second.appliedResult).toBe('applied_noop'); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('paid'); + + const [event] = await db + .select({ + appliedResult: monobankEvents.appliedResult, + appliedErrorCode: monobankEvents.appliedErrorCode, + }) + .from(monobankEvents) + .where( + and( + eq(monobankEvents.invoiceId, invoiceId), + eq(monobankEvents.appliedResult, 'applied_noop') + ) + ) + .limit(1); + expect(event?.appliedErrorCode).toBe('OUT_OF_ORDER'); + } finally { + await cleanup(orderId, invoiceId); + } + }); + + it('mismatch marks applied_with_issue and fails the attempt', async () => { + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + }); + + const mismatchBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 900, + ccy: 980, + }); + + try { + const res = await applyMonoWebhookEvent({ + rawBody: mismatchBody, + requestId: 'req_mismatch', + rawSha256: sha256HexUtf8(mismatchBody), + mode: 'apply', + }); + + expect(res.appliedResult).toBe('applied_with_issue'); + + const [attempt] = await db + .select({ + status: paymentAttempts.status, + lastErrorCode: paymentAttempts.lastErrorCode, + }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .limit(1); + expect(attempt?.status).toBe('failed'); + expect(attempt?.lastErrorCode).toBe('AMOUNT_MISMATCH'); + + const [order] = await db + .select({ + paymentStatus: orders.paymentStatus, + failureCode: orders.failureCode, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('needs_review'); + expect(order?.failureCode).toBe('MONO_AMOUNT_MISMATCH'); + + const [event] = await db + .select({ + appliedResult: monobankEvents.appliedResult, + appliedErrorCode: monobankEvents.appliedErrorCode, + }) + .from(monobankEvents) + .where(eq(monobankEvents.invoiceId, invoiceId)) + .limit(1); + expect(event?.appliedResult).toBe('applied_with_issue'); + expect(event?.appliedErrorCode).toBe('AMOUNT_MISMATCH'); + + const { restockOrder } = await import('@/lib/services/orders/restock'); + expect(restockOrder).not.toHaveBeenCalled(); + } finally { + await cleanup(orderId, invoiceId); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-crypto.test.ts b/frontend/lib/tests/shop/monobank-webhook-crypto.test.ts new file mode 100644 index 00000000..9380116f --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-crypto.test.ts @@ -0,0 +1,170 @@ +import crypto from 'node:crypto'; + +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', + 'MONO_API_BASE', + 'MONO_INVOICE_TIMEOUT_MS', +]; + +const previousEnv: Record = {}; +const originalFetch = globalThis.fetch; + +function rememberEnv() { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + } +} + +function restoreEnv() { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +function makeResponse(body: string) { + return { + ok: true, + status: 200, + text: async () => body, + }; +} + +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'; + process.env.MONO_INVOICE_TIMEOUT_MS = '5000'; + delete process.env.MONO_PUBLIC_KEY; + resetEnvCache(); + vi.resetModules(); +}); + +afterEach(() => { + restoreEnv(); + resetEnvCache(); + vi.restoreAllMocks(); + globalThis.fetch = originalFetch; +}); + +describe('monobank webhook crypto', () => { + it('verifies a valid signature', async () => { + const { verifyWebhookSignature } = await import('@/lib/psp/monobank'); + + const body = Buffer.from('{"invoiceId":"inv_1","status":"success"}'); + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + const signature = crypto + .sign('sha256', body, privateKey) + .toString('base64'); + const publicKeyPem = publicKey.export({ + type: 'spki', + format: 'pem', + }) as string; + + expect( + verifyWebhookSignature(body, signature, Buffer.from(publicKeyPem)) + ).toBe(true); + }); + + it('rejects when payload changes', async () => { + const { verifyWebhookSignature } = await import('@/lib/psp/monobank'); + + const body = Buffer.from('{"invoiceId":"inv_2","status":"success"}'); + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + const signature = crypto + .sign('sha256', body, privateKey) + .toString('base64'); + const publicKeyPem = publicKey.export({ + type: 'spki', + format: 'pem', + }) as string; + + const tampered = Buffer.from(body); + tampered[0] = tampered[0] ^ 0xff; + + expect( + verifyWebhookSignature(tampered, signature, Buffer.from(publicKeyPem)) + ).toBe(false); + }); + + it('refreshes pubkey once when cached key fails', async () => { + const body = Buffer.from('{"invoiceId":"inv_3","status":"success"}'); + const { publicKey: wrongPub } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + const { publicKey: rightPub, privateKey: rightPriv } = + crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + + const wrongPem = wrongPub.export({ type: 'spki', format: 'pem' }) as string; + const rightPem = rightPub.export({ type: 'spki', format: 'pem' }) as string; + const signature = crypto.sign('sha256', body, rightPriv).toString('base64'); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeResponse(JSON.stringify({ key: wrongPem }))) + .mockResolvedValueOnce(makeResponse(JSON.stringify({ key: rightPem }))); + globalThis.fetch = fetchMock as any; + + const { verifyWebhookSignatureWithRefresh } = + await import('@/lib/psp/monobank'); + + const ok = await verifyWebhookSignatureWithRefresh({ + rawBodyBytes: body, + signature, + }); + + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('returns false when refresh still fails', async () => { + const body = Buffer.from('{"invoiceId":"inv_4","status":"success"}'); + const { publicKey: fetchedPub } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + const { privateKey: signingPriv } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + const wrongPem = fetchedPub.export({ + type: 'spki', + format: 'pem', + }) as string; + const signature = crypto + .sign('sha256', body, signingPriv) + .toString('base64'); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeResponse(JSON.stringify({ key: wrongPem }))) + .mockResolvedValueOnce(makeResponse(JSON.stringify({ key: wrongPem }))); + globalThis.fetch = fetchMock as any; + + const { verifyWebhookSignatureWithRefresh } = + await import('@/lib/psp/monobank'); + + const ok = await verifyWebhookSignatureWithRefresh({ + rawBodyBytes: body, + signature, + }); + + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-mode.test.ts b/frontend/lib/tests/shop/monobank-webhook-mode.test.ts new file mode 100644 index 00000000..57cfd363 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-mode.test.ts @@ -0,0 +1,224 @@ +import crypto from 'crypto'; +import { eq, or } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/psp/monobank', () => ({ + verifyWebhookSignatureWithRefresh: vi.fn(async () => true), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +const ENV_KEYS = ['MONO_WEBHOOK_MODE']; +const previousEnv: Record = {}; + +beforeEach(() => { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + delete process.env[key]; + } + resetEnvCache(); +}); + +afterEach(async () => { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetEnvCache(); +}); + +async function insertOrderAndAttempt(invoiceId: string) { + const orderId = crypto.randomUUID(); + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + } as any); + + const attemptId = crypto.randomUUID(); + await db.insert(paymentAttempts).values({ + id: attemptId, + orderId, + provider: 'monobank', + status: 'active', + attemptNumber: 1, + currency: 'UAH', + expectedAmountMinor: 1000, + idempotencyKey: buildMonobankAttemptIdempotencyKey(orderId, 1), + providerPaymentIntentId: invoiceId, + } as any); + + return { orderId, attemptId }; +} + +async function cleanup(orderId: string, invoiceId: string) { + await db + .delete(monobankEvents) + .where( + or( + eq(monobankEvents.orderId, orderId), + eq(monobankEvents.invoiceId, invoiceId) + ) + ); + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function postWebhook(payload: Record) { + const { POST } = await import('@/app/api/shop/webhooks/monobank/route'); + const req = new NextRequest('http://localhost/api/shop/webhooks/monobank', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-sign': 'test-signature', + 'x-request-id': 'mono-webhook-test', + }, + body: JSON.stringify(payload), + }); + return POST(req); +} + +describe.sequential('monobank webhook mode handling', () => { + it('drops events without applying or storing', async () => { + process.env.MONO_WEBHOOK_MODE = 'drop'; + resetEnvCache(); + + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt(invoiceId); + + try { + const res = await postWebhook({ invoiceId, status: 'success' }); + expect(res.status).toBe(200); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('pending'); + + const [attempt] = await db + .select({ status: paymentAttempts.status }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .limit(1); + expect(attempt?.status).toBe('active'); + + const [event] = await db + .select({ + invoiceId: monobankEvents.invoiceId, + appliedResult: monobankEvents.appliedResult, + }) + .from(monobankEvents) + .where(eq(monobankEvents.invoiceId, invoiceId)) + .limit(1); + expect(event?.invoiceId).toBe(invoiceId); + expect(event?.appliedResult).toBe('dropped'); + } finally { + await cleanup(orderId, invoiceId); + } + }); + + it('stores events without applying updates', async () => { + process.env.MONO_WEBHOOK_MODE = 'store'; + resetEnvCache(); + + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt(invoiceId); + const payload = { invoiceId, status: 'success', amount: 1000, ccy: 980 }; + const rawHash = crypto + .createHash('sha256') + .update(JSON.stringify(payload)) + .digest('hex'); + + try { + const res = await postWebhook(payload); + expect(res.status).toBe(200); + + const [event] = await db + .select({ + rawSha256: monobankEvents.rawSha256, + invoiceId: monobankEvents.invoiceId, + appliedResult: monobankEvents.appliedResult, + }) + .from(monobankEvents) + .where(eq(monobankEvents.invoiceId, invoiceId)) + .limit(1); + + expect(event?.invoiceId).toBe(invoiceId); + expect(event?.rawSha256).toBe(rawHash); + expect(event?.appliedResult).toBe('stored'); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('pending'); + + const [attempt] = await db + .select({ status: paymentAttempts.status }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .limit(1); + expect(attempt?.status).toBe('active'); + } finally { + await cleanup(orderId, invoiceId); + } + }); + + it('applies updates when mode=apply', async () => { + process.env.MONO_WEBHOOK_MODE = 'apply'; + resetEnvCache(); + + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt(invoiceId); + + try { + const res = await postWebhook({ invoiceId, status: 'success' }); + expect(res.status).toBe(200); + + const [order] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('paid'); + expect(order?.status).toBe('PAID'); + + const [attempt] = await db + .select({ status: paymentAttempts.status }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .limit(1); + expect(attempt?.status).toBe('succeeded'); + } finally { + await cleanup(orderId, invoiceId); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-paid-reversal.test.ts b/frontend/lib/tests/shop/monobank-webhook-paid-reversal.test.ts new file mode 100644 index 00000000..85db5f98 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-paid-reversal.test.ts @@ -0,0 +1,151 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; +import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/services/orders/restock', () => ({ + restockOrder: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/lib/services/orders/payment-state', () => ({ + guardedPaymentStatusUpdate: vi.fn().mockResolvedValue({ + applied: true, + currentProvider: 'monobank', + from: 'paid', + reason: null, + }), +})); + +vi.mock('@/lib/logging', () => ({ + logError: vi.fn(), + logInfo: vi.fn(), +})); + +async function cleanup( + orderId: string, + attemptId: string, + eventId: string | null +) { + if (eventId) { + await db.delete(monobankEvents).where(eq(monobankEvents.id, eventId)); + } + await db.delete(paymentAttempts).where(eq(paymentAttempts.id, attemptId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +describe.sequential( + 'monobank webhook: paid must not block reversed/failure/expired', + () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('paid + reversed reaches reversal handler (attempt canceled, restock called, status transition invoked)', async () => { + const { applyMonoWebhookEvent } = + await import('@/lib/services/orders/monobank-webhook'); + const { restockOrder } = await import('@/lib/services/orders/restock'); + const { guardedPaymentStatusUpdate } = + await import('@/lib/services/orders/payment-state'); + + const orderId = crypto.randomUUID(); + const attemptId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + pspMetadata: {}, + pspChargeId: invoiceId, + } as any); + + await db.insert(paymentAttempts).values({ + id: attemptId, + orderId, + provider: 'monobank', + status: 'succeeded', + attemptNumber: 1, + currency: 'UAH', + expectedAmountMinor: 1000, + idempotencyKey: buildMonobankAttemptIdempotencyKey(orderId, 1), + providerPaymentIntentId: invoiceId, + metadata: {}, + } as any); + + const payload = { + invoiceId, + status: 'reversed', + amount: 1000, + ccy: 980, + reference: attemptId, + modifiedAt: Date.now(), + }; + + const rawBody = JSON.stringify(payload); + const rawSha256 = crypto + .createHash('sha256') + .update(rawBody) + .digest('hex'); + + let eventId: string | null = null; + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + requestId: 'req_paid_reversed', + mode: 'apply', + rawSha256, + parsedPayload: payload, + eventKey: rawSha256, + }); + + eventId = res.eventId; + expect(eventId).toBeTruthy(); + const expectedNote = `event:${eventId!}:${payload.status}`; + expect(res.appliedResult).toBe('applied'); + + const [attempt] = await db + .select({ + status: paymentAttempts.status, + lastErrorCode: paymentAttempts.lastErrorCode, + }) + .from(paymentAttempts) + .where(eq(paymentAttempts.id, attemptId)) + .limit(1); + + expect(attempt?.status).toBe('canceled'); + expect(attempt?.lastErrorCode).toBe('reversed'); + + expect(guardedPaymentStatusUpdate).toHaveBeenCalledTimes(1); + expect(guardedPaymentStatusUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + orderId, + paymentProvider: 'monobank', + to: 'refunded', + source: 'monobank_webhook', + note: expectedNote, + }) + ); + + expect(restockOrder).toHaveBeenCalledTimes(1); + expect(restockOrder).toHaveBeenCalledWith(orderId, { + reason: 'refunded', + workerId: 'monobank_webhook', + }); + } finally { + await cleanup(orderId, attemptId, eventId); + } + }, 20000); + } +); diff --git a/frontend/lib/tests/shop/monobank-webhook-route-f2.test.ts b/frontend/lib/tests/shop/monobank-webhook-route-f2.test.ts new file mode 100644 index 00000000..21826f13 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-route-f2.test.ts @@ -0,0 +1,269 @@ +import crypto from 'node:crypto'; + +import { and, eq, or } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; +import { toDbMoney } from '@/lib/shop/money'; + +const verifyWebhookSignatureWithRefreshMock = vi.fn( + async (..._args: unknown[]) => true +); + +vi.mock('@/lib/psp/monobank', () => ({ + verifyWebhookSignatureWithRefresh: (args: unknown) => + verifyWebhookSignatureWithRefreshMock(args), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +const ENV_KEYS = ['MONO_WEBHOOK_MODE']; +const previousEnv: Record = {}; + +beforeEach(() => { + vi.clearAllMocks(); + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + } + process.env.MONO_WEBHOOK_MODE = 'apply'; + resetEnvCache(); +}); + +afterEach(async () => { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetEnvCache(); +}); + +async function insertOrderAndAttempt(invoiceId: string) { + const orderId = crypto.randomUUID(); + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + } as any); + + const attemptId = crypto.randomUUID(); + await db.insert(paymentAttempts).values({ + id: attemptId, + orderId, + provider: 'monobank', + status: 'active', + attemptNumber: 1, + currency: 'UAH', + expectedAmountMinor: 1000, + idempotencyKey: buildMonobankAttemptIdempotencyKey(orderId, 1), + providerPaymentIntentId: invoiceId, + } as any); + + return { orderId }; +} + +async function cleanup(orderId: string, invoiceId: string) { + await db + .delete(monobankEvents) + .where( + or( + eq(monobankEvents.orderId, orderId), + eq(monobankEvents.invoiceId, invoiceId) + ) + ); + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function postWebhookRaw(rawBody: string, signature = 'test-signature') { + const { POST } = await import('@/app/api/shop/webhooks/monobank/route'); + + const req = new NextRequest('http://localhost/api/shop/webhooks/monobank', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-sign': signature, + 'x-request-id': 'mono-webhook-route-f2', + }, + body: rawBody, + }); + + return POST(req); +} + +describe.sequential('monobank webhook route F2', () => { + it('invalid signature: no event write and no order/attempt state changes', async () => { + verifyWebhookSignatureWithRefreshMock.mockResolvedValue(false); + + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt(invoiceId); + const rawBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + modifiedDate: Date.now(), + }); + const rawSha256 = crypto.createHash('sha256').update(rawBody).digest('hex'); + + try { + const res = await postWebhookRaw(rawBody, 'bad-signature'); + expect(res.status).toBe(200); + const json: any = await res.json(); + expect(json.ok).toBe(true); + + const events = await db + .select({ id: monobankEvents.id }) + .from(monobankEvents) + .where(eq(monobankEvents.rawSha256, rawSha256)); + expect(events.length).toBe(0); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('pending'); + + const [attempt] = await db + .select({ status: paymentAttempts.status }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .limit(1); + expect(attempt?.status).toBe('active'); + } finally { + await cleanup(orderId, invoiceId); + } + }); + + it('dedupe: same raw payload is inserted once and applied once', async () => { + verifyWebhookSignatureWithRefreshMock.mockResolvedValue(true); + + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt(invoiceId); + const rawBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + modifiedDate: Date.now(), + }); + const rawSha256 = crypto.createHash('sha256').update(rawBody).digest('hex'); + const eventKey = rawSha256; + + try { + const first = await postWebhookRaw(rawBody); + const second = await postWebhookRaw(rawBody); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + + const events = await db + .select({ + id: monobankEvents.id, + appliedResult: monobankEvents.appliedResult, + }) + .from(monobankEvents) + .where(eq(monobankEvents.eventKey, eventKey)); + expect(events.length).toBe(1); + expect(events[0]?.appliedResult).toBe('applied'); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('paid'); + + const [attempt] = await db + .select({ status: paymentAttempts.status }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .limit(1); + expect(attempt?.status).toBe('succeeded'); + } finally { + await cleanup(orderId, invoiceId); + } + }); + + it('out-of-order: older event does not revert paid state', async () => { + verifyWebhookSignatureWithRefreshMock.mockResolvedValue(true); + + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt(invoiceId); + const now = Date.now(); + + const successBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + modifiedDate: now, + }); + const olderBody = JSON.stringify({ + invoiceId, + status: 'processing', + amount: 1000, + ccy: 980, + modifiedDate: now - 60_000, + }); + + try { + const first = await postWebhookRaw(successBody); + const second = await postWebhookRaw(olderBody); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + + const [order] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(order?.paymentStatus).toBe('paid'); + + const [attempt] = await db + .select({ status: paymentAttempts.status }) + .from(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .limit(1); + expect(attempt?.status).toBe('succeeded'); + + const [olderEvent] = await db + .select({ + appliedResult: monobankEvents.appliedResult, + appliedErrorCode: monobankEvents.appliedErrorCode, + }) + .from(monobankEvents) + .where( + and( + eq(monobankEvents.invoiceId, invoiceId), + eq(monobankEvents.status, 'processing') + ) + ) + .limit(1); + expect(olderEvent?.appliedResult).toBe('applied_noop'); + expect(olderEvent?.appliedErrorCode).toBe('OUT_OF_ORDER'); + } finally { + await cleanup(orderId, invoiceId); + } + }); +}); diff --git a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts index 5606444c..6f92582d 100644 --- a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts +++ b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts @@ -52,7 +52,6 @@ async function cleanupByIds(params: { orderId?: string; productId: string }) { const { orderId, productId } = params; if (orderId) { - // delete children first await db.delete(inventoryMoves).where(eq(inventoryMoves.orderId, orderId)); await db.delete(orderItems).where(eq(orderItems.orderId, orderId)); await db.delete(orders).where(eq(orders.id, orderId)); @@ -154,7 +153,6 @@ describe('P0-6 snapshots: order_items immutability', () => { expect(before[0].productSku).toBe(skuV1); expect(before[0].unitPriceMinor).toBe(900); expect(before[0].lineTotalMinor).toBe(900); - const titleV2 = `${titleV1} UPDATED`; const slugV2 = `${slugV1}-updated`; const skuV2 = `${skuV1}-UPDATED`; diff --git a/frontend/lib/tests/shop/order-status-token.test.ts b/frontend/lib/tests/shop/order-status-token.test.ts new file mode 100644 index 00000000..0f4133c6 --- /dev/null +++ b/frontend/lib/tests/shop/order-status-token.test.ts @@ -0,0 +1,284 @@ +import crypto from 'crypto'; +import { eq } from 'drizzle-orm'; +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 { toDbMoney } from '@/lib/shop/money'; +import { createStatusToken } from '@/lib/shop/status-token'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; + +beforeAll(() => { + process.env.SHOP_STATUS_TOKEN_SECRET = + 'test_status_token_secret_test_status_token_secret'; +}); + +afterAll(() => { + if (__prevStatusSecret === undefined) + delete process.env.SHOP_STATUS_TOKEN_SECRET; + else process.env.SHOP_STATUS_TOKEN_SECRET = __prevStatusSecret; +}); + +async function insertOrder(orderId: string) { + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + } as any); +} + +async function deleteOrder(orderId: string) { + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function insertAttempt(args: { + orderId: string; + status: 'creating' | 'active' | 'succeeded' | 'failed' | 'canceled'; + attemptNumber: number; + providerRef: string | null; + checkoutUrl?: string | null; + metadata?: Record; + updatedAt?: Date; +}) { + const createdAt = new Date( + (args.updatedAt ?? new Date()).getTime() - 1_000 + ); + + await db.insert(paymentAttempts).values({ + id: crypto.randomUUID(), + orderId: args.orderId, + provider: 'monobank', + status: args.status, + attemptNumber: args.attemptNumber, + currency: 'UAH', + expectedAmountMinor: 1000, + idempotencyKey: crypto.randomUUID(), + providerPaymentIntentId: args.providerRef, + checkoutUrl: args.checkoutUrl ?? null, + metadata: args.metadata ?? {}, + createdAt, + updatedAt: args.updatedAt ?? new Date(), + } as any); +} + +describe('order status token access control', () => { + it('requires token when no session', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + + try { + const { GET } = await import('@/app/api/shop/orders/[id]/status/route'); + const req = new NextRequest( + `http://localhost/api/shop/orders/${orderId}/status` + ); + const res = await GET(req, { params: Promise.resolve({ id: orderId }) }); + expect(res.status).toBe(401); + const json: any = await res.json(); + expect(json.code).toBe('STATUS_TOKEN_REQUIRED'); + } finally { + await deleteOrder(orderId); + } + }); + + it('allows access with valid token for that order', 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?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.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(); + + const [row] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(row?.paymentStatus).toBe('pending'); + } finally { + await deleteOrder(orderId); + } + }); + + it('rejects token for another order', async () => { + const orderId = crypto.randomUUID(); + const otherOrderId = crypto.randomUUID(); + await insertOrder(orderId); + await insertOrder(otherOrderId); + + 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/${otherOrderId}/status?statusToken=${encodeURIComponent( + token + )}` + ); + const res = await GET(req, { + params: Promise.resolve({ id: otherOrderId }), + }); + expect(res.status).toBe(403); + const json: any = await res.json(); + expect(json.code).toBe('STATUS_TOKEN_INVALID'); + } finally { + await deleteOrder(orderId); + await deleteOrder(otherOrderId); + } + }); + + it('rejects expired token', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + + try { + const token = createStatusToken({ + orderId, + ttlSeconds: 60, + nowMs: Date.now() - 2 * 60 * 60 * 1000, + }); + 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_INVALID'); + } finally { + await deleteOrder(orderId); + } + }); + + it('returns attempt when a payment attempt exists', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + await insertAttempt({ + orderId, + status: 'active', + attemptNumber: 1, + providerRef: 'inv_123', + checkoutUrl: 'https://pay.test/inv_123', + }); + + 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?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.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'); + } finally { + await deleteOrder(orderId); + } + }); + + it('prefers creating/active attempt over newer non-active attempt', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + + const now = Date.now(); + await insertAttempt({ + orderId, + status: 'active', + attemptNumber: 1, + providerRef: 'inv_active', + updatedAt: new Date(now - 60_000), + metadata: { pageUrl: 'https://pay.test/inv_active' }, + }); + await insertAttempt({ + orderId, + status: 'failed', + attemptNumber: 2, + providerRef: 'inv_failed_newer', + updatedAt: new Date(now), + checkoutUrl: 'https://pay.test/inv_failed_newer', + }); + + 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?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.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'); + } finally { + await deleteOrder(orderId); + } + }); + + it('returns 500 STATUS_TOKEN_MISCONFIGURED when secret is missing and token is provided', async () => { + const orderId = crypto.randomUUID(); + await insertOrder(orderId); + + const previous = process.env.SHOP_STATUS_TOKEN_SECRET; + delete process.env.SHOP_STATUS_TOKEN_SECRET; + + try { + const { GET } = await import('@/app/api/shop/orders/[id]/status/route'); + const req = new NextRequest( + `http://localhost/api/shop/orders/${orderId}/status?statusToken=invalid.token` + ); + const res = await GET(req, { params: Promise.resolve({ id: orderId }) }); + expect(res.status).toBe(500); + const json: any = await res.json(); + expect(json.code).toBe('STATUS_TOKEN_MISCONFIGURED'); + } finally { + if (previous === undefined) { + delete process.env.SHOP_STATUS_TOKEN_SECRET; + } else { + process.env.SHOP_STATUS_TOKEN_SECRET = previous; + } + await deleteOrder(orderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/orders-access.test.ts b/frontend/lib/tests/shop/orders-access.test.ts index 209d53d9..2cf8a291 100644 --- a/frontend/lib/tests/shop/orders-access.test.ts +++ b/frontend/lib/tests/shop/orders-access.test.ts @@ -1,129 +1,129 @@ import { NextRequest } from 'next/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@/lib/auth', () => ({ +vi.mock("@/lib/auth", () => ({ getCurrentUser: vi.fn(), -})); +})) -vi.mock('@/db', () => ({ +vi.mock("@/db", () => ({ db: { select: vi.fn(), }, -})); +})) import { db } from '@/db'; import { getCurrentUser } from '@/lib/auth'; -type MockUser = { id: string; role: 'user' | 'admin' }; +type MockUser = { id: string; role: "user" | "admin" } -describe('P0-SEC-1.1: GET /api/shop/orders/[id] access control', () => { - const orderId = '00000000-0000-0000-0000-000000000000'; - const ownerId = '11111111-1111-1111-1111-111111111111'; - const otherUserId = '22222222-2222-2222-2222-222222222222'; - const adminId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +describe("P0-SEC-1.1: GET /api/shop/orders/[id] access control", () => { + const orderId = "00000000-0000-0000-0000-000000000000" + const ownerId = "11111111-1111-1111-1111-111111111111" + const otherUserId = "22222222-2222-2222-2222-222222222222" + const adminId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" function mockDbRows(rows: any[]) { const builder = { from: vi.fn().mockReturnThis(), leftJoin: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue(rows), - }; - (db.select as any).mockReturnValue(builder); - return builder; + } + ;(db.select as any).mockReturnValue(builder) + return builder } async function callGet(id: string) { - const { GET } = await import('@/app/api/shop/orders/[id]/route'); + const { GET } = await import("@/app/api/shop/orders/[id]/route") const req = new NextRequest(`http://localhost/api/shop/orders/${id}`, { - method: 'GET', - }); + method: "GET", + }) - const res = await (GET as any)(req, { params: Promise.resolve({ id }) }); - return res as Response; + const res = await (GET as any)(req, { params: Promise.resolve({ id }) }) + return res as Response } beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - it('no session -> 401', async () => { - (getCurrentUser as any).mockResolvedValue(null); + it("no session -> 401", async () => { + ;(getCurrentUser as any).mockResolvedValue(null) - const res = await callGet(orderId); - expect(res.status).toBe(401); - expect(db.select).not.toHaveBeenCalled(); - }); + const res = await callGet(orderId) + expect(res.status).toBe(401) + expect(db.select).not.toHaveBeenCalled() + }) - it('not owner and not admin -> 404 (hide existence)', async () => { - const user: MockUser = { id: otherUserId, role: 'user' }; - (getCurrentUser as any).mockResolvedValue(user); + it("not owner and not admin -> 404 (hide existence)", async () => { + const user: MockUser = { id: otherUserId, role: "user" } + ;(getCurrentUser as any).mockResolvedValue(user) - mockDbRows([]); + mockDbRows([]) - const res = await callGet(orderId); - expect(res.status).toBe(404); - }); + const res = await callGet(orderId) + expect(res.status).toBe(404) + }) - it('owner -> 200', async () => { - const user: MockUser = { id: ownerId, role: 'user' }; - (getCurrentUser as any).mockResolvedValue(user); + it("owner -> 200", async () => { + const user: MockUser = { id: ownerId, role: "user" } + ;(getCurrentUser as any).mockResolvedValue(user) - const now = new Date(); + const now = new Date() mockDbRows([ { order: { id: orderId, userId: ownerId, - totalAmount: '10.00', - currency: 'USD', - paymentStatus: 'pending', - paymentProvider: 'stripe', + totalAmount: "10.00", + currency: "USD", + paymentStatus: "pending", + paymentProvider: "stripe", paymentIntentId: null, stockRestored: false, restockedAt: null, - idempotencyKey: 'idem_key', + idempotencyKey: "idem_key", createdAt: now, updatedAt: now, }, item: null, }, - ]); + ]) - const res = await callGet(orderId); - expect(res.status).toBe(200); + const res = await callGet(orderId) + expect(res.status).toBe(200) - const json = await res.json(); - expect(json?.success).toBe(true); - expect(json?.order?.id).toBe(orderId); - expect(json?.order?.userId).toBe(ownerId); - }); + const json = await res.json() + expect(json?.success).toBe(true) + expect(json?.order?.id).toBe(orderId) + expect(json?.order?.userId).toBe(ownerId) + }) - it('admin -> 200', async () => { - const user: MockUser = { id: adminId, role: 'admin' }; - (getCurrentUser as any).mockResolvedValue(user); + it("admin -> 200", async () => { + const user: MockUser = { id: adminId, role: "admin" } + ;(getCurrentUser as any).mockResolvedValue(user) - const now = new Date(); + const now = new Date() mockDbRows([ { order: { id: orderId, userId: ownerId, - totalAmount: '10.00', - currency: 'USD', - paymentStatus: 'pending', - paymentProvider: 'stripe', + totalAmount: "10.00", + currency: "USD", + paymentStatus: "pending", + paymentProvider: "stripe", paymentIntentId: null, stockRestored: false, restockedAt: null, - idempotencyKey: 'idem_key', + idempotencyKey: "idem_key", createdAt: now, updatedAt: now, }, item: null, }, - ]); + ]) - const res = await callGet(orderId); - expect(res.status).toBe(200); - }); -}); + const res = await callGet(orderId) + expect(res.status).toBe(200) + }) +}) diff --git a/frontend/lib/tests/shop/payment-attempt-idempotency-key.test.ts b/frontend/lib/tests/shop/payment-attempt-idempotency-key.test.ts new file mode 100644 index 00000000..d0b490be --- /dev/null +++ b/frontend/lib/tests/shop/payment-attempt-idempotency-key.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildMonobankAttemptIdempotencyKey, + buildStripeAttemptIdempotencyKey, +} from '@/lib/services/orders/attempt-idempotency'; + +const uuidA = '11111111-1111-1111-1111-111111111111'; +const uuidB = '22222222-2222-2222-2222-222222222222'; + +describe('payment_attempts idempotency_key format', () => { + it('namespaces stripe attempts by provider', () => { + const key = buildStripeAttemptIdempotencyKey('stripe', uuidA, 1); + expect(key).toBe(`pi:stripe:${uuidA}:1`); + expect(key).toMatch(/^pi:stripe:[0-9a-f-]{36}:\d+$/); + }); + + it('namespaces monobank attempts by provider', () => { + const key = buildMonobankAttemptIdempotencyKey(uuidA, 2); + expect(key).toBe(`mono:${uuidA}:2`); + expect(key).toMatch(/^mono:[0-9a-f-]{36}:\d+$/); + }); + + it('cannot collide across providers', () => { + const stripeKey = buildStripeAttemptIdempotencyKey('stripe', uuidA, 1); + const monoKey = buildMonobankAttemptIdempotencyKey(uuidA, 1); + expect(stripeKey).not.toBe(monoKey); + }); + + it('includes order id to avoid cross-order collisions', () => { + const stripeKeyA = buildStripeAttemptIdempotencyKey('stripe', uuidA, 1); + const stripeKeyB = buildStripeAttemptIdempotencyKey('stripe', uuidB, 1); + expect(stripeKeyA).not.toBe(stripeKeyB); + }); +}); diff --git a/frontend/lib/tests/shop/payment-state-legacy-writers.test.ts b/frontend/lib/tests/shop/payment-state-legacy-writers.test.ts index 93f8b393..0151a5ce 100644 --- a/frontend/lib/tests/shop/payment-state-legacy-writers.test.ts +++ b/frontend/lib/tests/shop/payment-state-legacy-writers.test.ts @@ -57,6 +57,127 @@ function walkFiles(dir: string, out: string[]) { else if (entry.isFile() && p.endsWith('.ts')) out.push(p); } } +function findMatchingBrace(src: string, start: number): number { + let depth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + let inLineComment = false; + let inBlockComment = false; + + for (let i = start; i < src.length; i++) { + const ch = src[i]; + const next = src[i + 1]; + + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + + if (inBlockComment) { + if (ch === '*' && next === '/') { + inBlockComment = false; + i++; + } + continue; + } + + if (inSingle) { + if (ch === '\\') { + i++; + continue; + } + if (ch === "'") inSingle = false; + continue; + } + + if (inDouble) { + if (ch === '\\') { + i++; + continue; + } + if (ch === '"') inDouble = false; + continue; + } + + if (inTemplate) { + if (ch === '\\') { + i++; + continue; + } + if (ch === '`') inTemplate = false; + + continue; + } + + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + + if (ch === '/' && next === '*') { + inBlockComment = true; + i++; + continue; + } + + if (ch === "'") { + inSingle = true; + continue; + } + + if (ch === '"') { + inDouble = true; + continue; + } + + if (ch === '`') { + inTemplate = true; + continue; + } + + if (ch === '{') { + depth++; + continue; + } + + if (ch === '}') { + depth--; + if (depth === 0) return i; + continue; + } + } + + return -1; +} + +function hasDirectPaymentStatusWriter(src: string): boolean { + let from = 0; + while (true) { + const idx = src.indexOf('.set(', from); + if (idx === -1) return false; + + let i = idx + '.set('.length; + while (i < src.length && /\s/.test(src[i]!)) i++; + + if (src[i] !== '{') { + from = i; + continue; + } + + const end = findMatchingBrace(src, i); + if (end === -1) { + from = i + 1; + continue; + } + + const objLiteral = src.slice(i, end + 1); + if (/\bpaymentStatus\s*:/.test(objLiteral)) return true; + + from = end + 1; + } +} describe('Task 5: guarded payment transitions block legacy/forbidden paths', () => { const created: string[] = []; @@ -173,9 +294,7 @@ describe('Task 5: guarded payment transitions block legacy/forbidden paths', () if (f.endsWith(path.join('orders', 'payment-state.ts'))) continue; const s = fs.readFileSync(f, 'utf8'); - const hasDirectWriter = /\.set\(\s*{[\s\S]{0,800}?paymentStatus\s*:/.test( - s - ); + const hasDirectWriter = hasDirectPaymentStatusWriter(s); if (hasDirectWriter) offenders.push(path.relative(process.cwd(), f)); } diff --git a/frontend/lib/tests/shop/restock-release-failure-invariant.test.ts b/frontend/lib/tests/shop/restock-release-failure-invariant.test.ts index fa786a85..725cee22 100644 --- a/frontend/lib/tests/shop/restock-release-failure-invariant.test.ts +++ b/frontend/lib/tests/shop/restock-release-failure-invariant.test.ts @@ -142,7 +142,6 @@ describe('P0 Inventory release invariants', () => { } if (restockErr) { - // Accept ONLY the simulated release failure; rethrow anything else. if ( restockErr instanceof Error && restockErr.message.includes('SIMULATED_RELEASE_FAIL') diff --git a/frontend/lib/tests/shop/restock-stale-stripe-orphan.test.ts b/frontend/lib/tests/shop/restock-stale-stripe-orphan.test.ts index d12bca02..71fe9478 100644 --- a/frontend/lib/tests/shop/restock-stale-stripe-orphan.test.ts +++ b/frontend/lib/tests/shop/restock-stale-stripe-orphan.test.ts @@ -12,7 +12,7 @@ describe('P0-3.x Restock stale pending orders: stripe orphan cleanup', () => { const orderId = crypto.randomUUID(); const idem = `test-stale-orphan-stripe-${crypto.randomUUID()}`; - const createdAt = new Date(Date.now() - 2 * 60 * 60 * 1000); // 2h ago + const createdAt = new Date(Date.now() - 2 * 60 * 60 * 1000); const totalAmountMinor = 1234; try { diff --git a/frontend/lib/tests/shop/shop-url.test.ts b/frontend/lib/tests/shop/shop-url.test.ts new file mode 100644 index 00000000..ad3986f6 --- /dev/null +++ b/frontend/lib/tests/shop/shop-url.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +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', + 'NODE_ENV', +]; + +const previousEnv: Record = {}; + +beforeEach(() => { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + delete process.env[key]; + } + + process.env.DATABASE_URL = 'https://db.example.test'; + resetEnvCache(); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetEnvCache(); +}); + +describe('shop base url helper', () => { + it('prefers SHOP_BASE_URL over other envs', () => { + process.env.SHOP_BASE_URL = 'https://shop.example.test'; + process.env.APP_ORIGIN = 'https://app.example.test'; + process.env.NEXT_PUBLIC_SITE_URL = 'https://public.example.test'; + resetEnvCache(); + + expect(resolveShopBaseUrl().origin).toBe('https://shop.example.test'); + }); + + it('falls back to APP_ORIGIN when SHOP_BASE_URL is missing', () => { + process.env.APP_ORIGIN = 'https://app.example.test'; + process.env.NEXT_PUBLIC_SITE_URL = 'https://public.example.test'; + resetEnvCache(); + + expect(resolveShopBaseUrl().origin).toBe('https://app.example.test'); + }); + + it('falls back to NEXT_PUBLIC_SITE_URL when SHOP_BASE_URL and APP_ORIGIN are missing', () => { + process.env.NEXT_PUBLIC_SITE_URL = 'https://public.example.test'; + resetEnvCache(); + + expect(resolveShopBaseUrl().origin).toBe('https://public.example.test'); + }); + + it('enforces https in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + process.env.SHOP_BASE_URL = 'http://example.test'; + resetEnvCache(); + + expect(() => resolveShopBaseUrl()).toThrow('https'); + }); + + it('joins base url and path safely', () => { + process.env.SHOP_BASE_URL = 'https://x.test'; + resetEnvCache(); + + expect(toAbsoluteUrl('/api/shop/webhooks/monobank')).toBe( + 'https://x.test/api/shop/webhooks/monobank' + ); + }); +}); diff --git a/frontend/lib/tests/shop/stripe-webhook-contract.test.ts b/frontend/lib/tests/shop/stripe-webhook-contract.test.ts index 04399ff4..3518efe6 100644 --- a/frontend/lib/tests/shop/stripe-webhook-contract.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-contract.test.ts @@ -42,7 +42,7 @@ describe('P0-3.3 Stripe webhook contract: disabled vs invalid signature', () => it('returns 500 WEBHOOK_DISABLED when webhook env is missing/disabled', async () => { const { POST } = await import('@/app/api/shop/webhooks/stripe/route'); - process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; process.env.STRIPE_WEBHOOK_SECRET = ''; @@ -56,7 +56,7 @@ describe('P0-3.3 Stripe webhook contract: disabled vs invalid signature', () => }); it('returns 400 INVALID_SIGNATURE when signature is invalid', async () => { - process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_dummy'; diff --git a/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts b/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts index 77b50e8c..35c457c7 100644 --- a/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts @@ -1,3 +1,5 @@ +import crypto from 'node:crypto'; + import { sql } from 'drizzle-orm'; import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -21,27 +23,60 @@ function makeReq(url: string, body: string, headers?: Record) { ); } -async function pickActiveProductIdForCurrency(currency: 'UAH' | 'USD') { - const res = (await db.execute(sql` - select p.id - from products p - inner join product_prices pp - on pp.product_id = p.id - and pp.currency = ${currency} - where p.is_active = true - and p.stock > 0 - and pp.price_minor > 0 - order by p.updated_at desc - limit 1 - `)) as DbRows<{ id: string }>; - - const id = res.rows?.[0]?.id; - if (!id) { - throw new Error( - `No active product found for currency=${currency}. Ensure DB has products.is_active=true, stock>0, and product_prices row.` - ); - } - return id; +function safeCurrencyLiteral(currency: 'USD' | 'UAH'): 'USD' | 'UAH' { + return currency === 'UAH' ? 'UAH' : 'USD'; +} + +async function createStripeOrderFixture(args: { currency: 'USD' | 'UAH' }) { + const currency = safeCurrencyLiteral(args.currency); + + const orderId = crypto.randomUUID(); + const totalMinor = 12_345; + const piId = `pi_test_mismatch_${orderId.slice(0, 8)}`; + const idemKey = `test_${crypto.randomUUID()}`; + const now = new Date(); + + await db.execute(sql` + insert into orders ( + id, + user_id, + total_amount_minor, + total_amount, + currency, + payment_status, + payment_provider, + payment_intent_id, + status, + inventory_status, + failure_code, + failure_message, + idempotency_key, + idempotency_request_hash, + stock_restored, + restocked_at, + updated_at + ) values ( + ${orderId}::uuid, + null, + ${totalMinor}, + (${totalMinor}::numeric / 100), + ${sql.raw(`'${currency}'`)}, + 'requires_payment', + 'stripe', + ${piId}, + 'INVENTORY_RESERVED', + 'reserved', + null, + null, + ${idemKey}, + ${`hash_${idemKey}`}, + false, + null, + ${now} + ) + `); + + return { orderId, piId, totalMinor, currency }; } describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set paid', () => { @@ -55,151 +90,127 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p vi.restoreAllMocks(); }); - it('mismatch: does NOT set paid and stores pspStatusReason + pspMetadata(expected/actual + event id)', async () => { - process.env.PAYMENTS_ENABLED = 'false'; - - vi.doMock('@/lib/auth', async () => { - const actual = await vi.importActual('@/lib/auth'); - return { - __esModule: true, - ...actual, - getCurrentUser: vi.fn(async () => null), - }; - }); - - vi.doMock('@/lib/psp/stripe', async () => { - const actual = await vi.importActual('@/lib/psp/stripe'); - return { - __esModule: true, - ...actual, - - verifyWebhookSignature: vi.fn((params: any) => { - const rawBody = params?.rawBody; - if (typeof rawBody !== 'string' || !rawBody.trim()) { - throw new Error('TEST_INVALID_RAW_BODY'); - } - return JSON.parse(rawBody); - }), - }; - }); - const { POST: checkoutPOST } = - await import('@/app/api/shop/checkout/route'); - - const productId = await pickActiveProductIdForCurrency('UAH'); - const idemKey = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `idem_${Date.now()}_${Math.random().toString(16).slice(2)}`; - - const checkoutBody = JSON.stringify({ - items: [{ productId, quantity: 1 }], - }); - - const checkoutRes = await checkoutPOST( - makeReq('http://localhost/api/shop/checkout', checkoutBody, { - 'accept-language': 'uk-UA,uk;q=0.9', - 'idempotency-key': idemKey, - origin: 'http://localhost:3000', - }) - ); - - expect([200, 201]).toContain(checkoutRes.status); - - const checkoutJson: any = await checkoutRes.json(); - expect(checkoutJson?.success).toBe(true); - - const orderId: string = - checkoutJson?.order?.id ?? checkoutJson?.orderId ?? ''; - expect(orderId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - ); - - const row0 = (await db.execute(sql` - select total_amount_minor, currency - from orders - where id = ${orderId} - limit 1 - `)) as DbRows<{ total_amount_minor: number | string; currency: string }>; - - const expectedMinor = Number(row0.rows?.[0]?.total_amount_minor); - const expectedCurrency = String(row0.rows?.[0]?.currency); - - expect(Number.isFinite(expectedMinor)).toBe(true); - expect(expectedMinor).toBeGreaterThan(0); - expect(expectedCurrency).toBe('UAH'); - - const piId = `pi_test_mismatch_${orderId.slice(0, 8)}`; - await db.execute(sql` - update orders - set payment_provider = 'stripe', - payment_status = 'requires_payment', - payment_intent_id = ${piId} - where id = ${orderId} - `); - - process.env.PAYMENTS_ENABLED = 'true'; - process.env.STRIPE_SECRET_KEY = 'stripe_secret_key_placeholder'; - process.env.STRIPE_WEBHOOK_SECRET = 'stripe_webhook_secret_placeholder'; - - const evtId = `evt_mismatch_${orderId.slice(0, 8)}`; - const actualMinor = expectedMinor + 1; - - const mockedEvent = { - id: evtId, - type: 'payment_intent.succeeded', - data: { - object: { - id: piId, - object: 'payment_intent', - status: 'succeeded', - currency: 'uah', - amount: actualMinor, - amount_received: actualMinor, - metadata: { orderId }, + it( + 'mismatch: does NOT set paid and stores pspStatusReason + pspMetadata(expected/actual + event id)', + async () => { + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'false'; + + vi.doMock('@/lib/auth', async () => { + const actual = await vi.importActual('@/lib/auth'); + return { + __esModule: true, + ...actual, + getCurrentUser: vi.fn(async () => null), + }; + }); + + vi.doMock('@/lib/psp/stripe', async () => { + const actual = await vi.importActual('@/lib/psp/stripe'); + return { + __esModule: true, + ...actual, + verifyWebhookSignature: vi.fn((params: any) => { + const rawBody = params?.rawBody; + if (typeof rawBody !== 'string' || !rawBody.trim()) { + throw new Error('TEST_INVALID_RAW_BODY'); + } + return JSON.parse(rawBody); + }), + }; + }); + + const { orderId, piId, totalMinor, currency } = + await createStripeOrderFixture({ currency: 'USD' }); + + expect(orderId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + + const row0 = (await db.execute(sql` + select total_amount_minor, currency + from orders + where id = ${orderId}::uuid + limit 1 + `)) as DbRows<{ total_amount_minor: number | string; currency: string }>; + + const expectedMinor = Number(row0.rows?.[0]?.total_amount_minor); + const expectedCurrency = String(row0.rows?.[0]?.currency); + + expect(Number.isFinite(expectedMinor)).toBe(true); + expect(expectedMinor).toBe(totalMinor); + expect(expectedMinor).toBeGreaterThan(0); + expect(expectedCurrency).toBe(currency); + + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_SECRET_KEY = 'stripe_secret_key_placeholder'; + process.env.STRIPE_WEBHOOK_SECRET = 'stripe_webhook_secret_placeholder'; + + const evtId = `evt_mismatch_${orderId.slice(0, 8)}`; + const actualMinor = expectedMinor + 1; + + const mockedEvent = { + id: evtId, + type: 'payment_intent.succeeded', + data: { + object: { + id: piId, + object: 'payment_intent', + status: 'succeeded', + currency: 'usd', + amount: actualMinor, + amount_received: actualMinor, + metadata: { orderId }, + }, }, - }, - }; - - const { POST: webhookPOST } = - await import('@/app/api/shop/webhooks/stripe/route'); - - const webhookRes = await webhookPOST( - makeReq( - 'http://localhost/api/shop/webhooks/stripe', - JSON.stringify(mockedEvent), - { - 'stripe-signature': 't=0,v1=deadbeef', - } - ) - ); - - expect([200, 202]).toContain(webhookRes.status); - - const row1 = (await db.execute(sql` - select payment_status, psp_status_reason, psp_metadata - from orders - where id = ${orderId} - limit 1 - `)) as DbRows<{ - payment_status: string; - psp_status_reason: string | null; - psp_metadata: unknown; - }>; - - const paymentStatus = String(row1.rows?.[0]?.payment_status ?? ''); - const reason = row1.rows?.[0]?.psp_status_reason ?? null; - const metaRaw = row1.rows?.[0]?.psp_metadata; - - expect(paymentStatus).not.toBe('paid'); - expect(reason && reason.length > 0).toBe(true); - - const metaObj = - typeof metaRaw === 'string' ? JSON.parse(metaRaw) : (metaRaw ?? {}); - - expect(metaObj?.mismatch?.eventId).toBe(evtId); - expect(metaObj?.mismatch?.expected?.amountMinor).toBe(expectedMinor); - expect(metaObj?.mismatch?.actual?.amountMinor).toBe(actualMinor); - expect(String(metaObj?.mismatch?.expected?.currency)).toBe('UAH'); - expect(String(metaObj?.mismatch?.actual?.currency)).toBe('uah'); - }, 30_000); + }; + + const { POST: webhookPOST } = await import( + '@/app/api/shop/webhooks/stripe/route' + ); + + const webhookRes = await webhookPOST( + makeReq( + 'http://localhost/api/shop/webhooks/stripe', + JSON.stringify(mockedEvent), + { 'stripe-signature': 't=0,v1=deadbeef' } + ) + ); + + expect([200, 202]).toContain(webhookRes.status); + + const row1 = (await db.execute(sql` + select payment_status, psp_status_reason, psp_metadata + from orders + where id = ${orderId}::uuid + limit 1 + `)) as DbRows<{ + payment_status: string; + psp_status_reason: string | null; + psp_metadata: unknown; + }>; + + const paymentStatus = String(row1.rows?.[0]?.payment_status ?? ''); + const reason = row1.rows?.[0]?.psp_status_reason ?? null; + const metaRaw = row1.rows?.[0]?.psp_metadata; + + expect(paymentStatus).not.toBe('paid'); + expect(reason && reason.length > 0).toBe(true); + + const metaObj = + typeof metaRaw === 'string' ? JSON.parse(metaRaw) : (metaRaw ?? {}); + + expect(metaObj?.mismatch?.eventId).toBe(evtId); + expect(metaObj?.mismatch?.expected?.amountMinor).toBe(expectedMinor); + expect(metaObj?.mismatch?.actual?.amountMinor).toBe(actualMinor); + expect(String(metaObj?.mismatch?.expected?.currency)).toBe(currency); + expect(String(metaObj?.mismatch?.actual?.currency)).toBe('usd'); + + await db.execute(sql` + delete from orders + where id = ${orderId}::uuid + `); + }, + 30_000 + ); }); diff --git a/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts b/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts index f7cfc247..8ed5730e 100644 --- a/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts @@ -163,7 +163,6 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded data: { object: charge }, } as unknown as Stripe.Event); - // 1st call const res1 = await POST(makeRequest()); expect(res1.status).toBe(200); const json1 = await res1.json(); diff --git a/frontend/lib/utils/uuid.ts b/frontend/lib/utils/uuid.ts new file mode 100644 index 00000000..ac62d292 --- /dev/null +++ b/frontend/lib/utils/uuid.ts @@ -0,0 +1,6 @@ +export const UUID_V1_V5_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isUuidV1toV5(value: unknown): value is string { + return typeof value === 'string' && UUID_V1_V5_RE.test(value); +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index dd44577e..634a8740 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -930,37 +930,6 @@ "noPoints": "No points awarded (result not improved)", "viewLeaderboard": "View leaderboard", "tryAgain": "Try again" - }, - "quizResults": { - "title": "Quiz Results", - "noAttempts": "You haven't taken any quizzes yet", - "startQuiz": "Try one", - "score": "Score", - "integrity": "Integrity", - "points": "Points", - "scoreHint": "Number of correct answers out of total questions", - "integrityHint": "Fair play score (no violations detected)", - "pointsHint": "Points earned for improving your previous result", - "timeAgo": "{value} ago", - "mastered": "Mastered", - "needsReview": "Review", - "study": "Study", - "date": "Date", - "status": "Status" - }, - "quizReview": { - "title": "Error Analysis", - "subtitle": "{incorrect} of {total} — incorrect", - "allCorrect": "All answers are correct!", - "allCorrectHint": "You answered all questions correctly", - "yourAnswer": "Your answer", - "correctAnswer": "Correct answer", - "explanation": "Explanation", - "retakeQuiz": "Retake Quiz", - "backToDashboard": "Back to Dashboard", - "notFound": "Attempt not found", - "expandAll": "Expand all", - "collapseAll": "Collapse all" }, "explainedTerms": { "title": "Learned Terms", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 7b920e65..6262628e 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -44,7 +44,7 @@ "dashboard": "Panel" }, "homepage": { - "title": "DevLovers — Platforma do przygotowania do rozmów technicznych", + "title": "DevLovers", "subtitle": "Platforma do przygotowania do rozmów kwalifikacyjnych", "description": "Przygotuj się do rozmów technicznych dzięki pytaniom, quizom, rankingom i wyjaśnieniom wspieranym przez AI.", "ogImageAlt": "DevLovers — platforma do przygotowania do rozmów technicznych", @@ -957,37 +957,6 @@ "noPoints": "Nie przyznano punktów (wynik nie uległ poprawie)", "viewLeaderboard": "Zobacz ranking", "tryAgain": "Spróbuj ponownie" - }, - "quizResults": { - "title": "Wyniki quizów", - "noAttempts": "Nie przeszedłeś jeszcze żadnego quizu", - "startQuiz": "Spróbuj", - "score": "Wynik", - "integrity": "Czystość", - "points": "Punkty", - "scoreHint": "Liczba poprawnych odpowiedzi z ogólnej liczby", - "integrityHint": "Wskaźnik uczciwego przejścia (bez naruszeń)", - "pointsHint": "Punkty przyznane za poprawę wyniku", - "timeAgo": "{value} temu", - "mastered": "Opanowane", - "needsReview": "Powtórka", - "study": "Nauka", - "date": "Data", - "status": "Status" - }, - "quizReview": { - "title": "Analiza błędów", - "subtitle": "{incorrect} z {total} — niepoprawnie", - "allCorrect": "Wszystkie odpowiedzi poprawne!", - "allCorrectHint": "Odpowiedziałeś poprawnie na wszystkie pytania", - "yourAnswer": "Twoja odpowiedź", - "correctAnswer": "Poprawna odpowiedź", - "explanation": "Wyjaśnienie", - "retakeQuiz": "Rozwiąż ponownie", - "backToDashboard": "Wróć do panelu", - "notFound": "Nie znaleziono próby", - "expandAll": "Rozwiń wszystko", - "collapseAll": "Zwiń wszystko" }, "explainedTerms": { "title": "Nauczone Terminy", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index 0ee8d5b2..46cc0f4d 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -933,37 +933,6 @@ "noPoints": "Бали не нараховано (результат не покращено)", "viewLeaderboard": "Переглянути рейтинг", "tryAgain": "Пройти ще раз" - }, - "quizResults": { - "title": "Результати квізів", - "noAttempts": "Ви ще не проходили жодного квізу", - "startQuiz": "Спробувати", - "score": "Результат", - "integrity": "Чистота", - "points": "Балів", - "scoreHint": "Кількість правильних відповідей з загальної кількості", - "integrityHint": "Показник чесного проходження (без порушень)", - "pointsHint": "Бали, нараховані за покращення результату", - "timeAgo": "{value} тому", - "mastered": "Засвоєно", - "needsReview": "Повторити", - "study": "Вивчити", - "date": "Дата", - "status": "Статус" - }, - "quizReview": { - "title": "Аналіз помилок", - "subtitle": "{incorrect} з {total} — неправильно", - "allCorrect": "Всі відповіді правильні!", - "allCorrectHint": "Ви відповіли на всі питання вірно", - "yourAnswer": "Ваша відповідь", - "correctAnswer": "Правильна відповідь", - "explanation": "Пояснення", - "retakeQuiz": "Пройти ще раз", - "backToDashboard": "Назад до кабінету", - "notFound": "Спроба не знайдена", - "expandAll": "Розгорнути все", - "collapseAll": "Згорнути все" }, "explainedTerms": { "title": "Вивчені терміни", diff --git a/frontend/next.config.ts b/frontend/next.config.ts index b25aaaab..ccbfec6e 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,3 +1,4 @@ +import { withSentryConfig } from '@sentry/nextjs'; import type { NextConfig } from 'next'; import createNextIntlPlugin from 'next-intl/plugin'; @@ -50,4 +51,40 @@ const nextConfig: NextConfig = { }, }; -export default withNextIntl(nextConfig); +export default withSentryConfig(withNextIntl(nextConfig), { + // For all available options, see: + // https://www.npmjs.com/package/@sentry/webpack-plugin#options + + org: "devlovers", + + project: "devlovers-nextjs", + + // Only print logs for uploading source maps in CI + silent: !process.env.CI, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + // tunnelRoute: "/monitoring", + + webpack: { + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, + + // Tree-shaking options for reducing bundle size + treeshake: { + // Automatically tree-shake Sentry logger statements to reduce bundle size + removeDebugLogging: true, + }, + }, +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 589b1c61..faf6f14a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.5.6", + "version": "0.5.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.5.6", + "version": "0.5.7", "dependencies": { "@neondatabase/serverless": "^1.0.2", "@portabletext/react": "^5.0.0", @@ -15,6 +15,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@sanity/client": "^7.12.1", "@sanity/image-url": "^1.2.0", + "@sentry/nextjs": "^10.10.0", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.0", "@upstash/redis": "^1.36.1", @@ -46,7 +47,7 @@ "zod": "^3.24.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4", + "@tailwindcss/postcss": "^4.1.18", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.1", @@ -100,6 +101,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz", + "integrity": "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==", + "license": "Apache-2.0" + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz", + "integrity": "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.8.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", @@ -115,9 +133,9 @@ } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -139,9 +157,9 @@ } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -159,7 +177,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -174,7 +191,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -184,7 +200,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -215,7 +230,6 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -232,7 +246,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -249,7 +262,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -259,7 +271,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -273,7 +284,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -291,7 +301,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -301,7 +310,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -311,7 +319,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -321,7 +328,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -335,7 +341,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -361,7 +366,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -376,7 +380,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -395,7 +398,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -436,9 +438,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.0.tgz", - "integrity": "sha512-q4d82GTl8BIlh/dTnVsWmxnbWJeb3kiU8eUH71UxlxnS+WIaALmtzTL8gR15PkYOexMQYVk0CO4qIG93C1IvPA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.1.tgz", + "integrity": "sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q==", "dev": true, "funding": [ { @@ -511,9 +513,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.26", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", - "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", "dev": true, "funding": [ { @@ -1215,9 +1217,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", - "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.13.0.tgz", + "integrity": "sha512-VnfL2lS43Z9F8li1faMH9hDZwqfrF5JvOePmrF8oESfo0ijaujnT81zYtienQRpoFa+FJbq0E5rrnMWEW73gOw==", "dev": true, "license": "MIT", "engines": { @@ -1811,11 +1813,27 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1826,7 +1844,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1837,24 +1854,32 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1888,9 +1913,9 @@ } }, "node_modules/@neondatabase/serverless/node_modules/@types/node": { - "version": "22.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz", - "integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==", + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2088,217 +2113,726 @@ "node": ">=12.4.0" } }, - "node_modules/@parcel/watcher": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", - "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "hasInstallScript": true, - "license": "MIT", + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "license": "Apache-2.0", "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" + "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" + "node": ">=8.0.0" } }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.0.tgz", + "integrity": "sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw==", + "license": "Apache-2.0", "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@opentelemetry/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", + "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@opentelemetry/instrumentation": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz", + "integrity": "sha512-fjpQtH18J6GxzUZ+cwNhWUpb71u+DzT7rFkg5pLssDGaEber91Y2WNGdpVpwGivfEluMlNMZumzjEqfg8DeKXQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.54.0.tgz", + "integrity": "sha512-43RmbhUhqt3uuPnc16cX6NsxEASEtn8z/cYV8Zpt6EP4p2h9s4FNuJ4Q9BbEQ2C0YlCCB/2crO1ruVz/hWt8fA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.28.0.tgz", + "integrity": "sha512-ExXGBp0sUj8yhm6Znhf9jmuOaGDsYfDES3gswZnKr4MCqoBWQdEFn6EoDdt5u+RdbxQER+t43FoUihEfTSqsjA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.59.0.tgz", + "integrity": "sha512-pMKV/qnHiW/Q6pmbKkxt0eIhuNEtvJ7sUAyee192HErlr+a1Jx+FZ3WjfmzhQL1geewyGEiPGkmjjAgNY8TgDA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.30.0.tgz", + "integrity": "sha512-n3Cf8YhG7reaj5dncGlRIU7iT40bxPOjsBEA5Bc1a1g6e9Qvb+JFJ7SEiMlPbUw4PBmxE3h40ltE8LZ3zVt6OA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.54.0.tgz", + "integrity": "sha512-8dXMBzzmEdXfH/wjuRvcJnUFeWzZHUnExkmFJ2uPfa31wmpyBCMxO59yr8f/OXXgSogNgi/uPo9KW9H7LMIZ+g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.58.0.tgz", + "integrity": "sha512-+yWVVY7fxOs3j2RixCbvue8vUuJ1inHxN2q1sduqDB0Wnkr4vOzVKRYl/Zy7B31/dcPS72D9lo/kltdOTBM3bQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.57.0.tgz", + "integrity": "sha512-Os4THbvls8cTQTVA8ApLfZZztuuqGEeqog0XUnyRW7QVF0d/vOVBEcBCk1pazPFmllXGEdNbbat8e2fYIWdFbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.211.0.tgz", + "integrity": "sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.0", + "@opentelemetry/instrumentation": "0.211.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.59.0.tgz", + "integrity": "sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.20.0.tgz", + "integrity": "sha512-yJXOuWZROzj7WmYCUiyT27tIfqBrVtl1/TwVbQyWPz7rL0r1Lu7kWjD0PiVeTCIL6CrIZ7M2s8eBxsTAOxbNvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.55.0.tgz", + "integrity": "sha512-FtTL5DUx5Ka/8VK6P1VwnlUXPa3nrb7REvm5ddLUIeXXq4tb9pKd+/ThB1xM/IjefkRSN3z8a5t7epYw1JLBJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.59.0.tgz", + "integrity": "sha512-K9o2skADV20Skdu5tG2bogPKiSpXh4KxfLjz6FuqIVvDJNibwSdu5UvyyBzRVp1rQMV6UmoIk6d3PyPtJbaGSg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.55.0.tgz", + "integrity": "sha512-FDBfT7yDGcspN0Cxbu/k8A0Pp1Jhv/m7BMTzXGpcb8ENl3tDj/51U65R5lWzUH15GaZA15HQ5A5wtafklxYj7g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.64.0.tgz", + "integrity": "sha512-pFlCJjweTqVp7B220mCvCld1c1eYKZfQt1p3bxSbcReypKLJTwat+wbL2YZoX9jPi5X2O8tTKFEOahO5ehQGsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.57.0.tgz", + "integrity": "sha512-MthiekrU/BAJc5JZoZeJmo0OTX6ycJMiP6sMOSRTkvz5BrPMYDqaJos0OgsLPL/HpcgHP7eo5pduETuLguOqcg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz", + "integrity": "sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz", + "integrity": "sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.63.0.tgz", + "integrity": "sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.7" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg/node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.59.0.tgz", + "integrity": "sha512-JKv1KDDYA2chJ1PC3pLP+Q9ISMQk6h5ey+99mB57/ARk0vQPGZTTEb4h4/JlcEpy7AYT8HIGv7X6l+br03Neeg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.30.0.tgz", + "integrity": "sha512-bZy9Q8jFdycKQ2pAsyuHYUHNmCxCOGdG6eg1Mn75RvQDccq832sU5OWOBnc12EFUELI6icJkhR7+EQKMBam2GA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.21.0.tgz", + "integrity": "sha512-gok0LPUOTz2FQ1YJMZzaHcOzDFyT64XJ8M9rNkugk923/p6lDGms/cRW1cqgqp6N6qcd6K6YdVHwPEhnx9BWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", + "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", + "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", + "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.0", + "@opentelemetry/resources": "2.5.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", + "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" } @@ -2383,16 +2917,14 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "license": "MIT", + "optional": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=14" } }, "node_modules/@portabletext/react": { @@ -2432,6 +2964,47 @@ "node": ">=20.19 <22 || >=22.12" } }, + "node_modules/@prisma/instrumentation": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", + "integrity": "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.207.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", + "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", + "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.207.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2846,6 +3419,54 @@ } } }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", + "integrity": "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -2853,7 +3474,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2867,7 +3487,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2881,7 +3500,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2895,7 +3513,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2909,7 +3526,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2923,7 +3539,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2937,7 +3552,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2951,7 +3565,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2965,7 +3578,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2979,7 +3591,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2993,7 +3604,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3007,7 +3617,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3021,7 +3630,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3035,7 +3643,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3049,7 +3656,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3063,7 +3669,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3077,7 +3682,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3091,7 +3695,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3105,7 +3708,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3119,7 +3721,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3133,7 +3734,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3147,7 +3747,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3161,7 +3760,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3175,7 +3773,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3189,7 +3786,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3236,15 +3832,470 @@ "integrity": "sha512-pYRhti+lDi22it+npWXkEGuYyzbXJLF+d0TYLiyWbKu46JHhYhTDKkp6zmGu4YKF5cXUjT6pnUjFsaf2vlB9nQ==", "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=10.0.0" + } + }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.38.0.tgz", + "integrity": "sha512-UOJtYmdcxHCcV0NPfXFff/a95iXl/E0EhuQ1y0uE0BuZDMupWSF5t2BgC4HaE5Aw3RTjDF3XkSHWoIF6ohy7eA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.38.0.tgz", + "integrity": "sha512-JXneg9zRftyfy1Fyfc39bBlF/Qd8g4UDublFFkVvdc1S6JQPlK+P6q22DKz3Pc8w3ySby+xlIq/eTu9Pzqi4KA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.38.0.tgz", + "integrity": "sha512-YWIkL6/dnaiQyFiZXJ/nN+NXGv/15z45ia86bE/TMq01CubX/DUOilgsFz0pk2v/pg3tp/U2MskLO9Hz0cnqeg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.38.0", + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.38.0.tgz", + "integrity": "sha512-OXWM9jEqNYh4VTvrMu7v+z1anz+QKQ/fZXIZdsO7JTT2lGNZe58UUMeoq386M+Saxen8F9SUH7yTORy/8KI5qw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.38.0", + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.9.1.tgz", + "integrity": "sha512-0gEoi2Lb54MFYPOmdTfxlNKxI7kCOvNV7gP8lxMXJ7nCazF5OqOOZIVshfWjDLrc0QrSV6XdVvwPV9GDn4wBMg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/browser": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.38.0.tgz", + "integrity": "sha512-3phzp1YX4wcQr9mocGWKbjv0jwtuoDBv7+Y6Yfrys/kwyaL84mDLjjQhRf4gL5SX7JdYkhBp4WaiNlR0UC4kTA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.38.0", + "@sentry-internal/feedback": "10.38.0", + "@sentry-internal/replay": "10.38.0", + "@sentry-internal/replay-canvas": "10.38.0", + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugin-core": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.9.1.tgz", + "integrity": "sha512-moii+w7N8k8WdvkX7qCDY9iRBlhgHlhTHTUQwF2FNMhBHuqlNpVcSJJqJMjFUQcjYMBDrZgxhfKV18bt5ixwlQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/babel-plugin-component-annotate": "4.9.1", + "@sentry/cli": "^2.57.0", + "dotenv": "^16.3.1", + "find-up": "^5.0.0", + "glob": "^10.5.0", + "magic-string": "0.30.8", + "unplugin": "1.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/magic-string": { + "version": "0.30.8", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", + "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.4.tgz", + "integrity": "sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.4", + "@sentry/cli-linux-arm": "2.58.4", + "@sentry/cli-linux-arm64": "2.58.4", + "@sentry/cli-linux-i686": "2.58.4", + "@sentry/cli-linux-x64": "2.58.4", + "@sentry/cli-win32-arm64": "2.58.4", + "@sentry/cli-win32-i686": "2.58.4", + "@sentry/cli-win32-x64": "2.58.4" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.4.tgz", + "integrity": "sha512-kbTD+P4X8O+nsNwPxCywtj3q22ecyRHWff98rdcmtRrvwz8CKi/T4Jxn/fnn2i4VEchy08OWBuZAqaA5Kh2hRQ==", + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.4.tgz", + "integrity": "sha512-rdQ8beTwnN48hv7iV7e7ZKucPec5NJkRdrrycMJMZlzGBPi56LqnclgsHySJ6Kfq506A2MNuQnKGaf/sBC9REA==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.4.tgz", + "integrity": "sha512-0g0KwsOozkLtzN8/0+oMZoOuQ0o7W6O+hx+ydVU1bktaMGKEJLMAWxOQNjsh1TcBbNIXVOKM/I8l0ROhaAb8Ig==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.4.tgz", + "integrity": "sha512-NseoIQAFtkziHyjZNPTu1Gm1opeQHt7Wm1LbLrGWVIRvUOzlslO9/8i6wETUZ6TjlQxBVRgd3Q0lRBG2A8rFYA==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.4.tgz", + "integrity": "sha512-d3Arz+OO/wJYTqCYlSN3Ktm+W8rynQ/IMtSZLK8nu0ryh5mJOh+9XlXY6oDXw4YlsM8qCRrNquR8iEI1Y/IH+Q==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.4.tgz", + "integrity": "sha512-bqYrF43+jXdDBh0f8HIJU3tbvlOFtGyRjHB8AoRuMQv9TEDUfENZyCelhdjA+KwDKYl48R1Yasb4EHNzsoO83w==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.4.tgz", + "integrity": "sha512-3triFD6jyvhVcXOmGyttf+deKZcC1tURdhnmDUIBkiDPJKGT/N5xa4qAtHJlAB/h8L9jgYih9bvJnvvFVM7yug==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.4.tgz", + "integrity": "sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/core": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.38.0.tgz", + "integrity": "sha512-1pubWDZE5y5HZEPMAZERP4fVl2NH3Ihp1A+vMoVkb3Qc66Diqj1WierAnStlZP7tCx0TBa0dK85GTW/ZFYyB9g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/nextjs": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.38.0.tgz", + "integrity": "sha512-MW2f6mK54jFyS/lmJxT7GWr5d12E+3qvIhR5EdjdyzMX8udSOCGyFJaFIwUfMyEMuggPEvNQVFFpjIrvWXCSGA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/semantic-conventions": "^1.37.0", + "@rollup/plugin-commonjs": "28.0.1", + "@sentry-internal/browser-utils": "10.38.0", + "@sentry/bundler-plugin-core": "^4.8.0", + "@sentry/core": "10.38.0", + "@sentry/node": "10.38.0", + "@sentry/opentelemetry": "10.38.0", + "@sentry/react": "10.38.0", + "@sentry/vercel-edge": "10.38.0", + "@sentry/webpack-plugin": "^4.8.0", + "rollup": "^4.35.0", + "stacktrace-parser": "^0.1.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0" + } + }, + "node_modules/@sentry/node": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.38.0.tgz", + "integrity": "sha512-wriyDtWDAoatn8EhOj0U4PJR1WufiijTsCGALqakOHbFiadtBJANLe6aSkXoXT4tegw59cz1wY4NlzHjYksaPw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.5.0", + "@opentelemetry/core": "^2.5.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/instrumentation-amqplib": "0.58.0", + "@opentelemetry/instrumentation-connect": "0.54.0", + "@opentelemetry/instrumentation-dataloader": "0.28.0", + "@opentelemetry/instrumentation-express": "0.59.0", + "@opentelemetry/instrumentation-fs": "0.30.0", + "@opentelemetry/instrumentation-generic-pool": "0.54.0", + "@opentelemetry/instrumentation-graphql": "0.58.0", + "@opentelemetry/instrumentation-hapi": "0.57.0", + "@opentelemetry/instrumentation-http": "0.211.0", + "@opentelemetry/instrumentation-ioredis": "0.59.0", + "@opentelemetry/instrumentation-kafkajs": "0.20.0", + "@opentelemetry/instrumentation-knex": "0.55.0", + "@opentelemetry/instrumentation-koa": "0.59.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.55.0", + "@opentelemetry/instrumentation-mongodb": "0.64.0", + "@opentelemetry/instrumentation-mongoose": "0.57.0", + "@opentelemetry/instrumentation-mysql": "0.57.0", + "@opentelemetry/instrumentation-mysql2": "0.57.0", + "@opentelemetry/instrumentation-pg": "0.63.0", + "@opentelemetry/instrumentation-redis": "0.59.0", + "@opentelemetry/instrumentation-tedious": "0.30.0", + "@opentelemetry/instrumentation-undici": "0.21.0", + "@opentelemetry/resources": "^2.5.0", + "@opentelemetry/sdk-trace-base": "^2.5.0", + "@opentelemetry/semantic-conventions": "^1.39.0", + "@prisma/instrumentation": "7.2.0", + "@sentry/core": "10.38.0", + "@sentry/node-core": "10.38.0", + "@sentry/opentelemetry": "10.38.0", + "import-in-the-middle": "^2.0.6", + "minimatch": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.38.0.tgz", + "integrity": "sha512-ErXtpedrY1HghgwM6AliilZPcUCoNNP1NThdO4YpeMq04wMX9/GMmFCu46TnCcg6b7IFIOSr2S4yD086PxLlHQ==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/tracing-hooks": "^0.3.1", + "@sentry/core": "10.38.0", + "@sentry/opentelemetry": "10.38.0", + "import-in-the-middle": "^2.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.38.0.tgz", + "integrity": "sha512-YPVhWfYmC7nD3EJqEHGtjp4fp5LwtAbE5rt9egQ4hqJlYFvr8YEz9sdoqSZxO0cZzgs2v97HFl/nmWAXe52G2Q==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, + "node_modules/@sentry/react": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.38.0.tgz", + "integrity": "sha512-3UiKo6QsqTyPGUt0XWRY9KLaxc/cs6Kz4vlldBSOXEL6qPDL/EfpwNJT61osRo81VFWu8pKu7ZY2bvLPryrnBQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.38.0", + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/vercel-edge": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.38.0.tgz", + "integrity": "sha512-lElDFktj/PyRC/LDHejPFhQmHVMCB9Celj+IHi36aw96a/LekqF6/7vmp26hDtH58QtuiPO3h5voqEAMUOkSlw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/resources": "^2.5.0", + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/webpack-plugin": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-4.9.1.tgz", + "integrity": "sha512-Ssx2lHiq8VWywUGd/hmW3U3VYBC0Up7D6UzUiDAWvy18PbTCVszaa54fKMFEQ1yIBg/ePRET53pIzfkcZgifmQ==", + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "4.9.1", + "unplugin": "1.0.1", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "webpack": ">=4.40.0" } }, - "node_modules/@schummar/icu-type-parser": { - "version": "1.21.5", - "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", - "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3848,6 +4899,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3855,11 +4915,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/event-source-polyfill": { @@ -3887,7 +4968,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -3915,10 +4995,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { - "version": "20.19.32", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz", - "integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==", + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3955,16 +5044,25 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/pg-pool": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", + "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, "node_modules/@types/prismjs": { - "version": "1.26.5", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", - "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", - "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3981,18 +5079,27 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -4005,7 +5112,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", + "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -4021,16 +5128,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "engines": { @@ -4046,14 +5153,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "engines": { @@ -4068,14 +5175,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4086,9 +5193,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", "dev": true, "license": "MIT", "engines": { @@ -4103,15 +5210,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -4128,9 +5235,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", "dev": true, "license": "MIT", "engines": { @@ -4142,16 +5249,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", @@ -4169,22 +5276,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4199,16 +5290,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4223,13 +5314,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/types": "8.55.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -4666,6 +5757,16 @@ } } }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/@vitest/pretty-format": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", @@ -4732,6 +5833,181 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4748,7 +6024,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4757,6 +6032,28 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -4768,13 +6065,15 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", + "dependencies": { + "debug": "4" + }, "engines": { - "node": ">= 14" + "node": ">= 6.0.0" } }, "node_modules/agentkeepalive": { @@ -4806,11 +6105,52 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4836,6 +6176,31 @@ "dev": true, "license": "MIT" }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5052,6 +6417,16 @@ "js-tokens": "^10.0.0" } }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -5115,7 +6490,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -5146,11 +6520,22 @@ "require-from-string": "^2.0.2" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5160,7 +6545,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -5173,7 +6557,6 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5209,6 +6592,13 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT", + "peer": true + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5323,6 +6713,58 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5383,7 +6825,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5396,7 +6837,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -5421,6 +6861,12 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5432,14 +6878,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5488,9 +6932,9 @@ } }, "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5607,7 +7051,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5801,6 +7244,56 @@ "drizzle-kit": "index.js" } }, + "node_modules/drizzle-kit/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/drizzle-kit/node_modules/glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/drizzle-kit/node_modules/minimatch": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", + "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/drizzle-orm": { "version": "0.45.1", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", @@ -5940,6 +7433,12 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -5953,21 +7452,18 @@ "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", - "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -6631,7 +8127,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7226,7 +8721,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -7239,21 +8733,16 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", @@ -7291,6 +8780,16 @@ "node": ">=6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -7324,7 +8823,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -7371,6 +8869,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7381,6 +8896,23 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7398,7 +8930,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -7411,7 +8942,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -7481,6 +9011,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -7516,13 +9062,19 @@ "node": ">= 12.20" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, "node_modules/framer-motion": { - "version": "12.33.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.33.0.tgz", - "integrity": "sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==", + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz", + "integrity": "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.33.0", + "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, @@ -7554,7 +9106,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7619,7 +9170,6 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7698,9 +9248,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.5", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.5.tgz", - "integrity": "sha512-v4/4xAEpBRp6SvCkWhnGCaLkJf9IwWzrsygJPxD/+p2/xPE3C5m2fA9FD0Ry9tG+Rqqq3gBzHSl6y1/T9V/tMQ==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", "dev": true, "license": "MIT", "dependencies": { @@ -7711,21 +9261,21 @@ } }, "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">=12" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7744,18 +9294,12 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause", + "peer": true }, "node_modules/globals": { "version": "14.0.0", @@ -7803,7 +9347,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/groq": { @@ -7896,7 +9439,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8028,18 +9570,27 @@ "node": ">= 14" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", + "agent-base": "6", "debug": "4" }, "engines": { - "node": ">= 14" + "node": ">= 6" } }, "node_modules/humanize-ms": { @@ -8093,6 +9644,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8219,6 +9782,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -8348,6 +9923,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -8410,7 +9994,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8447,6 +10030,15 @@ "dev": true, "license": "MIT" }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8622,7 +10214,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -8682,6 +10273,52 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -8751,11 +10388,34 @@ } } }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -8789,6 +10449,13 @@ "node": "*" } }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT", + "peer": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8807,7 +10474,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -9192,11 +10858,24 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -9286,7 +10965,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -9325,7 +11003,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -9415,6 +11092,13 @@ "node": ">=0.12" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -9439,6 +11123,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -9483,16 +11180,15 @@ } }, "node_modules/minimatch": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", - "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", - "dev": true, + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -9508,6 +11204,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -9521,10 +11226,16 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/motion-dom": { - "version": "12.33.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.33.0.tgz", - "integrity": "sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==", + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.0.tgz", + "integrity": "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==", "license": "MIT", "dependencies": { "motion-utils": "^12.29.2" @@ -9592,6 +11303,13 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true + }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", @@ -9848,7 +11566,6 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, "license": "MIT" }, "node_modules/nodemailer": { @@ -9860,6 +11577,15 @@ "node": ">=6.0.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10042,7 +11768,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -10058,7 +11783,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -10070,6 +11794,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10113,7 +11843,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10123,7 +11852,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10136,6 +11864,28 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -10239,13 +11989,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -10492,6 +12241,15 @@ "react": ">=16.0.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -10509,6 +12267,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10555,6 +12319,16 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -10597,6 +12371,30 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10659,12 +12457,24 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -10721,7 +12531,6 @@ "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -10889,16 +12698,82 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -11010,7 +12885,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -11023,7 +12897,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11108,6 +12981,18 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -11177,6 +13062,18 @@ "dev": true, "license": "MIT" }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -11207,6 +13104,56 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -11321,26 +13268,43 @@ } }, "node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dev": true, + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { - "ansi-regex": "^2.0.0" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/strip-bom": { @@ -11479,14 +13443,95 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "peer": true + }, + "node_modules/terser/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" } }, "node_modules/through2": { @@ -11546,37 +13591,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -11588,22 +13602,22 @@ } }, "node_modules/tldts": { - "version": "7.0.22", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", - "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.22" + "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.22", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.22.tgz", - "integrity": "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "dev": true, "license": "MIT" }, @@ -11611,7 +13625,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -11683,6 +13696,16 @@ "ts-node": "dist/bin.js" } }, + "node_modules/ts-node/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ts-node/node_modules/ansi-styles": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", @@ -11720,6 +13743,19 @@ "node": ">=0.8.0" } }, + "node_modules/ts-node/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ts-node/node_modules/supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", @@ -11933,6 +13969,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -12026,16 +14071,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", - "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0" + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12080,6 +14125,18 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unplugin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.0.1.tgz", + "integrity": "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.8.1", + "chokidar": "^3.5.3", + "webpack-sources": "^3.2.3", + "webpack-virtual-modules": "^0.5.0" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -12119,7 +14176,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, "funding": [ { "type": "opencollective", @@ -12196,6 +14252,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8flags": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.1.1.tgz", @@ -12359,37 +14428,6 @@ "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", @@ -12468,19 +14506,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -12494,6 +14519,20 @@ "node": ">=18" } }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/web-streams-polyfill": { "version": "4.0.0-beta.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", @@ -12513,6 +14552,101 @@ "node": ">=20" } }, + "node_modules/webpack": { + "version": "5.105.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.1.tgz", + "integrity": "sha512-Gdj3X74CLJJ8zy4URmK42W7wTZUJrqL+z8nyGEr4dTN0kb3nVs+ZvjbTOqRYPD7qX4tUmwyHL9Q9K6T1seW6Yw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz", + "integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==", + "license": "MIT" + }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT", + "peer": true + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -12541,7 +14675,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -12676,6 +14809,100 @@ "dev": true, "license": "MIT" }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -12735,7 +14962,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, "license": "ISC" }, "node_modules/yn": { @@ -12755,7 +14981,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/frontend/package.json b/frontend/package.json index d8c72276..6898d2db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.5.6", + "version": "0.5.7", "private": true, "scripts": { "dev": "next dev", @@ -30,6 +30,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@sanity/client": "^7.12.1", "@sanity/image-url": "^1.2.0", + "@sentry/nextjs": "^10.10.0", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.0", "@upstash/redis": "^1.36.1", @@ -61,7 +62,7 @@ "zod": "^3.24.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4", + "@tailwindcss/postcss": "^4.1.18", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.1", diff --git a/frontend/proxy.ts b/frontend/proxy.ts index b53be269..ab2d29c7 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import createIntlMiddleware from 'next-intl/middleware'; +import { defaultLocale, locales } from '@/i18n/config'; import { AuthTokenPayload } from '@/lib/auth'; import { routing } from './i18n/routing'; @@ -52,6 +53,13 @@ function isAuthenticated(req: NextRequest): boolean { const intlMiddleware = createIntlMiddleware(routing); +function resolveLocaleFromPathname(pathname: string): string { + const maybeLocale = pathname.split('/')[1]; + return locales.includes(maybeLocale as (typeof locales)[number]) + ? maybeLocale + : defaultLocale; +} + function authMiddleware(req: NextRequest) { const { pathname } = req.nextUrl; const authenticated = isAuthenticated(req); @@ -61,7 +69,7 @@ function authMiddleware(req: NextRequest) { if (pathnameWithoutLocale.startsWith('/dashboard')) { if (!authenticated) { - const locale = pathname.split('/')[1] || 'en'; + const locale = resolveLocaleFromPathname(pathname); return NextResponse.redirect(new URL(`/${locale}/login`, req.url)); } } @@ -81,7 +89,7 @@ export function proxy(req: NextRequest) { return NextResponse.redirect(new URL('/en', req.url)); } - const locale = req.nextUrl.pathname.split('/')[1] || 'en'; + const locale = resolveLocaleFromPathname(req.nextUrl.pathname); const authResponse = authMiddleware(req); if (authResponse) return authResponse; diff --git a/frontend/scripts/verify-monobank-b3.ts b/frontend/scripts/verify-monobank-b3.ts new file mode 100644 index 00000000..e53f14df --- /dev/null +++ b/frontend/scripts/verify-monobank-b3.ts @@ -0,0 +1,98 @@ +import { and, eq, sql } from 'drizzle-orm'; + +import { db } from '@/db'; +import { productPrices, products } from '@/db/schema'; + +type IndexRow = { indexname?: string }; + +function normalizeIndexRows(result: unknown): string[] { + const rows = (result as { rows?: IndexRow[] })?.rows ?? []; + return rows + .map(r => (typeof r.indexname === 'string' ? r.indexname : '')) + .filter(Boolean); +} + +async function verifyUahPrices() { + const rows = await db + .select({ + id: products.id, + slug: products.slug, + title: products.title, + priceMinor: productPrices.priceMinor, + }) + .from(products) + .leftJoin( + productPrices, + and( + eq(productPrices.productId, products.id), + eq(productPrices.currency, 'UAH') + ) + ) + .where(eq(products.isActive, true)); + + const missing = rows.filter(row => { + const v = row.priceMinor; + if (v === null || v === undefined) return true; + if (typeof v === 'number') return v < 0; + if (typeof v === 'bigint') return v < BigInt(0); + + return true; + }); + + if (missing.length === 0) { + console.log('OK: All active products have UAH price rows.'); + return true; + } + + console.error('FAIL: Missing/invalid UAH prices for active products:'); + for (const row of missing) { + console.error( + `- id=${row.id} slug=${row.slug ?? 'n/a'} title=${row.title ?? 'n/a'}` + ); + } + return false; +} + +async function verifyIndexes() { + const res = await db.execute(sql` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'payment_attempts' + AND indexname IN ( + 'payment_attempts_order_provider_active_unique', + 'payment_attempts_provider_status_updated_idx' + ); + `); + + const existing = new Set(normalizeIndexRows(res)); + const required = [ + 'payment_attempts_order_provider_active_unique', + 'payment_attempts_provider_status_updated_idx', + ]; + + const missing = required.filter(name => !existing.has(name)); + if (missing.length === 0) { + console.log('OK: Required payment_attempts indexes are present.'); + return true; + } + + console.error( + `FAIL: Missing payment_attempts indexes: ${missing.join(', ')}` + ); + return false; +} + +async function main() { + const okPrices = await verifyUahPrices(); + const okIndexes = await verifyIndexes(); + + if (!okPrices || !okIndexes) { + process.exitCode = 1; + } +} + +main().catch(err => { + console.error('B3 verification failed:', err); + process.exitCode = 1; +}); diff --git a/frontend/sentry.edge.config.ts b/frontend/sentry.edge.config.ts new file mode 100644 index 00000000..e52e91bb --- /dev/null +++ b/frontend/sentry.edge.config.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/nextjs'; + +const isProduction = + process.env.VERCEL_ENV === 'production' || + process.env.NODE_ENV === 'production'; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + enabled: isProduction, + + tracesSampleRate: 0.1, + + environment: process.env.VERCEL_ENV || process.env.NODE_ENV, + release: process.env.VERCEL_GIT_COMMIT_SHA, + + sendDefaultPii: false, +}); diff --git a/frontend/sentry.server.config.ts b/frontend/sentry.server.config.ts new file mode 100644 index 00000000..e52e91bb --- /dev/null +++ b/frontend/sentry.server.config.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/nextjs'; + +const isProduction = + process.env.VERCEL_ENV === 'production' || + process.env.NODE_ENV === 'production'; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + enabled: isProduction, + + tracesSampleRate: 0.1, + + environment: process.env.VERCEL_ENV || process.env.NODE_ENV, + release: process.env.VERCEL_GIT_COMMIT_SHA, + + sendDefaultPii: false, +}); diff --git a/frontend/types/quiz.ts b/frontend/types/quiz.ts index 28e60e84..dc037a0b 100644 --- a/frontend/types/quiz.ts +++ b/frontend/types/quiz.ts @@ -26,25 +26,23 @@ export interface UserLastAttempt { categoryName: string | null; score: number; totalQuestions: number; - percentage: string; + percentage: number | string; pointsEarned: number; integrityScore: number | null; completedAt: Date; } -export interface AttemptAnswerDetail { - id: string; - answerText: string | null; - isCorrect: boolean; - isSelected: boolean; -} - export interface AttemptQuestionDetail { questionId: string; questionText: string | null; explanation: any; - answers: AttemptAnswerDetail[]; selectedAnswerId: string | null; + answers: Array<{ + id: string; + answerText: string | null; + isCorrect: boolean; + isSelected: boolean; + }>; } export interface AttemptReview { @@ -54,9 +52,9 @@ export interface AttemptReview { categorySlug: string | null; score: number; totalQuestions: number; - percentage: string; + percentage: number | string; pointsEarned: number; integrityScore: number | null; completedAt: Date; incorrectQuestions: AttemptQuestionDetail[]; -} \ No newline at end of file +} diff --git a/netlify.toml b/netlify.toml index 7c529ca7..59f0b388 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,6 @@ [build] base = "frontend" - command = "npm run build" + command = "npm ci --include=optional && npm run build" publish = ".next" [functions] diff --git a/studio/package-lock.json b/studio/package-lock.json index 133241ac..24969a9f 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -1,12 +1,12 @@ { "name": "devlovers", - "version": "0.5.6", + "version": "0.5.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devlovers", - "version": "0.5.6", + "version": "0.5.7", "license": "UNLICENSED", "dependencies": { "@sanity/orderable-document-list": "^1.4.2", @@ -1967,9 +1967,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz", - "integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==", + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz", + "integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -2051,9 +2051,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.39.12", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.12.tgz", - "integrity": "sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ==", + "version": "6.39.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz", + "integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", @@ -3924,9 +3924,9 @@ } }, "node_modules/@portabletext/editor": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/@portabletext/editor/-/editor-3.3.18.tgz", - "integrity": "sha512-K+504jYuodi4b4C+U1SibeHELIBsTDwF30pUZd2IAKp4+avfzpvgX5qKxtPg3aW8z++L3U5Y33ggC9IbJDIVOg==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/@portabletext/editor/-/editor-3.3.19.tgz", + "integrity": "sha512-8xN7gxZ+03QbgEVhJjqMFNmc5fsgHUIfRwm/DIjT7QmfBDkfQnhJTb1/q7/XIfGjt6iLpTk6KTdJWwE6slS0eQ==", "license": "MIT", "dependencies": { "@portabletext/block-tools": "^4.1.11", @@ -4174,9 +4174,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -4533,9 +4533,9 @@ } }, "node_modules/@sanity/blueprints-parser": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@sanity/blueprints-parser/-/blueprints-parser-0.3.0.tgz", - "integrity": "sha512-kS/MU3r71MXExzatvP6lCO7J/mhnmxO2qSsC+/j+YXm1qZo9BoXTRMsC8f0M/Hi5r+1i/l/6NSk3RUsNEtHAyg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@sanity/blueprints-parser/-/blueprints-parser-0.3.1.tgz", + "integrity": "sha512-MdZ7JzMalB2qjQpgNUHupnY6f+QvkV5cGbqL75ySJbXPpICKPJWLQsrPwJLA0E3ZRCO3MnE6/6zrEayQm8Hhew==", "license": "MIT", "engines": { "node": ">=20.19 <22 || >=22.12" @@ -4871,9 +4871,9 @@ } }, "node_modules/@sanity/import/node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz", + "integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.1.2", @@ -4888,9 +4888,9 @@ } }, "node_modules/@sanity/import/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -6542,9 +6542,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", - "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -6557,15 +6557,15 @@ "license": "MIT" }, "node_modules/@types/prismjs": { - "version": "1.26.5", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", - "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", - "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6639,17 +6639,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -6662,7 +6662,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", + "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -6678,16 +6678,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "engines": { @@ -6703,14 +6703,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "engines": { @@ -6725,14 +6725,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6743,9 +6743,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", "dev": true, "license": "MIT", "engines": { @@ -6760,15 +6760,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -6785,9 +6785,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", "dev": true, "license": "MIT", "engines": { @@ -6799,16 +6799,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", @@ -6866,16 +6866,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6890,13 +6890,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/types": "8.55.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -6967,15 +6967,15 @@ "license": "Apache-2.0" }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", - "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", "license": "MIT", "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.2", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -10021,12 +10021,12 @@ } }, "node_modules/framer-motion": { - "version": "12.33.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.33.0.tgz", - "integrity": "sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==", + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz", + "integrity": "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.33.0", + "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, @@ -12976,12 +12976,12 @@ "license": "MIT" }, "node_modules/motion": { - "version": "12.33.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.33.0.tgz", - "integrity": "sha512-TcND7PijsrTeIA9SRVUB8TOJQ+6mJnJ5K4a9oAJZvyI0Zy47Gq5oofU+VkTxbLcvDoKXnHspQcII2mnk3TbFsQ==", + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.34.0.tgz", + "integrity": "sha512-01Sfa/zgsD/di8zA/uFW5Eb7/SPXoGyUfy+uMRMW5Spa8j0z/UbfQewAYvPMYFCXRlyD6e5aLHh76TxeeJD+RA==", "license": "MIT", "dependencies": { - "framer-motion": "^12.33.0", + "framer-motion": "^12.34.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -13002,9 +13002,9 @@ } }, "node_modules/motion-dom": { - "version": "12.33.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.33.0.tgz", - "integrity": "sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==", + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.0.tgz", + "integrity": "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==", "license": "MIT", "dependencies": { "motion-utils": "^12.29.2" @@ -14762,9 +14762,9 @@ } }, "node_modules/remeda": { - "version": "2.33.5", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.5.tgz", - "integrity": "sha512-FqmpPA9i9T5EGcqgyHf9kHjefnyCZM1M3kSdZjPk1j2StGNoJyoYp0807RYcjNkQ1UpsEQa5qzgsjLY4vYtT8g==", + "version": "2.33.6", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.6.tgz", + "integrity": "sha512-tazDGH7s75kUPGBKLvhgBEHMgW+TdDFhjUAMdQj57IoWz6HsGa5D2RX5yDUz6IIqiRRvZiaEHzCzWdTeixc/Kg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/remeda" @@ -15656,12 +15656,12 @@ } }, "node_modules/sanity/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/sanity/node_modules/parse-entities": { @@ -16602,9 +16602,9 @@ "license": "MIT" }, "node_modules/styled-components": { - "version": "6.3.8", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.8.tgz", - "integrity": "sha512-Kq/W41AKQloOqKM39zfaMdJ4BcYDw/N5CIq4/GTI0YjU6pKcZ1KKhk6b4du0a+6RA9pIfOP/eu94Ge7cu+PDCA==", + "version": "6.3.9", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.9.tgz", + "integrity": "sha512-J72R4ltw0UBVUlEjTzI0gg2STOqlI9JBhQOL4Dxt7aJOnnSesy0qJDn4PYfMCafk9cWOaVg129Pesl5o+DIh0Q==", "license": "MIT", "dependencies": { "@emotion/is-prop-valid": "1.4.0", @@ -17091,16 +17091,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", - "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0" + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/studio/package.json b/studio/package.json index 1b8ec7e2..187c5df0 100644 --- a/studio/package.json +++ b/studio/package.json @@ -1,7 +1,7 @@ { "name": "devlovers", "private": true, - "version": "0.5.6", + "version": "0.5.7", "main": "package.json", "license": "UNLICENSED", "scripts": {