diff --git a/frontend/.env.example b/frontend/.env.example index 4585bc49..6cabe34f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -66,3 +66,12 @@ GMAIL_USER= # --- Security CSRF_SECRET= + +CHECKOUT_RATE_LIMIT_MAX=10 +CHECKOUT_RATE_LIMIT_WINDOW_SECONDS=300 + +STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30 +STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60 + +# emergency switch +RATE_LIMIT_DISABLED=0 diff --git a/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx b/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx index 03ec21f9..c117697d 100644 --- a/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx @@ -11,6 +11,7 @@ import { db } from '@/db'; import { products, productPrices } from '@/db/schema'; import type { CurrencyCode } from '@/lib/shop/currency'; import { currencyValues } from '@/lib/shop/currency'; +import { issueCsrfToken } from '@/lib/security/csrf'; export const dynamic = 'force-dynamic'; @@ -75,6 +76,7 @@ export default async function EditProductPage({ : parseMajorToMinor(product.originalPrice), }, ]; + const csrfToken = issueCsrfToken('admin:products:update'); return ( <> @@ -83,6 +85,7 @@ export default async function EditProductPage({ & { imageUrl?: string }; + csrfToken: string; }; type ApiResponse = { @@ -128,6 +129,7 @@ export function ProductForm({ mode, productId, initialValues, + csrfToken, }: ProductFormProps) { const router = useRouter(); @@ -374,6 +376,11 @@ export function ProductForm({ if (imageFile) { formData.append('image', imageFile); } + if (!csrfToken) { + setError('Security token missing. Refresh the page and retry.'); + setIsSubmitting(false); + return; + } const response = await fetch( mode === 'create' @@ -381,6 +388,9 @@ export function ProductForm({ : `/api/shop/admin/products/${productId}`, { method: mode === 'create' ? 'POST' : 'PATCH', + headers: { + 'x-csrf-token': csrfToken, + }, body: formData, } ); @@ -411,6 +421,13 @@ export function ProductForm({ setError(data.error ?? msg); return; } + if ( + response.status === 403 && + (data.code === 'CSRF_MISSING' || data.code === 'CSRF_INVALID') + ) { + setError('Security token expired. Refresh the page and retry.'); + return; + } setError( data.error ?? diff --git a/frontend/app/[locale]/shop/admin/products/new/page.tsx b/frontend/app/[locale]/shop/admin/products/new/page.tsx index f595d767..b40a26d0 100644 --- a/frontend/app/[locale]/shop/admin/products/new/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/new/page.tsx @@ -1,6 +1,7 @@ // frontend/app/[locale]/shop/admin/products/new/page.tsx import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; +import { issueCsrfToken } from '@/lib/security/csrf'; import { ProductForm } from '../_components/product-form'; @@ -8,12 +9,13 @@ export const dynamic = 'force-dynamic'; export default async function NewProductPage() { await guardShopAdminPage(); + const csrfToken = issueCsrfToken('admin:products:create'); return ( <>
- +
); diff --git a/frontend/app/[locale]/shop/admin/products/page.tsx b/frontend/app/[locale]/shop/admin/products/page.tsx index ee914370..eef347e9 100644 --- a/frontend/app/[locale]/shop/admin/products/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/page.tsx @@ -1,7 +1,7 @@ // frontend/app/[locale]/shop/admin/products/page.tsx import { Link } from '@/i18n/routing'; import { and, desc, eq } from 'drizzle-orm'; - +import { issueCsrfToken } from '@/lib/security/csrf'; import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; @@ -67,6 +67,7 @@ export default async function AdminProductsPage({ const hasNext = all.length > PAGE_SIZE; const rows = all.slice(0, PAGE_SIZE); + const csrfTokenStatus = issueCsrfToken('admin:products:status'); return ( <> @@ -251,6 +252,7 @@ export default async function AdminProductsPage({ 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 5f402676..e21d6e4b 100644 --- a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts @@ -6,6 +6,7 @@ import { AdminUnauthorizedError, requireAdminApi, } from '@/lib/auth/admin'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { logError } from '@/lib/logging'; import { OrderNotFoundError, InvalidPayloadError } from '@/lib/services/errors'; @@ -18,6 +19,9 @@ export async function POST( ) { try { await requireAdminApi(request); + const csrfRes = requireAdminCsrf(request, 'admin:orders:refund'); + if (csrfRes) return csrfRes; + const rawParams = await context.params; const parsed = orderIdParamSchema.safeParse(rawParams); diff --git a/frontend/app/api/shop/admin/products/[id]/route.ts b/frontend/app/api/shop/admin/products/[id]/route.ts index e09333eb..78472581 100644 --- a/frontend/app/api/shop/admin/products/[id]/route.ts +++ b/frontend/app/api/shop/admin/products/[id]/route.ts @@ -12,6 +12,7 @@ import { SlugConflictError, PriceConfigError, } from '@/lib/services/errors'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm'; import { logError } from '@/lib/logging'; @@ -148,6 +149,13 @@ export async function PATCH( } const formData = await request.formData(); + const csrfRes = requireAdminCsrf( + request, + 'admin:products:update', + formData + ); + if (csrfRes) return csrfRes; + // PATCH inside PATCH() right after: const formData = await request.formData(); const saleViolationFromForm = getSaleViolationFromFormData(formData); @@ -305,6 +313,9 @@ export async function DELETE( ): Promise { try { await requireAdminApi(request); + const csrfRes = requireAdminCsrf(request, 'admin:products:delete'); + if (csrfRes) return csrfRes; + const rawParams = await context.params; const parsedParams = productIdParamSchema.safeParse(rawParams); diff --git a/frontend/app/api/shop/admin/products/[id]/status/route.ts b/frontend/app/api/shop/admin/products/[id]/status/route.ts index 716e2570..867b41be 100644 --- a/frontend/app/api/shop/admin/products/[id]/status/route.ts +++ b/frontend/app/api/shop/admin/products/[id]/status/route.ts @@ -6,6 +6,7 @@ import { AdminUnauthorizedError, requireAdminApi, } from '@/lib/auth/admin'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { logError } from '@/lib/logging'; import { toggleProductStatus } from '@/lib/services/products'; @@ -18,6 +19,8 @@ export async function PATCH( ) { try { await requireAdminApi(request); + const csrfRes = requireAdminCsrf(request, 'admin:products:status'); + if (csrfRes) return csrfRes; const rawParams = await context.params; const parsedParams = productIdParamSchema.safeParse(rawParams); diff --git a/frontend/app/api/shop/admin/products/route.ts b/frontend/app/api/shop/admin/products/route.ts index 090e6a49..18499eeb 100644 --- a/frontend/app/api/shop/admin/products/route.ts +++ b/frontend/app/api/shop/admin/products/route.ts @@ -6,6 +6,7 @@ import { AdminUnauthorizedError, requireAdminApi, } from '@/lib/auth/admin'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm'; import { logError } from '@/lib/logging'; @@ -82,6 +83,13 @@ export async function POST(request: NextRequest) { await requireAdminApi(request); const formData = await request.formData(); + const csrfRes = requireAdminCsrf( + request, + 'admin:products:create', + formData + ); + if (csrfRes) return csrfRes; + const imageFile = formData.get('image'); if (!(imageFile instanceof File) || imageFile.size === 0) { return NextResponse.json( diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index 7ed814db..b72ba0a1 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -1,5 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; - +import { + enforceRateLimit, + getClientIp, + rateLimitResponse, +} from '@/lib/security/rate-limit'; import { getCurrentUser } from '@/lib/auth'; import { isPaymentsEnabled } from '@/lib/env/stripe'; import { logError, logWarn } from '@/lib/logging'; @@ -227,6 +231,24 @@ export async function POST(request: NextRequest) { ); } } + // P1: rate limit checkout (cross-instance, DB-backed) + // Policy: allow reasonable retries; block abusive burst. + const checkoutSubject = sessionUserId ?? getClientIp(request) ?? 'anon'; + + const decision = await enforceRateLimit({ + key: `checkout:${checkoutSubject}`, + limit: Number(process.env.CHECKOUT_RATE_LIMIT_MAX ?? 10), + windowSeconds: Number( + process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? 300 + ), + }); + + if (!decision.ok) { + return rateLimitResponse({ + retryAfterSeconds: decision.retryAfterSeconds, + details: { scope: 'checkout' }, + }); + } try { const result = await createOrderWithItems({ diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 6bcc1f12..ef6a0d93 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -13,6 +13,11 @@ import { appendRefundToMeta, } from '@/lib/services/orders/psp-metadata/refunds'; import { markStripeAttemptFinal } from '@/lib/services/orders/payment-attempts'; +import { + enforceRateLimit, + getClientIp, + rateLimitResponse, +} from '@/lib/security/rate-limit'; const REFUND_FULLNESS_UNDETERMINED = 'REFUND_FULLNESS_UNDETERMINED' as const; @@ -312,6 +317,22 @@ export async function POST(request: NextRequest) { const signature = request.headers.get('stripe-signature'); if (!signature) { + const ip = getClientIp(request) ?? 'anon'; + const decision = await enforceRateLimit({ + key: `stripe_webhook:missing_sig:${ip}`, + limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30), + windowSeconds: Number( + process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60 + ), + }); + + if (!decision.ok) { + return rateLimitResponse({ + retryAfterSeconds: decision.retryAfterSeconds, + details: { scope: 'stripe_webhook', reason: 'missing_signature' }, + }); + } + logError( 'Stripe webhook missing signature header', new Error('MISSING_STRIPE_SIGNATURE') @@ -332,6 +353,22 @@ export async function POST(request: NextRequest) { error instanceof Error && error.message === 'STRIPE_INVALID_SIGNATURE' ) { + const ip = getClientIp(request) ?? 'anon'; + const decision = await enforceRateLimit({ + key: `stripe_webhook:invalid_sig:${ip}`, + limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30), + windowSeconds: Number( + process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60 + ), + }); + + if (!decision.ok) { + return rateLimitResponse({ + retryAfterSeconds: decision.retryAfterSeconds, + details: { scope: 'stripe_webhook', reason: 'invalid_signature' }, + }); + } + logError('Stripe webhook signature verification failed', error); return NextResponse.json({ code: 'INVALID_SIGNATURE' }, { status: 400 }); } diff --git a/frontend/components/shop/admin/admin-product-status-toggle.tsx b/frontend/components/shop/admin/admin-product-status-toggle.tsx index d59ce590..413e9087 100644 --- a/frontend/components/shop/admin/admin-product-status-toggle.tsx +++ b/frontend/components/shop/admin/admin-product-status-toggle.tsx @@ -5,51 +5,68 @@ import { useId, useState } from 'react'; interface AdminProductStatusToggleProps { id: string; initialIsActive: boolean; + csrfToken: string; } export function AdminProductStatusToggle({ id, initialIsActive, + csrfToken, }: AdminProductStatusToggleProps) { const [isActive, setIsActive] = useState(initialIsActive); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - // For accessible association; stable per component instance. const errorId = useId(); const toggleStatus = async () => { setIsLoading(true); setError(null); + if (!csrfToken) { + setError('Security token missing. Refresh the page.'); + setIsLoading(false); + return; + } + try { const response = await fetch(`/api/shop/admin/products/${id}/status`, { method: 'PATCH', + headers: { + 'x-csrf-token': csrfToken, + }, }); if (!response.ok) { + let code: string | undefined; + try { + const body = await response.json(); + code = typeof body?.code === 'string' ? body.code : undefined; + } catch { + // ignore + } + + if (response.status === 403 && (code === 'CSRF_MISSING' || code === 'CSRF_INVALID')) { + setError('Security token expired. Refresh the page and retry.'); + return; + } + setError('Failed to update status'); return; } const data: { product?: { isActive?: boolean } } = await response.json(); - if (typeof data.product?.isActive === 'boolean') { setIsActive(data.product.isActive); } } catch { - // Avoid noisy console in UI components; keep UX deterministic. setError('Failed to update status'); } finally { setIsLoading(false); } }; - const buttonLabel = isLoading - ? 'Updating' - : isActive - ? 'Deactivate' - : 'Activate'; + const buttonLabel = isLoading ? 'Updating' : isActive ? 'Deactivate' : 'Activate'; return (
diff --git a/frontend/db/schema/shop.ts b/frontend/db/schema/shop.ts index 04f57182..315f6715 100644 --- a/frontend/db/schema/shop.ts +++ b/frontend/db/schema/shop.ts @@ -377,6 +377,24 @@ export const internalJobState = pgTable('internal_job_state', { .defaultNow(), }); +export const apiRateLimits = pgTable( + 'api_rate_limits', + { + key: text('key').primaryKey(), + windowStartedAt: timestamp('window_started_at', { + withTimezone: true, + }).notNull(), + count: integer('count').notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + t => [ + check('api_rate_limits_count_non_negative', sql`${t.count} >= 0`), + index('api_rate_limits_updated_at_idx').on(t.updatedAt), + ] +); + export const paymentAttempts = pgTable( 'payment_attempts', { @@ -452,3 +470,4 @@ export type DbOrderItem = typeof orderItems.$inferSelect; export type DbInventoryMove = typeof inventoryMoves.$inferSelect; export type DbInternalJobState = typeof internalJobState.$inferSelect; export type DbPaymentAttempt = typeof paymentAttempts.$inferSelect; +export type DbApiRateLimit = typeof apiRateLimits.$inferSelect; diff --git a/frontend/drizzle/0004_add_api_rate_limits.sql b/frontend/drizzle/0004_add_api_rate_limits.sql new file mode 100644 index 00000000..f17f2985 --- /dev/null +++ b/frontend/drizzle/0004_add_api_rate_limits.sql @@ -0,0 +1,22 @@ +CREATE TABLE "api_rate_limits" ( + "key" text PRIMARY KEY NOT NULL, + "window_started_at" timestamp with time zone NOT NULL, + "count" integer NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "api_rate_limits_count_non_negative" CHECK ("api_rate_limits"."count" >= 0) +); +--> statement-breakpoint +CREATE INDEX "api_rate_limits_updated_at_idx" ON "api_rate_limits" USING btree ("updated_at");--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'payment_attempts_provider_check' + AND conrelid = 'public.payment_attempts'::regclass + ) THEN + ALTER TABLE "payment_attempts" + ADD CONSTRAINT "payment_attempts_provider_check" + CHECK ("payment_attempts"."provider" in ('stripe')); + END IF; +END $$; diff --git a/frontend/drizzle/meta/0004_snapshot.json b/frontend/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000..fb3c0b67 --- /dev/null +++ b/frontend/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2805 @@ +{ + "id": "666d4e8b-79a6-409d-81d9-cef200363124", + "prevId": "1b3c7a10-06b5-4278-89cb-e3c38748f980", + "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', '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 + }, + "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')" + }, + "payment_attempts_status_check": { + "name": "payment_attempts_status_check", + "value": "\"payment_attempts\".\"status\" in ('active','succeeded','failed','canceled')" + }, + "payment_attempts_attempt_number_check": { + "name": "payment_attempts_attempt_number_check", + "value": "\"payment_attempts\".\"attempt_number\" >= 1" + } + }, + "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": "no action", + "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/_journal.json b/frontend/drizzle/meta/_journal.json index 48d82cf4..3ccfd4e8 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1768690525242, "tag": "0003_add_stripe_events_claim_lock", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1768707328896, + "tag": "0004_add_api_rate_limits", + "breakpoints": true } ] } \ No newline at end of file diff --git a/frontend/lib/security/admin-csrf.ts b/frontend/lib/security/admin-csrf.ts new file mode 100644 index 00000000..bc445d16 --- /dev/null +++ b/frontend/lib/security/admin-csrf.ts @@ -0,0 +1,55 @@ +import 'server-only'; + +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + +import { logError } from '@/lib/logging'; +import { CSRF_FORM_FIELD, verifyCsrfToken } from '@/lib/security/csrf'; + +function readTokenFromForm(formData?: FormData): string | null { + if (!formData) return null; + const raw = formData.get(CSRF_FORM_FIELD); + return typeof raw === 'string' ? raw.trim() : null; +} + +function readTokenFromHeader(request: unknown): string | null { + const headers = (request as any)?.headers; + if (!headers || typeof headers.get !== 'function') return null; + + const raw = headers.get('x-csrf-token'); + return typeof raw === 'string' ? raw.trim() : null; +} + +/** + * Admin CSRF gate for cookie-based auth. + * Token source: + * - multipart/form-data: field CSRF_FORM_FIELD + * - otherwise: header x-csrf-token + * + * Fail-closed: + * - missing/invalid token => 403 + * - CSRF secret misconfigured => 503 (not 500) + */ +export function requireAdminCsrf( + request: NextRequest, + purpose: string, + formData?: FormData +): NextResponse | null { + const token = readTokenFromHeader(request) || readTokenFromForm(formData); + + if (!token) { + return NextResponse.json({ code: 'CSRF_MISSING' }, { status: 403 }); + } + + try { + const ok = verifyCsrfToken(token, purpose); + if (!ok) { + return NextResponse.json({ code: 'CSRF_INVALID' }, { status: 403 }); + } + return null; + } catch (err) { + // e.g. CSRF_SECRET missing -> csrf.ts throws + logError('CSRF verification failed (misconfigured)', err); + return NextResponse.json({ code: 'CSRF_DISABLED' }, { status: 503 }); + } +} diff --git a/frontend/lib/security/rate-limit.ts b/frontend/lib/security/rate-limit.ts new file mode 100644 index 00000000..f502a787 --- /dev/null +++ b/frontend/lib/security/rate-limit.ts @@ -0,0 +1,138 @@ +import 'server-only'; + +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { sql } from 'drizzle-orm'; + +import { db } from '@/db'; + +type GateRow = { window_started_at: unknown; count: unknown }; + +function normalizeDate(x: unknown): Date | null { + if (!x) return null; + if (x instanceof Date) return isNaN(x.getTime()) ? null : x; + const d = new Date(String(x)); + return isNaN(d.getTime()) ? null : d; +} + +function envInt(name: string, fallback: number): number { + const raw = (process.env[name] ?? '').trim(); + if (!raw) return fallback; + const n = Number(raw); + return Number.isFinite(n) ? Math.floor(n) : fallback; +} + +export function getClientIp(request: NextRequest): string | null { + const h = request.headers; + + const cf = (h.get('cf-connecting-ip') ?? '').trim(); + if (cf) return cf; + + const xr = (h.get('x-real-ip') ?? '').trim(); + if (xr) return xr; + + const xff = (h.get('x-forwarded-for') ?? '').trim(); + if (xff) { + const first = xff.split(',')[0]?.trim(); + return first?.length ? first : null; + } + + return null; +} + +export type RateLimitOk = { ok: true; remaining: number }; +export type RateLimitNo = { ok: false; retryAfterSeconds: number }; +export type RateLimitDecision = RateLimitOk | RateLimitNo; + +/** + * DB-backed fixed-window limiter (cross-instance). + * - Atomic insert/update with conditional WHERE to avoid going above limit. + * - If limited: computes Retry-After from stored window_started_at. + */ +export async function enforceRateLimit(params: { + key: string; + limit: number; + windowSeconds: number; +}): Promise { + const limit = Math.max(0, Math.floor(params.limit)); + const windowSeconds = Math.max(1, Math.floor(params.windowSeconds)); + const key = params.key; + + // Allow disabling via env (for emergency): RATE_LIMIT_DISABLED=1 + if (envInt('RATE_LIMIT_DISABLED', 0) === 1) { + return { ok: true, remaining: Number.MAX_SAFE_INTEGER }; + } + + if (!key || limit <= 0) { + return { ok: true, remaining: Number.MAX_SAFE_INTEGER }; + } + + const res = await db.execute(sql` + INSERT INTO api_rate_limits (key, window_started_at, count, updated_at) + VALUES (${key}, now(), 1, now()) + ON CONFLICT (key) DO UPDATE + SET + count = CASE + WHEN api_rate_limits.window_started_at <= now() - make_interval(secs => ${windowSeconds}) + THEN 1 + ELSE api_rate_limits.count + 1 + END, + window_started_at = CASE + WHEN api_rate_limits.window_started_at <= now() - make_interval(secs => ${windowSeconds}) + THEN now() + ELSE api_rate_limits.window_started_at + END, + updated_at = now() + WHERE + api_rate_limits.window_started_at <= now() - make_interval(secs => ${windowSeconds}) + OR api_rate_limits.count < ${limit} + RETURNING window_started_at, count + `); + + const rows = (res as any).rows ?? []; + if (rows.length > 0) { + const count = Number(rows[0]?.count ?? 1); + const remaining = Math.max(0, limit - count); + return { ok: true, remaining }; + } + + const res2 = await db.execute(sql` + SELECT window_started_at, count + FROM api_rate_limits + WHERE key = ${key} + LIMIT 1 + `); + + const rows2 = (res2 as any).rows ?? []; + const startedAt = normalizeDate(rows2[0]?.window_started_at) ?? new Date(); + const windowEndMs = startedAt.getTime() + windowSeconds * 1000; + const retryAfterSeconds = Math.max( + 1, + Math.ceil((windowEndMs - Date.now()) / 1000) + ); + + return { ok: false, retryAfterSeconds }; +} + +export function rateLimitResponse(params: { + code?: string; + retryAfterSeconds: number; + details?: Record; +}): NextResponse { + const retryAfterSeconds = Math.max(1, Math.floor(params.retryAfterSeconds)); + const code = params.code ?? 'RATE_LIMITED'; + + const res = NextResponse.json( + { + success: false, + code, + retryAfterSeconds, + ...(params.details ? { details: params.details } : {}), + }, + { status: 429 } + ); + + res.headers.set('Retry-After', String(retryAfterSeconds)); + res.headers.set('Cache-Control', 'no-store'); + return res; +} diff --git a/frontend/lib/tests/admin-csrf-contract.test.ts b/frontend/lib/tests/admin-csrf-contract.test.ts new file mode 100644 index 00000000..c2b334d0 --- /dev/null +++ b/frontend/lib/tests/admin-csrf-contract.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Mock admin auth to "pass" +vi.mock('@/lib/auth/admin', () => { + class AdminApiDisabledError extends Error { + code = 'ADMIN_API_DISABLED'; + } + class AdminUnauthorizedError extends Error { + code = 'ADMIN_UNAUTHORIZED'; + } + class AdminForbiddenError extends Error { + code = 'ADMIN_FORBIDDEN'; + } + return { + AdminApiDisabledError, + AdminUnauthorizedError, + AdminForbiddenError, + requireAdminApi: vi.fn().mockResolvedValue(undefined), + }; +}); + +// Import AFTER mocking +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 () => { + process.env.CSRF_SECRET = 'test_csrf_secret'; + + const req = new NextRequest( + new Request('http://localhost/api/shop/admin/products/x/status', { + method: 'PATCH', + }) + ); + + const res = await patchStatus(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'); + }); +}); diff --git a/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts b/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts index 1e0af014..b5d73cc8 100644 --- a/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts +++ b/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts @@ -31,6 +31,9 @@ vi.mock('@/lib/services/products', () => ({ getAdminProductByIdWithPrices: vi.fn(), deleteProduct: vi.fn(), })); +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: vi.fn(() => null), +})); import { PATCH } from '@/app/api/shop/admin/products/[id]/route'; diff --git a/frontend/lib/tests/admin-product-sale-contract.test.ts b/frontend/lib/tests/admin-product-sale-contract.test.ts index 1f52d99a..2732e406 100644 --- a/frontend/lib/tests/admin-product-sale-contract.test.ts +++ b/frontend/lib/tests/admin-product-sale-contract.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { NextRequest } from 'next/server'; /** @@ -38,6 +38,10 @@ vi.mock('@/lib/admin/parseAdminProductForm', () => ({ parseAdminProductForm: parseAdminProductFormMock, })); +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: vi.fn(() => null), +})); + function makeFile(): File { return new File([new Uint8Array([1, 2, 3])], 'test.png', { type: 'image/png', @@ -62,6 +66,8 @@ function makeFormData(payload?: { describe('P1-3 SALE rule end-to-end contract: admin products API returns stable code + details', () => { beforeEach(() => { + vi.stubEnv('ENABLE_ADMIN_API', 'true'); + parseAdminProductFormMock.mockReset(); productsServiceMock.createProduct.mockReset(); productsServiceMock.updateProduct.mockReset(); @@ -69,6 +75,10 @@ describe('P1-3 SALE rule end-to-end contract: admin products API returns stable productsServiceMock.getAdminProductByIdWithPrices.mockReset(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('POST /api/shop/admin/products: SALE without originalPriceMinor -> 400 SALE_ORIGINAL_REQUIRED (required)', async () => { parseAdminProductFormMock.mockReturnValue({ ok: true, diff --git a/frontend/lib/tests/checkout-no-payments.test.ts b/frontend/lib/tests/checkout-no-payments.test.ts index 49741cf2..77458776 100644 --- a/frontend/lib/tests/checkout-no-payments.test.ts +++ b/frontend/lib/tests/checkout-no-payments.test.ts @@ -156,6 +156,11 @@ async function cleanupIsolatedProduct(productId: string) { ); } } +function deriveTestIpFromIdemKey(idemKey: string): string { + const hex = idemKey.replace(/[^0-9a-f]/gi, '').slice(0, 2); + const n = hex ? (parseInt(hex, 16) % 250) + 1 : 1; + return `203.0.113.${n}`; +} async function postCheckout(params: { idemKey: string; @@ -167,7 +172,9 @@ async function postCheckout(params: { selectedColor?: string; }>; }) { - const { POST } = await import('@/app/api/shop/checkout/route'); + 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', @@ -175,17 +182,19 @@ async function postCheckout(params: { 'content-type': 'application/json', 'accept-language': params.acceptLanguage ?? 'en', 'idempotency-key': params.idemKey, + 'x-forwarded-for': deriveTestIpFromIdemKey(params.idemKey), }, + body: JSON.stringify({ items: params.items }), }); - return POST(req); + return mod.POST(req); } type MoveRow = { productId: string; type: string; quantity: number }; async function readMoves(orderId: string): Promise { - const res = await db.execute( + const res = (await db.execute( sql` select product_id as "productId", @@ -195,14 +204,23 @@ async function readMoves(orderId: string): Promise { where order_id = ${orderId}::uuid order by created_at asc ` - ); + )) as unknown as { + rows?: Array<{ productId: unknown; type: unknown; quantity: unknown }>; + }; return (res.rows ?? []).map(row => ({ - productId: String((row as any).productId ?? ''), - type: String((row as any).type ?? ''), - quantity: Number((row as any).quantity ?? 0), + productId: String(row.productId ?? ''), + type: String(row.type ?? ''), + quantity: Number(row.quantity ?? 0), })); } +async function countMovesForProduct(productId: string): Promise { + const res = (await db.execute( + sql`select count(*)::int as c from inventory_moves where product_id = ${productId}::uuid` + )) as unknown as { rows?: Array<{ c: number | string }> }; + + return Number(res.rows?.[0]?.c ?? 0); +} async function bestEffortHardDeleteOrder(orderId: string) { // Keep DB reasonably clean in dev. @@ -493,10 +511,7 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(p0).toBeTruthy(); const stockBefore = p0!.stock; // ДО checkout: порахували moves - const moves0 = await db.execute( - sql`select count(*)::int as c from inventory_moves where product_id = ${productId}::uuid` - ); - const countBefore = Number((moves0.rows?.[0] as any)?.c ?? 0); + const countBefore = await countMovesForProduct(productId); const res = await postCheckout({ idemKey, @@ -521,10 +536,7 @@ describe.sequential('Checkout (no payments) invariants', () => { .where(eq(products.id, productId)); expect(json?.code).toBe('INVALID_VARIANT'); - const moves1 = await db.execute( - sql`select count(*)::int as c from inventory_moves where product_id = ${productId}::uuid` - ); - const countAfter = Number((moves1.rows?.[0] as any)?.c ?? 0); + const countAfter = await countMovesForProduct(productId); expect(countAfter).toBe(countBefore); // No order should be created for invalid variant @@ -602,10 +614,7 @@ describe.sequential('Checkout (no payments) invariants', () => { const stockBefore = p0!.stock; // ДО checkout: порахували moves - const moves0 = await db.execute( - sql`select count(*)::int as c from inventory_moves where product_id = ${productId}::uuid` - ); - const countBefore = Number((moves0.rows?.[0] as any)?.c ?? 0); + const countBefore = await countMovesForProduct(productId); const res = await postCheckout({ idemKey, @@ -634,10 +643,7 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(json?.code).toBe('INVALID_VARIANT'); // Після checkout: moves count must be unchanged - const moves1 = await db.execute( - sql`select count(*)::int as c from inventory_moves where product_id = ${productId}::uuid` - ); - const countAfter = Number((moves1.rows?.[0] as any)?.c ?? 0); + const countAfter = await countMovesForProduct(productId); expect(countAfter).toBe(countBefore); // No order should be created for this idemKey diff --git a/frontend/lib/tests/helpers/makeCheckoutReq.ts b/frontend/lib/tests/helpers/makeCheckoutReq.ts index 70eab2a9..02f12c9a 100644 --- a/frontend/lib/tests/helpers/makeCheckoutReq.ts +++ b/frontend/lib/tests/helpers/makeCheckoutReq.ts @@ -1,12 +1,19 @@ import { NextRequest } from 'next/server'; -type CheckoutItemInput = { +export type CheckoutItemInput = { productId: string; quantity: number; selectedSize?: string; selectedColor?: string; }; +function deriveTestIpFromIdemKey(idemKey: string): string { + // беремо перші 2 hex-символи, робимо байт 1..250 + const hex = idemKey.replace(/[^0-9a-f]/gi, '').slice(0, 2); + const n = hex ? (parseInt(hex, 16) % 250) + 1 : 1; + return `203.0.113.${n}`; // TEST-NET-3 +} + export function makeCheckoutReq(params: { idempotencyKey: string; locale?: string; // mapped to Accept-Language @@ -14,14 +21,13 @@ export function makeCheckoutReq(params: { userId?: string; }) { const locale = params.locale ?? 'en'; + const idemKey = params.idempotencyKey; const items = params.items ?? [ { productId: '11111111-1111-4111-8111-111111111111', quantity: 1, - // IMPORTANT: - // do NOT force selectedSize/selectedColor unless explicitly provided. - // Empty strings often fail schema validation (min(1) etc). + // IMPORTANT: не форсимо selectedSize/selectedColor }, ]; @@ -30,18 +36,16 @@ export function makeCheckoutReq(params: { productId: i.productId, quantity: i.quantity, }; - - // Include variant fields only if caller provided them (including empty string intentionally). if (i.selectedSize !== undefined) base.selectedSize = i.selectedSize; if (i.selectedColor !== undefined) base.selectedColor = i.selectedColor; - return base; }); const headers = new Headers({ - 'Content-Type': 'application/json', - 'Idempotency-Key': params.idempotencyKey, - 'Accept-Language': locale, + 'content-type': 'application/json', + 'accept-language': locale, + 'idempotency-key': idemKey, + 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), }); const req = new Request('http://localhost/api/shop/checkout', { @@ -53,6 +57,5 @@ export function makeCheckoutReq(params: { }), }); - // Wrap the real Request to ensure body is readable by request.text() in route return new NextRequest(req); } diff --git a/frontend/project-structure.txt b/frontend/project-structure.txt index 69150a9f..db72f9ec 100644 --- a/frontend/project-structure.txt +++ b/frontend/project-structure.txt @@ -311,6 +311,7 @@ 📄 points.ts 📄 questions.ts 📄 quiz.ts + 📄 sessions.ts 📄 shop.ts 📄 users.ts 📄 seed-categories.ts @@ -340,8 +341,14 @@ 📄 fondy.md 📁 drizzle 📄 0000_dry_young_avengers.sql + 📄 0001_add_payment_attempts.sql + 📄 0002_clean_martin_li.sql + 📄 0003_add_stripe_events_claim_lock.sql 📁 meta 📄 0000_snapshot.json + 📄 0001_snapshot.json + 📄 0002_snapshot.json + 📄 0003_snapshot.json 📄 _journal.json 📄 drizzle.config.ts 📄 eslint.config.mjs @@ -396,12 +403,14 @@ 📄 quiz-session.ts 📄 quiz-storage-keys.ts 📁 security + 📄 admin-csrf.ts 📄 csrf.ts 📁 services 📄 errors.ts 📄 inventory.ts 📁 orders 📄 checkout.ts + 📄 payment-attempts.ts 📄 payment-intent.ts 📄 payment-state.ts 📁 psp-metadata @@ -438,7 +447,10 @@ 📄 slug.ts 📁 tests 📄 admin-api-killswitch.test.ts + 📄 admin-csrf-contract.test.ts + 📄 admin-product-patch-price-config-error-contract.test.ts 📄 admin-product-sale-contract.test.ts + 📄 cart-rehydrate-variant-sanitize.test.ts 📄 checkout-concurrency-stock1.test.ts 📄 checkout-currency-policy.test.ts 📄 checkout-no-payments.test.ts @@ -455,6 +467,8 @@ 📄 payment-state-machine.helper.test.ts 📄 payment-status-tripwire.test.ts 📄 prices.test.ts + 📄 product-admin-merged-prices-policy.test.ts + 📄 product-admin-update-prices-patch-semantics.test.ts 📄 product-sale-invariant.test.ts 📄 public-product-visibility.test.ts 📄 restock-order-only-once.test.ts