From e03efaa2ce85eeb4e0b6580ef8b4f2bb85cf5b75 Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Thu, 11 Apr 2024 23:52:56 +0300 Subject: [PATCH 01/25] fix(sanity) update sdn --- frontend/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/client.ts b/frontend/client.ts index 3436262d..3085f871 100644 --- a/frontend/client.ts +++ b/frontend/client.ts @@ -3,6 +3,6 @@ import { createClient } from '@sanity/client'; export const client = createClient({ projectId: '6y9ive6v', dataset: 'production', - useCdn: true, + useCdn: false, apiVersion: '2025-11-29', }); From a284aa171a9c5f3c8ffbdedc9ade1d4859a351e0 Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Fri, 12 Apr 2024 00:31:39 +0300 Subject: [PATCH 02/25] fix(sanity) update sdn --- frontend/app/not-found.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx index 87b2579e..ebc984aa 100644 --- a/frontend/app/not-found.tsx +++ b/frontend/app/not-found.tsx @@ -1,13 +1,9 @@ -import { cookies } from 'next/headers'; - import enMessages from '@/messages/en.json'; import plMessages from '@/messages/pl.json'; import ukMessages from '@/messages/uk.json'; type Locale = 'uk' | 'en' | 'pl'; -const locales: Locale[] = ['uk', 'en', 'pl']; - const messages = { uk: ukMessages.notFound, en: enMessages.notFound, @@ -15,12 +11,9 @@ const messages = { }; export default async function NotFound() { - const cookieStore = await cookies(); - const localeCookie = cookieStore.get('NEXT_LOCALE')?.value; - const locale: Locale = - localeCookie && locales.includes(localeCookie as Locale) - ? (localeCookie as Locale) - : 'en'; + // Keep root not-found static; reading cookies here triggers + // DYNAMIC_SERVER_USAGE on statically rendered pages. + const locale: Locale = 'en'; const t = messages[locale]; return ( From 7d254c39b273e1bde0165aff1505bd7e2ea3ccec Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Fri, 12 Apr 2024 00:44:24 +0300 Subject: [PATCH 03/25] fix(sanity) update sdn --- frontend/app/not-found.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx index ebc984aa..87b2579e 100644 --- a/frontend/app/not-found.tsx +++ b/frontend/app/not-found.tsx @@ -1,9 +1,13 @@ +import { cookies } from 'next/headers'; + import enMessages from '@/messages/en.json'; import plMessages from '@/messages/pl.json'; import ukMessages from '@/messages/uk.json'; type Locale = 'uk' | 'en' | 'pl'; +const locales: Locale[] = ['uk', 'en', 'pl']; + const messages = { uk: ukMessages.notFound, en: enMessages.notFound, @@ -11,9 +15,12 @@ const messages = { }; export default async function NotFound() { - // Keep root not-found static; reading cookies here triggers - // DYNAMIC_SERVER_USAGE on statically rendered pages. - const locale: Locale = 'en'; + const cookieStore = await cookies(); + const localeCookie = cookieStore.get('NEXT_LOCALE')?.value; + const locale: Locale = + localeCookie && locales.includes(localeCookie as Locale) + ? (localeCookie as Locale) + : 'en'; const t = messages[locale]; return ( From 41098bc96bfe22207bd74ef426134d98e373a8c8 Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Fri, 12 Apr 2024 00:55:23 +0300 Subject: [PATCH 04/25] fix(sanity) update sdn --- frontend/app/[locale]/blog/[slug]/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/app/[locale]/blog/[slug]/page.tsx b/frontend/app/[locale]/blog/[slug]/page.tsx index 377b9354..14ae5061 100644 --- a/frontend/app/[locale]/blog/[slug]/page.tsx +++ b/frontend/app/[locale]/blog/[slug]/page.tsx @@ -25,15 +25,13 @@ export async function generateMetadata({ const post = await client.fetch( groq`*[_type == "post" && slug.current == $slug][0]{ - "title": coalesce(title[$locale], title.en, title), - "description": pt::text(coalesce(body[$locale], body.en, body))[0...160] + "title": string(coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title)) }`, { slug, locale } ); return { title: post?.title || 'Post', - description: post?.description || undefined, }; } From 635499ef24cb9799ae791d4d1f2801e9c9abd69f Mon Sep 17 00:00:00 2001 From: Lesia Soloviova <106915140+LesiaUKR@users.noreply.github.com> Date: Wed, 4 Mar 2026 05:09:42 +0000 Subject: [PATCH 05/25] (SP: 1) [Frontend] Fix 500 error on blog post pages (#383) --- .../app/[locale]/blog/[slug]/PostDetails.tsx | 23 +- frontend/app/[locale]/blog/[slug]/page.tsx | 21 +- .../blog/category/[category]/page.tsx | 6 +- frontend/app/[locale]/blog/page.tsx | 14 +- frontend/app/api/blog-search/route.ts | 4 +- frontend/client.ts | 2 +- frontend/db/schema/blog.ts | 198 + frontend/db/schema/index.ts | 1 + .../drizzle/0027_material_bloodscream.sql | 75 + frontend/drizzle/meta/0027_snapshot.json | 6737 +++++++++++++++++ frontend/drizzle/meta/_journal.json | 9 +- frontend/package-lock.json | 4 +- 12 files changed, 7052 insertions(+), 42 deletions(-) create mode 100644 frontend/db/schema/blog.ts create mode 100644 frontend/drizzle/0027_material_bloodscream.sql create mode 100644 frontend/drizzle/meta/0027_snapshot.json diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index 95b3c13c..fdd39a92 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -1,7 +1,7 @@ import groq from 'groq'; import Image from 'next/image'; import { notFound } from 'next/navigation'; -import { getTranslations } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { client } from '@/client'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; @@ -321,7 +321,7 @@ function hashString(input: string) { const query = groq` *[_type=="post" && slug.current==$slug][0]{ _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), + "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), publishedAt, "mainImage": mainImage.asset->url, "categories": categories[]->title, @@ -329,16 +329,16 @@ const query = groq` resourceLink, "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), - "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl, company), - "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl, jobTitle), - "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl, city), - "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl, bio), + "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl), + "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl), + "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl), + "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl), + "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl), "image": image.asset->url, socialMedia[]{ _key, platform, url } }, - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ + "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ ..., _type == "image" => { ..., @@ -350,16 +350,16 @@ const query = groq` const recommendedQuery = groq` *[_type=="post" && defined(slug.current) && slug.current != $slug]{ _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), + "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), publishedAt, "mainImage": mainImage.asset->url, slug, "categories": categories[]->title, "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), + "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl), "image": image.asset->url }, - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ + "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ ..., _type == "image" => { ..., @@ -376,6 +376,7 @@ export default async function PostDetails({ slug: string; locale: string; }) { + setRequestLocale(locale); const t = await getTranslations({ locale, namespace: 'blog' }); const tNav = await getTranslations({ locale, namespace: 'navigation' }); const slugParam = String(slug || '').trim(); diff --git a/frontend/app/[locale]/blog/[slug]/page.tsx b/frontend/app/[locale]/blog/[slug]/page.tsx index 14ae5061..dac1e0d5 100644 --- a/frontend/app/[locale]/blog/[slug]/page.tsx +++ b/frontend/app/[locale]/blog/[slug]/page.tsx @@ -1,20 +1,11 @@ import groq from 'groq'; +import { setRequestLocale } from 'next-intl/server'; import { client } from '@/client'; import PostDetails from './PostDetails'; -export const revalidate = 3600; - -export async function generateStaticParams() { - const slugs = await client.fetch( - groq`*[_type == "post" && defined(slug.current)][].slug.current` - ); - - return slugs.map(slug => ({ - slug, - })); -} +export const dynamic = 'force-dynamic'; export async function generateMetadata({ params, @@ -25,14 +16,13 @@ export async function generateMetadata({ const post = await client.fetch( groq`*[_type == "post" && slug.current == $slug][0]{ - "title": string(coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title)) + "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl) }`, { slug, locale } ); - return { - title: post?.title || 'Post', - }; + const title = typeof post?.title === 'string' ? post.title : 'Post'; + return { title }; } export default async function Page({ @@ -41,5 +31,6 @@ export default async function Page({ params: Promise<{ slug: string; locale: string }>; }) { const { slug, locale } = await params; + setRequestLocale(locale); return ; } diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx index 8db74901..d5c1874c 100644 --- a/frontend/app/[locale]/blog/category/[category]/page.tsx +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -64,17 +64,17 @@ export default async function BlogCategoryPage({ *[_type == "post" && defined(slug.current) && $category in categories[]->title] | order(coalesce(publishedAt, _createdAt) desc) { _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), + "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), slug, publishedAt, "categories": categories[]->title, - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ + "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ ..., children[]{ text } }, "mainImage": mainImage.asset->url, "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), + "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl), "image": image.asset->url } } diff --git a/frontend/app/[locale]/blog/page.tsx b/frontend/app/[locale]/blog/page.tsx index a0a159d6..b1c61756 100644 --- a/frontend/app/[locale]/blog/page.tsx +++ b/frontend/app/[locale]/blog/page.tsx @@ -35,7 +35,7 @@ export default async function BlogPage({ *[_type == "post" && defined(slug.current)] | order(coalesce(publishedAt, _createdAt) desc) { _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), + "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), slug, publishedAt, tags, @@ -43,7 +43,7 @@ export default async function BlogPage({ "categories": categories[]->title, - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ + "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ ..., children[]{ text @@ -51,11 +51,11 @@ export default async function BlogPage({ }, "mainImage": mainImage.asset->url, "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), - "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl, company), - "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl, jobTitle), - "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl, city), - "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl, bio), + "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl), + "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl), + "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl), + "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl), + "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl), "image": image.asset->url, socialMedia[]{ _key, diff --git a/frontend/app/api/blog-search/route.ts b/frontend/app/api/blog-search/route.ts index bc35755b..6e1c53d5 100644 --- a/frontend/app/api/blog-search/route.ts +++ b/frontend/app/api/blog-search/route.ts @@ -6,8 +6,8 @@ import { client } from '@/client'; const searchQuery = groq` *[_type == "post" && defined(slug.current)] | order(publishedAt desc) { _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ + "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), + "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ ..., children[]{ text } }, diff --git a/frontend/client.ts b/frontend/client.ts index 3085f871..3436262d 100644 --- a/frontend/client.ts +++ b/frontend/client.ts @@ -3,6 +3,6 @@ import { createClient } from '@sanity/client'; export const client = createClient({ projectId: '6y9ive6v', dataset: 'production', - useCdn: false, + useCdn: true, apiVersion: '2025-11-29', }); diff --git a/frontend/db/schema/blog.ts b/frontend/db/schema/blog.ts new file mode 100644 index 00000000..c492f86d --- /dev/null +++ b/frontend/db/schema/blog.ts @@ -0,0 +1,198 @@ +import { relations } from 'drizzle-orm'; +import { + boolean, + integer, + jsonb, + pgTable, + primaryKey, + text, + timestamp, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; + +// ── Blog Categories ────────────────────────────────────────────── + +export const blogCategories = pgTable('blog_categories', { + id: uuid('id').defaultRandom().primaryKey(), + slug: varchar('slug', { length: 50 }).notNull().unique(), + displayOrder: integer('display_order').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const blogCategoryTranslations = pgTable( + 'blog_category_translations', + { + categoryId: uuid('category_id') + .notNull() + .references(() => blogCategories.id, { onDelete: 'cascade' }), + locale: varchar('locale', { length: 5 }).notNull(), + title: text('title').notNull(), + description: text('description'), + }, + table => ({ + pk: primaryKey({ columns: [table.categoryId, table.locale] }), + }) +); + +// ── Blog Authors ───────────────────────────────────────────────── + +export const blogAuthors = pgTable('blog_authors', { + id: uuid('id').defaultRandom().primaryKey(), + slug: varchar('slug', { length: 100 }).notNull().unique(), + imageUrl: text('image_url'), + imagePublicId: text('image_public_id'), + socialMedia: jsonb('social_media').notNull().default([]), + displayOrder: integer('display_order').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const blogAuthorTranslations = pgTable( + 'blog_author_translations', + { + authorId: uuid('author_id') + .notNull() + .references(() => blogAuthors.id, { onDelete: 'cascade' }), + locale: varchar('locale', { length: 5 }).notNull(), + name: text('name').notNull(), + bio: text('bio'), + jobTitle: text('job_title'), + company: text('company'), + city: text('city'), + }, + table => ({ + pk: primaryKey({ columns: [table.authorId, table.locale] }), + }) +); + +// ── Blog Posts ─────────────────────────────────────────────────── + +export const blogPosts = pgTable('blog_posts', { + id: uuid('id').defaultRandom().primaryKey(), + slug: varchar('slug', { length: 200 }).notNull().unique(), + authorId: uuid('author_id').references(() => blogAuthors.id, { + onDelete: 'set null', + }), + mainImageUrl: text('main_image_url'), + mainImagePublicId: text('main_image_public_id'), + tags: text('tags').array().notNull().default([]), + resourceLink: text('resource_link'), + publishedAt: timestamp('published_at', { withTimezone: true }), + scheduledPublishAt: timestamp('scheduled_publish_at', { + withTimezone: true, + }), + isPublished: boolean('is_published').notNull().default(false), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const blogPostTranslations = pgTable( + 'blog_post_translations', + { + postId: uuid('post_id') + .notNull() + .references(() => blogPosts.id, { onDelete: 'cascade' }), + locale: varchar('locale', { length: 5 }).notNull(), + title: text('title').notNull(), + body: jsonb('body'), + }, + table => ({ + pk: primaryKey({ columns: [table.postId, table.locale] }), + }) +); + +// ── Blog Post ↔ Category junction ─────────────────────────────── + +export const blogPostCategories = pgTable( + 'blog_post_categories', + { + postId: uuid('post_id') + .notNull() + .references(() => blogPosts.id, { onDelete: 'cascade' }), + categoryId: uuid('category_id') + .notNull() + .references(() => blogCategories.id, { onDelete: 'cascade' }), + }, + table => ({ + pk: primaryKey({ columns: [table.postId, table.categoryId] }), + }) +); + +// ── Relations ─────────────────────────────────────────────────── + +export const blogCategoriesRelations = relations( + blogCategories, + ({ many }) => ({ + translations: many(blogCategoryTranslations), + posts: many(blogPostCategories), + }) +); + +export const blogCategoryTranslationsRelations = relations( + blogCategoryTranslations, + ({ one }) => ({ + category: one(blogCategories, { + fields: [blogCategoryTranslations.categoryId], + references: [blogCategories.id], + }), + }) +); + +export const blogAuthorsRelations = relations(blogAuthors, ({ many }) => ({ + translations: many(blogAuthorTranslations), + posts: many(blogPosts), +})); + +export const blogAuthorTranslationsRelations = relations( + blogAuthorTranslations, + ({ one }) => ({ + author: one(blogAuthors, { + fields: [blogAuthorTranslations.authorId], + references: [blogAuthors.id], + }), + }) +); + +export const blogPostsRelations = relations(blogPosts, ({ one, many }) => ({ + author: one(blogAuthors, { + fields: [blogPosts.authorId], + references: [blogAuthors.id], + }), + translations: many(blogPostTranslations), + categories: many(blogPostCategories), +})); + +export const blogPostTranslationsRelations = relations( + blogPostTranslations, + ({ one }) => ({ + post: one(blogPosts, { + fields: [blogPostTranslations.postId], + references: [blogPosts.id], + }), + }) +); + +export const blogPostCategoriesRelations = relations( + blogPostCategories, + ({ one }) => ({ + post: one(blogPosts, { + fields: [blogPostCategories.postId], + references: [blogPosts.id], + }), + category: one(blogCategories, { + fields: [blogPostCategories.categoryId], + references: [blogCategories.id], + }), + }) +); diff --git a/frontend/db/schema/index.ts b/frontend/db/schema/index.ts index 26a33fa5..934d5bc5 100644 --- a/frontend/db/schema/index.ts +++ b/frontend/db/schema/index.ts @@ -1,3 +1,4 @@ +export * from './blog'; export * from './categories'; export * from './emailVerificationTokens'; export * from './notifications'; diff --git a/frontend/drizzle/0027_material_bloodscream.sql b/frontend/drizzle/0027_material_bloodscream.sql new file mode 100644 index 00000000..690a2c2f --- /dev/null +++ b/frontend/drizzle/0027_material_bloodscream.sql @@ -0,0 +1,75 @@ +CREATE TABLE "blog_author_translations" ( + "author_id" uuid NOT NULL, + "locale" varchar(5) NOT NULL, + "name" text NOT NULL, + "bio" text, + "job_title" text, + "company" text, + "city" text, + CONSTRAINT "blog_author_translations_author_id_locale_pk" PRIMARY KEY("author_id","locale") +); +--> statement-breakpoint +CREATE TABLE "blog_authors" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(100) NOT NULL, + "image_url" text, + "image_public_id" text, + "social_media" jsonb DEFAULT '[]'::jsonb NOT NULL, + "display_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "blog_authors_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "blog_categories" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(50) NOT NULL, + "display_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "blog_categories_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "blog_category_translations" ( + "category_id" uuid NOT NULL, + "locale" varchar(5) NOT NULL, + "title" text NOT NULL, + "description" text, + CONSTRAINT "blog_category_translations_category_id_locale_pk" PRIMARY KEY("category_id","locale") +); +--> statement-breakpoint +CREATE TABLE "blog_post_categories" ( + "post_id" uuid NOT NULL, + "category_id" uuid NOT NULL, + CONSTRAINT "blog_post_categories_post_id_category_id_pk" PRIMARY KEY("post_id","category_id") +); +--> statement-breakpoint +CREATE TABLE "blog_post_translations" ( + "post_id" uuid NOT NULL, + "locale" varchar(5) NOT NULL, + "title" text NOT NULL, + "body" jsonb, + CONSTRAINT "blog_post_translations_post_id_locale_pk" PRIMARY KEY("post_id","locale") +); +--> statement-breakpoint +CREATE TABLE "blog_posts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(200) NOT NULL, + "author_id" uuid, + "main_image_url" text, + "main_image_public_id" text, + "tags" text[] DEFAULT '{}' NOT NULL, + "resource_link" text, + "published_at" timestamp with time zone, + "scheduled_publish_at" timestamp with time zone, + "is_published" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "blog_posts_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +ALTER TABLE "blog_author_translations" ADD CONSTRAINT "blog_author_translations_author_id_blog_authors_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."blog_authors"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blog_category_translations" ADD CONSTRAINT "blog_category_translations_category_id_blog_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."blog_categories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blog_post_categories" ADD CONSTRAINT "blog_post_categories_post_id_blog_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."blog_posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blog_post_categories" ADD CONSTRAINT "blog_post_categories_category_id_blog_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."blog_categories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blog_post_translations" ADD CONSTRAINT "blog_post_translations_post_id_blog_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."blog_posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_author_id_blog_authors_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."blog_authors"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/frontend/drizzle/meta/0027_snapshot.json b/frontend/drizzle/meta/0027_snapshot.json new file mode 100644 index 00000000..a608d4be --- /dev/null +++ b/frontend/drizzle/meta/0027_snapshot.json @@ -0,0 +1,6737 @@ +{ + "id": "2e864fcd-334b-40c0-8378-ea18ead78463", + "prevId": "f4e91e8f-78b2-466f-b7aa-2dfbb52d5a09", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blog_author_translations": { + "name": "blog_author_translations", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "blog_author_translations_author_id_blog_authors_id_fk": { + "name": "blog_author_translations_author_id_blog_authors_id_fk", + "tableFrom": "blog_author_translations", + "tableTo": "blog_authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_author_translations_author_id_locale_pk": { + "name": "blog_author_translations_author_id_locale_pk", + "columns": [ + "author_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_authors": { + "name": "blog_authors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_public_id": { + "name": "image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "social_media": { + "name": "social_media", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_authors_slug_unique": { + "name": "blog_authors_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_categories": { + "name": "blog_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": { + "blog_categories_slug_unique": { + "name": "blog_categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_category_translations": { + "name": "blog_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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "blog_category_translations_category_id_blog_categories_id_fk": { + "name": "blog_category_translations_category_id_blog_categories_id_fk", + "tableFrom": "blog_category_translations", + "tableTo": "blog_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_category_translations_category_id_locale_pk": { + "name": "blog_category_translations_category_id_locale_pk", + "columns": [ + "category_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_post_categories": { + "name": "blog_post_categories", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_post_categories_post_id_blog_posts_id_fk": { + "name": "blog_post_categories_post_id_blog_posts_id_fk", + "tableFrom": "blog_post_categories", + "tableTo": "blog_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blog_post_categories_category_id_blog_categories_id_fk": { + "name": "blog_post_categories_category_id_blog_categories_id_fk", + "tableFrom": "blog_post_categories", + "tableTo": "blog_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_post_categories_post_id_category_id_pk": { + "name": "blog_post_categories_post_id_category_id_pk", + "columns": [ + "post_id", + "category_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_post_translations": { + "name": "blog_post_translations", + "schema": "", + "columns": { + "post_id": { + "name": "post_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 + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "blog_post_translations_post_id_blog_posts_id_fk": { + "name": "blog_post_translations_post_id_blog_posts_id_fk", + "tableFrom": "blog_post_translations", + "tableTo": "blog_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_post_translations_post_id_locale_pk": { + "name": "blog_post_translations_post_id_locale_pk", + "columns": [ + "post_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "main_image_url": { + "name": "main_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "main_image_public_id": { + "name": "main_image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "resource_link": { + "name": "resource_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scheduled_publish_at": { + "name": "scheduled_publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {}, + "foreignKeys": { + "blog_posts_author_id_blog_authors_id_fk": { + "name": "blog_posts_author_id_blog_authors_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_posts_slug_unique": { + "name": "blog_posts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_translations": { + "name": "category_translations", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "category_translations_category_id_categories_id_fk": { + "name": "category_translations_category_id_categories_id_fk", + "tableFrom": "category_translations", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "category_translations_category_id_locale_pk": { + "name": "category_translations_category_id_locale_pk", + "columns": [ + "category_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_tokens": { + "name": "email_verification_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_tokens_user_id_idx": { + "name": "email_verification_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.point_transactions": { + "name": "point_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'quiz'" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "point_transactions_user_id_idx": { + "name": "point_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_translations": { + "name": "question_translations", + "schema": "", + "columns": { + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer_blocks": { + "name": "answer_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "question_translations_question_id_questions_id_fk": { + "name": "question_translations_question_id_questions_id_fk", + "tableFrom": "question_translations", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "question_translations_question_id_locale_pk": { + "name": "question_translations_question_id_locale_pk", + "columns": [ + "question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "questions_category_sort_order_idx": { + "name": "questions_category_sort_order_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answer_translations": { + "name": "quiz_answer_translations", + "schema": "", + "columns": { + "quiz_answer_id": { + "name": "quiz_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "answer_text": { + "name": "answer_text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk": { + "name": "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_answer_translations", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_answer_translations_quiz_answer_id_locale_pk": { + "name": "quiz_answer_translations_quiz_answer_id_locale_pk", + "columns": [ + "quiz_answer_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answers": { + "name": "quiz_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "quiz_answers_question_display_order_idx": { + "name": "quiz_answers_question_display_order_idx", + "columns": [ + { + "expression": "quiz_question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempt_answers": { + "name": "quiz_attempt_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_answer_id": { + "name": "selected_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempt_answers_attempt_idx": { + "name": "quiz_attempt_answers_attempt_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk": { + "name": "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempts": { + "name": "quiz_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_questions": { + "name": "total_questions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "time_spent_seconds": { + "name": "time_spent_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "integrity_score": { + "name": "integrity_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 100 + }, + "points_earned": { + "name": "points_earned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempts_user_id_idx": { + "name": "quiz_attempts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_user_completed_at_idx": { + "name": "quiz_attempts_user_completed_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_percentage_completed_at_idx": { + "name": "quiz_attempts_quiz_percentage_completed_at_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "percentage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_integrity_score_idx": { + "name": "quiz_attempts_quiz_integrity_score_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integrity_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_question_content": { + "name": "quiz_question_content", + "schema": "", + "columns": { + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question_text": { + "name": "question_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_question_content_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_question_content_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_question_content", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_question_content_quiz_question_id_locale_pk": { + "name": "quiz_question_content_quiz_question_id_locale_pk", + "columns": [ + "quiz_question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_questions": { + "name": "quiz_questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_question_id": { + "name": "source_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_questions_quiz_display_order_idx": { + "name": "quiz_questions_quiz_display_order_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_translations": { + "name": "quiz_translations", + "schema": "", + "columns": { + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_translations_quiz_id_quizzes_id_fk": { + "name": "quiz_translations_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_translations", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_translations_quiz_id_locale_pk": { + "name": "quiz_translations_quiz_id_locale_pk", + "columns": [ + "quiz_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quizzes": { + "name": "quizzes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "questions_count": { + "name": "questions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "time_limit_seconds": { + "name": "time_limit_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quizzes_slug_idx": { + "name": "quizzes_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.active_sessions": { + "name": "active_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "active_sessions_last_activity_idx": { + "name": "active_sessions_last_activity_idx", + "columns": [ + { + "expression": "last_activity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_log_dedupe_key_uq": { + "name": "admin_audit_log_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_order_id_idx": { + "name": "admin_audit_log_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_actor_user_id_idx": { + "name": "admin_audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_occurred_at_idx": { + "name": "admin_audit_log_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_order_id_orders_id_fk": { + "name": "admin_audit_log_order_id_orders_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_audit_log_actor_user_id_users_id_fk": { + "name": "admin_audit_log_actor_user_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_rate_limits": { + "name": "api_rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_started_at": { + "name": "window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_rate_limits_updated_at_idx": { + "name": "api_rate_limits_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_rate_limits_count_non_negative": { + "name": "api_rate_limits_count_non_negative", + "value": "\"api_rate_limits\".\"count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.internal_job_state": { + "name": "internal_job_state", + "schema": "", + "columns": { + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "next_allowed_at": { + "name": "next_allowed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_moves": { + "name": "inventory_moves", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "move_key": { + "name": "move_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "inventory_move_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_moves_move_key_uq": { + "name": "inventory_moves_move_key_uq", + "columns": [ + { + "expression": "move_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inventory_moves_quantity_gt_0": { + "name": "inventory_moves_quantity_gt_0", + "value": "\"inventory_moves\".\"quantity\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.monobank_events": { + "name": "monobank_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monobank'" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ccy": { + "name": "ccy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "normalized_payload": { + "name": "normalized_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_result": { + "name": "applied_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_error_code": { + "name": "applied_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_error_message": { + "name": "applied_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_sha256": { + "name": "raw_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_events_event_key_unique": { + "name": "monobank_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_events_provider_check": { + "name": "monobank_events_provider_check", + "value": "\"monobank_events\".\"provider\" in ('monobank')" + } + }, + "isRLSEnabled": false + }, + "public.monobank_payment_cancels": { + "name": "monobank_payment_cancels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ext_ref": { + "name": "ext_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_response": { + "name": "psp_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_payment_cancels_ext_ref_unique": { + "name": "monobank_payment_cancels_ext_ref_unique", + "columns": [ + { + "expression": "ext_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_payment_cancels_order_id_idx": { + "name": "monobank_payment_cancels_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_payment_cancels_attempt_id_idx": { + "name": "monobank_payment_cancels_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_payment_cancels_status_check": { + "name": "monobank_payment_cancels_status_check", + "value": "\"monobank_payment_cancels\".\"status\" in ('requested','processing','success','failure')" + } + }, + "isRLSEnabled": false + }, + "public.monobank_refunds": { + "name": "monobank_refunds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monobank'" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ext_ref": { + "name": "ext_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'UAH'" + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_refunds_ext_ref_unique": { + "name": "monobank_refunds_ext_ref_unique", + "columns": [ + { + "expression": "ext_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_refunds_provider_check": { + "name": "monobank_refunds_provider_check", + "value": "\"monobank_refunds\".\"provider\" in ('monobank')" + }, + "monobank_refunds_status_check": { + "name": "monobank_refunds_status_check", + "value": "\"monobank_refunds\".\"status\" in ('requested','processing','success','failure','needs_review')" + }, + "monobank_refunds_amount_minor_non_negative": { + "name": "monobank_refunds_amount_minor_non_negative", + "value": "\"monobank_refunds\".\"amount_minor\" >= 0" + }, + "monobank_refunds_currency_uah": { + "name": "monobank_refunds_currency_uah", + "value": "\"monobank_refunds\".\"currency\" = 'UAH'" + } + }, + "isRLSEnabled": false + }, + "public.notification_outbox": { + "name": "notification_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "template_key": { + "name": "template_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_domain": { + "name": "source_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_owner": { + "name": "lease_owner", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dead_lettered_at": { + "name": "dead_lettered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_outbox_dedupe_key_uq": { + "name": "notification_outbox_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_next_attempt_idx": { + "name": "notification_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_lease_expires_idx": { + "name": "notification_outbox_status_lease_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_order_created_idx": { + "name": "notification_outbox_order_created_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_template_status_idx": { + "name": "notification_outbox_template_status_idx", + "columns": [ + { + "expression": "template_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_outbox_order_id_orders_id_fk": { + "name": "notification_outbox_order_id_orders_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notification_outbox_source_domain_chk": { + "name": "notification_outbox_source_domain_chk", + "value": "\"notification_outbox\".\"source_domain\" in ('shipping_event','payment_event')" + }, + "notification_outbox_status_chk": { + "name": "notification_outbox_status_chk", + "value": "\"notification_outbox\".\"status\" in ('pending','processing','sent','failed','dead_letter')" + }, + "notification_outbox_attempt_count_non_negative_chk": { + "name": "notification_outbox_attempt_count_non_negative_chk", + "value": "\"notification_outbox\".\"attempt_count\" >= 0" + }, + "notification_outbox_max_attempts_positive_chk": { + "name": "notification_outbox_max_attempts_positive_chk", + "value": "\"notification_outbox\".\"max_attempts\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.np_cities": { + "name": "np_cities", + "schema": "", + "columns": { + "ref": { + "name": "ref", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name_ua": { + "name": "name_ua", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_ru": { + "name": "name_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settlement_type": { + "name": "settlement_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_sync_run_id": { + "name": "last_sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "np_cities_active_name_idx": { + "name": "np_cities_active_name_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_ua", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_cities_last_sync_run_idx": { + "name": "np_cities_last_sync_run_idx", + "columns": [ + { + "expression": "last_sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_cities_active_name_prefix_idx": { + "name": "np_cities_active_name_prefix_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_ua", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.np_warehouses": { + "name": "np_warehouses", + "schema": "", + "columns": { + "ref": { + "name": "ref", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "city_ref": { + "name": "city_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settlement_ref": { + "name": "settlement_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_ru": { + "name": "name_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_ru": { + "name": "address_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_post_machine": { + "name": "is_post_machine", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_sync_run_id": { + "name": "last_sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "np_warehouses_settlement_active_idx": { + "name": "np_warehouses_settlement_active_idx", + "columns": [ + { + "expression": "settlement_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_warehouses_city_active_idx": { + "name": "np_warehouses_city_active_idx", + "columns": [ + { + "expression": "city_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_warehouses_active_name_idx": { + "name": "np_warehouses_active_name_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_warehouses_last_sync_run_idx": { + "name": "np_warehouses_last_sync_run_idx", + "columns": [ + { + "expression": "last_sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "np_warehouses_settlement_ref_np_cities_ref_fk": { + "name": "np_warehouses_settlement_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "tableTo": "np_cities", + "columnsFrom": [ + "settlement_ref" + ], + "columnsTo": [ + "ref" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_items": { + "name": "order_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_size": { + "name": "selected_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "selected_color": { + "name": "selected_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_total_minor": { + "name": "line_total_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price": { + "name": "unit_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "line_total": { + "name": "line_total", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "product_title": { + "name": "product_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_slug": { + "name": "product_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "order_items_order_id_idx": { + "name": "order_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_items_order_variant_uq": { + "name": "order_items_order_variant_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_size", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_color", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_items_quantity_positive": { + "name": "order_items_quantity_positive", + "value": "\"order_items\".\"quantity\" > 0" + }, + "order_items_unit_price_minor_non_negative": { + "name": "order_items_unit_price_minor_non_negative", + "value": "\"order_items\".\"unit_price_minor\" >= 0" + }, + "order_items_line_total_minor_non_negative": { + "name": "order_items_line_total_minor_non_negative", + "value": "\"order_items\".\"line_total_minor\" >= 0" + }, + "order_items_line_total_consistent": { + "name": "order_items_line_total_consistent", + "value": "\"order_items\".\"line_total_minor\" = \"order_items\".\"unit_price_minor\" * \"order_items\".\"quantity\"" + }, + "order_items_unit_price_mirror_consistent": { + "name": "order_items_unit_price_mirror_consistent", + "value": "\"order_items\".\"unit_price\" = (\"order_items\".\"unit_price_minor\"::numeric / 100)" + }, + "order_items_line_total_mirror_consistent": { + "name": "order_items_line_total_mirror_consistent", + "value": "\"order_items\".\"line_total\" = (\"order_items\".\"line_total_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.order_legal_consents": { + "name": "order_legal_consents", + "schema": "", + "columns": { + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "privacy_accepted": { + "name": "privacy_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "terms_version": { + "name": "terms_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "privacy_version": { + "name": "privacy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'checkout'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "order_legal_consents_consented_idx": { + "name": "order_legal_consents_consented_idx", + "columns": [ + { + "expression": "consented_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_legal_consents_order_id_orders_id_fk": { + "name": "order_legal_consents_order_id_orders_id_fk", + "tableFrom": "order_legal_consents", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_legal_consents_terms_accepted_chk": { + "name": "order_legal_consents_terms_accepted_chk", + "value": "\"order_legal_consents\".\"terms_accepted\" = true" + }, + "order_legal_consents_privacy_accepted_chk": { + "name": "order_legal_consents_privacy_accepted_chk", + "value": "\"order_legal_consents\".\"privacy_accepted\" = true" + } + }, + "isRLSEnabled": false + }, + "public.order_shipping": { + "name": "order_shipping", + "schema": "", + "columns": { + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "shipping_address": { + "name": "shipping_address", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "order_shipping_updated_idx": { + "name": "order_shipping_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_amount_minor": { + "name": "total_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "fulfillment_mode": { + "name": "fulfillment_mode", + "type": "fulfillment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ua_np'" + }, + "quote_status": { + "name": "quote_status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "quote_version": { + "name": "quote_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "items_subtotal_minor": { + "name": "items_subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quote_accepted_at": { + "name": "quote_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quote_payment_deadline_at": { + "name": "quote_payment_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "shipping_required": { + "name": "shipping_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "shipping_payer": { + "name": "shipping_payer", + "type": "shipping_payer", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_provider": { + "name": "shipping_provider", + "type": "shipping_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_method_code": { + "name": "shipping_method_code", + "type": "shipping_method_code", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_amount_minor": { + "name": "shipping_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_status": { + "name": "shipping_status", + "type": "shipping_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shipping_provider_ref": { + "name": "shipping_provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_charge_id": { + "name": "psp_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_payment_method": { + "name": "psp_payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_status_reason": { + "name": "psp_status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_metadata": { + "name": "psp_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "order_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CREATED'" + }, + "inventory_status": { + "name": "inventory_status", + "type": "inventory_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_request_hash": { + "name": "idempotency_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stock_restored": { + "name": "stock_restored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "restocked_at": { + "name": "restocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sweep_claimed_at": { + "name": "sweep_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_claim_expires_at": { + "name": "sweep_claim_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_run_id": { + "name": "sweep_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sweep_claimed_by": { + "name": "sweep_claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "orders_sweep_claim_expires_idx": { + "name": "orders_sweep_claim_expires_idx", + "columns": [ + { + "expression": "sweep_claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orders_user_id_created_at": { + "name": "idx_orders_user_id_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_shipping_status_idx": { + "name": "orders_shipping_status_idx", + "columns": [ + { + "expression": "shipping_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_deadline_idx": { + "name": "orders_quote_status_deadline_idx", + "columns": [ + { + "expression": "fulfillment_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_payment_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_updated_idx": { + "name": "orders_quote_status_updated_idx", + "columns": [ + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "orders_payment_provider_valid": { + "name": "orders_payment_provider_valid", + "value": "\"orders\".\"payment_provider\" in ('stripe', 'monobank', 'none')" + }, + "orders_total_amount_minor_non_negative": { + "name": "orders_total_amount_minor_non_negative", + "value": "\"orders\".\"total_amount_minor\" >= 0" + }, + "orders_items_subtotal_minor_non_negative": { + "name": "orders_items_subtotal_minor_non_negative", + "value": "\"orders\".\"items_subtotal_minor\" >= 0" + }, + "orders_shipping_quote_minor_non_negative": { + "name": "orders_shipping_quote_minor_non_negative", + "value": "\"orders\".\"shipping_quote_minor\" is null or \"orders\".\"shipping_quote_minor\" >= 0" + }, + "orders_payment_intent_id_null_when_none": { + "name": "orders_payment_intent_id_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_intent_id\" IS NULL" + }, + "orders_psp_fields_null_when_none": { + "name": "orders_psp_fields_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR (\n \"orders\".\"psp_charge_id\" IS NULL AND\n \"orders\".\"psp_payment_method\" IS NULL AND\n \"orders\".\"psp_status_reason\" IS NULL\n )" + }, + "orders_total_amount_mirror_consistent": { + "name": "orders_total_amount_mirror_consistent", + "value": "\"orders\".\"total_amount\" = (\"orders\".\"total_amount_minor\"::numeric / 100)" + }, + "orders_payment_status_valid_when_none": { + "name": "orders_payment_status_valid_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_status\" in ('paid','failed')" + }, + "orders_shipping_null_when_not_required_chk": { + "name": "orders_shipping_null_when_not_required_chk", + "value": "\n \"orders\".\"shipping_required\" IS TRUE\n OR (\n \"orders\".\"shipping_provider\" IS NULL\n AND \"orders\".\"shipping_method_code\" IS NULL\n AND \"orders\".\"shipping_status\" IS NULL\n )\n " + }, + "orders_shipping_present_when_required_chk": { + "name": "orders_shipping_present_when_required_chk", + "value": "\n \"orders\".\"shipping_required\" IS DISTINCT FROM TRUE\n OR (\n \"orders\".\"shipping_provider\" IS NOT NULL\n AND \"orders\".\"shipping_method_code\" IS NOT NULL\n AND \"orders\".\"shipping_status\" IS NOT NULL\n )\n " + }, + "orders_shipping_amount_minor_non_negative_chk": { + "name": "orders_shipping_amount_minor_non_negative_chk", + "value": "\"orders\".\"shipping_amount_minor\" IS NULL OR \"orders\".\"shipping_amount_minor\" >= 0" + }, + "orders_shipping_payer_null_when_not_required_chk": { + "name": "orders_shipping_payer_null_when_not_required_chk", + "value": "\"orders\".\"shipping_required\" IS TRUE OR \"orders\".\"shipping_payer\" IS NULL" + }, + "orders_shipping_payer_present_when_required_chk": { + "name": "orders_shipping_payer_present_when_required_chk", + "value": "\"orders\".\"shipping_required\" IS DISTINCT FROM TRUE OR \"orders\".\"shipping_payer\" IS NOT NULL" + }, + "orders_intl_provider_restriction_chk": { + "name": "orders_intl_provider_restriction_chk", + "value": "\"orders\".\"fulfillment_mode\" <> 'intl' OR \"orders\".\"payment_provider\" in ('stripe', 'none')" + } + }, + "isRLSEnabled": false + }, + "public.payment_attempts": { + "name": "payment_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "expected_amount_minor": { + "name": "expected_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_url": { + "name": "checkout_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "janitor_claimed_until": { + "name": "janitor_claimed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "janitor_claimed_by": { + "name": "janitor_claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payment_attempts_order_provider_attempt_unique": { + "name": "payment_attempts_order_provider_attempt_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_provider_pi_unique": { + "name": "payment_attempts_provider_pi_unique", + "columns": [ + { + "expression": "provider_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_order_provider_status_idx": { + "name": "payment_attempts_order_provider_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_order_provider_active_unique": { + "name": "payment_attempts_order_provider_active_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_provider_status_updated_idx": { + "name": "payment_attempts_provider_status_updated_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_janitor_claim_idx": { + "name": "payment_attempts_janitor_claim_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "janitor_claimed_until", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "payment_attempts_provider_check": { + "name": "payment_attempts_provider_check", + "value": "\"payment_attempts\".\"provider\" in ('stripe','monobank')" + }, + "payment_attempts_status_check": { + "name": "payment_attempts_status_check", + "value": "\"payment_attempts\".\"status\" in ('creating','active','succeeded','failed','canceled')" + }, + "payment_attempts_attempt_number_check": { + "name": "payment_attempts_attempt_number_check", + "value": "\"payment_attempts\".\"attempt_number\" >= 1" + }, + "payment_attempts_expected_amount_minor_non_negative": { + "name": "payment_attempts_expected_amount_minor_non_negative", + "value": "\"payment_attempts\".\"expected_amount_minor\" is null or \"payment_attempts\".\"expected_amount_minor\" >= 0" + }, + "payment_attempts_mono_currency_uah": { + "name": "payment_attempts_mono_currency_uah", + "value": "\"payment_attempts\".\"provider\" <> 'monobank' OR \"payment_attempts\".\"currency\" = 'UAH'" + } + }, + "isRLSEnabled": false + }, + "public.payment_events": { + "name": "payment_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_charge_id": { + "name": "provider_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_events_dedupe_key_uq": { + "name": "payment_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_order_id_idx": { + "name": "payment_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_attempt_id_idx": { + "name": "payment_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_event_ref_idx": { + "name": "payment_events_event_ref_idx", + "columns": [ + { + "expression": "event_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_occurred_at_idx": { + "name": "payment_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_events_attempt_id_payment_attempts_id_fk": { + "name": "payment_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "payment_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_prices": { + "name": "product_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price_minor": { + "name": "price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "original_price_minor": { + "name": "original_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_prices_product_id_idx": { + "name": "product_prices_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_prices_product_currency_uq": { + "name": "product_prices_product_currency_uq", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "product_prices_price_positive": { + "name": "product_prices_price_positive", + "value": "\"product_prices\".\"price_minor\" > 0" + }, + "product_prices_original_price_valid": { + "name": "product_prices_original_price_valid", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price_minor\" > \"product_prices\".\"price_minor\"" + }, + "product_prices_price_mirror_consistent": { + "name": "product_prices_price_mirror_consistent", + "value": "\"product_prices\".\"price\" = (\"product_prices\".\"price_minor\"::numeric / 100)" + }, + "product_prices_original_price_null_coupled": { + "name": "product_prices_original_price_null_coupled", + "value": "(\"product_prices\".\"original_price_minor\" is null) = (\"product_prices\".\"original_price\" is null)" + }, + "product_prices_original_price_mirror_consistent": { + "name": "product_prices_original_price_mirror_consistent", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price\" = (\"product_prices\".\"original_price_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_public_id": { + "name": "image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "colors": { + "name": "colors", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "sizes": { + "name": "sizes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "badge": { + "name": "badge", + "type": "product_badge", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NONE'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stock": { + "name": "stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "products_slug_unique": { + "name": "products_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "products_stock_non_negative": { + "name": "products_stock_non_negative", + "value": "\"products\".\"stock\" >= 0" + }, + "products_currency_usd_only": { + "name": "products_currency_usd_only", + "value": "\"products\".\"currency\" = 'USD'" + }, + "products_price_positive": { + "name": "products_price_positive", + "value": "\"products\".\"price\" > 0" + }, + "products_original_price_valid": { + "name": "products_original_price_valid", + "value": "\"products\".\"original_price\" is null or \"products\".\"original_price\" > \"products\".\"price\"" + } + }, + "isRLSEnabled": false + }, + "public.return_items": { + "name": "return_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "return_request_id": { + "name": "return_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_total_minor": { + "name": "line_total_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "return_items_idempotency_key_uq": { + "name": "return_items_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_return_request_idx": { + "name": "return_items_return_request_idx", + "columns": [ + { + "expression": "return_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_order_id_idx": { + "name": "return_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_product_id_idx": { + "name": "return_items_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_items_return_request_id_return_requests_id_fk": { + "name": "return_items_return_request_id_return_requests_id_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_id_orders_id_fk": { + "name": "return_items_order_id_orders_id_fk", + "tableFrom": "return_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_item_id_order_items_id_fk": { + "name": "return_items_order_item_id_order_items_id_fk", + "tableFrom": "return_items", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_product_id_products_id_fk": { + "name": "return_items_product_id_products_id_fk", + "tableFrom": "return_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_return_request_order_fk": { + "name": "return_items_return_request_order_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id", + "order_id" + ], + "columnsTo": [ + "id", + "order_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_items_quantity_positive_chk": { + "name": "return_items_quantity_positive_chk", + "value": "\"return_items\".\"quantity\" > 0" + }, + "return_items_unit_price_minor_non_negative_chk": { + "name": "return_items_unit_price_minor_non_negative_chk", + "value": "\"return_items\".\"unit_price_minor\" >= 0" + }, + "return_items_line_total_minor_non_negative_chk": { + "name": "return_items_line_total_minor_non_negative_chk", + "value": "\"return_items\".\"line_total_minor\" >= 0" + }, + "return_items_line_total_consistent_chk": { + "name": "return_items_line_total_consistent_chk", + "value": "\"return_items\".\"line_total_minor\" = (\"return_items\".\"unit_price_minor\" * \"return_items\".\"quantity\")" + } + }, + "isRLSEnabled": false + }, + "public.return_requests": { + "name": "return_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "return_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_restock": { + "name": "policy_restock", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "refund_amount_minor": { + "name": "refund_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refunded_by": { + "name": "refunded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refund_provider_ref": { + "name": "refund_provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "return_requests_order_id_uq": { + "name": "return_requests_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_id_order_id_uq": { + "name": "return_requests_id_order_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_idempotency_key_uq": { + "name": "return_requests_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_status_created_idx": { + "name": "return_requests_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_user_id_created_idx": { + "name": "return_requests_user_id_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_requests_order_id_orders_id_fk": { + "name": "return_requests_order_id_orders_id_fk", + "tableFrom": "return_requests", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_requests_user_id_users_id_fk": { + "name": "return_requests_user_id_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_approved_by_users_id_fk": { + "name": "return_requests_approved_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_rejected_by_users_id_fk": { + "name": "return_requests_rejected_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "rejected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_received_by_users_id_fk": { + "name": "return_requests_received_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "received_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_refunded_by_users_id_fk": { + "name": "return_requests_refunded_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "refunded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_requests_refund_amount_minor_non_negative_chk": { + "name": "return_requests_refund_amount_minor_non_negative_chk", + "value": "\"return_requests\".\"refund_amount_minor\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.shipping_events": { + "name": "shipping_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shipment_id": { + "name": "shipment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_from": { + "name": "status_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_to": { + "name": "status_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_events_dedupe_key_uq": { + "name": "shipping_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_order_id_idx": { + "name": "shipping_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_shipment_id_idx": { + "name": "shipping_events_shipment_id_idx", + "columns": [ + { + "expression": "shipment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_occurred_at_idx": { + "name": "shipping_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_events_order_id_orders_id_fk": { + "name": "shipping_events_order_id_orders_id_fk", + "tableFrom": "shipping_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_events_shipment_id_shipping_shipments_id_fk": { + "name": "shipping_events_shipment_id_shipping_shipments_id_fk", + "tableFrom": "shipping_events", + "tableTo": "shipping_shipments", + "columnsFrom": [ + "shipment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shipping_quotes": { + "name": "shipping_quotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "offered_by": { + "name": "offered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "offered_at": { + "name": "offered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_quotes_order_version_uq": { + "name": "shipping_quotes_order_version_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_status_idx": { + "name": "shipping_quotes_order_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_status_expires_idx": { + "name": "shipping_quotes_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_updated_idx": { + "name": "shipping_quotes_order_updated_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_quotes_order_id_orders_id_fk": { + "name": "shipping_quotes_order_id_orders_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_quotes_offered_by_users_id_fk": { + "name": "shipping_quotes_offered_by_users_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "users", + "columnsFrom": [ + "offered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_quotes_version_positive_chk": { + "name": "shipping_quotes_version_positive_chk", + "value": "\"shipping_quotes\".\"version\" >= 1" + }, + "shipping_quotes_quote_minor_non_negative_chk": { + "name": "shipping_quotes_quote_minor_non_negative_chk", + "value": "\"shipping_quotes\".\"shipping_quote_minor\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.shipping_shipments": { + "name": "shipping_shipments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "shipping_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nova_poshta'" + }, + "status": { + "name": "status", + "type": "shipping_shipment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_shipments_order_id_uq": { + "name": "shipping_shipments_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_queue_idx": { + "name": "shipping_shipments_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_shipments_attempt_count_non_negative_chk": { + "name": "shipping_shipments_attempt_count_non_negative_chk", + "value": "\"shipping_shipments\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_events": { + "name": "stripe_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_events_event_id_idx": { + "name": "stripe_events_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_provider_provider_id_unique": { + "name": "users_provider_provider_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.fulfillment_mode": { + "name": "fulfillment_mode", + "schema": "public", + "values": [ + "ua_np", + "intl" + ] + }, + "public.inventory_move_type": { + "name": "inventory_move_type", + "schema": "public", + "values": [ + "reserve", + "release" + ] + }, + "public.inventory_status": { + "name": "inventory_status", + "schema": "public", + "values": [ + "none", + "reserving", + "reserved", + "release_pending", + "released", + "failed" + ] + }, + "public.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "email", + "sms" + ] + }, + "public.order_status": { + "name": "order_status", + "schema": "public", + "values": [ + "CREATED", + "INVENTORY_RESERVED", + "INVENTORY_FAILED", + "PAID", + "CANCELED" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "requires_payment", + "paid", + "failed", + "refunded", + "needs_review" + ] + }, + "public.product_badge": { + "name": "product_badge", + "schema": "public", + "values": [ + "NEW", + "SALE", + "NONE" + ] + }, + "public.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "none", + "requested", + "offered", + "accepted", + "declined", + "expired", + "requires_requote" + ] + }, + "public.return_request_status": { + "name": "return_request_status", + "schema": "public", + "values": [ + "requested", + "approved", + "rejected", + "received", + "refunded" + ] + }, + "public.shipping_method_code": { + "name": "shipping_method_code", + "schema": "public", + "values": [ + "NP_WAREHOUSE", + "NP_LOCKER", + "NP_COURIER" + ] + }, + "public.shipping_payer": { + "name": "shipping_payer", + "schema": "public", + "values": [ + "customer", + "merchant" + ] + }, + "public.shipping_provider": { + "name": "shipping_provider", + "schema": "public", + "values": [ + "nova_poshta", + "ukrposhta" + ] + }, + "public.shipping_shipment_status": { + "name": "shipping_shipment_status", + "schema": "public", + "values": [ + "queued", + "processing", + "succeeded", + "failed", + "needs_attention" + ] + }, + "public.shipping_status": { + "name": "shipping_status", + "schema": "public", + "values": [ + "pending", + "queued", + "creating_label", + "label_created", + "shipped", + "delivered", + "cancelled", + "needs_attention" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json index f387f51c..52af9b9e 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1772252564812, "tag": "0026_gray_stone_men", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1772560628660, + "tag": "0027_material_bloodscream", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b956429c..18f66ff1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "@neondatabase/serverless": "^1.0.2", "@phosphor-icons/react": "^2.1.10", From efa85ef9b95c50ea9b15b38f34dfefc181379068 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova <106915140+LesiaUKR@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:14:08 +0000 Subject: [PATCH 06/25] =?UTF-8?q?(SP:=203)=20[Database][Backend]=20Blog=20?= =?UTF-8?q?DB=20schema=20+=20Sanity=20=E2=86=92=20PostgreSQL=20data=20migr?= =?UTF-8?q?ation=20(#390)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(blog): resolve 500 error on blog post pages * fix(blog): remove unsafe GROQ coalesce fallbacks and revert CDN * feat(blog): add DB schema indexes + Sanity => PostgreSQL migration script - Add FK indexes on blog_posts.author_id and blog_post_categories.category_id - Add one-time migration script: fetches Sanity data via REST API, re-uploads images to Cloudinary, converts Portable Text → Tiptap JSON, inserts into 7 blog tables (4 categories, 3 authors, 21 posts) - Drizzle migration 0028 for index changes Closes #384, #385 --- frontend/db/schema/blog.ts | 8 +- frontend/db/seed-blog-migration.ts | 640 ++ .../drizzle/0028_bitter_wrecking_crew.sql | 2 + frontend/drizzle/meta/0028_snapshot.json | 6769 +++++++++++++++++ frontend/drizzle/meta/_journal.json | 7 + 5 files changed, 7425 insertions(+), 1 deletion(-) create mode 100644 frontend/db/seed-blog-migration.ts create mode 100644 frontend/drizzle/0028_bitter_wrecking_crew.sql create mode 100644 frontend/drizzle/meta/0028_snapshot.json diff --git a/frontend/db/schema/blog.ts b/frontend/db/schema/blog.ts index c492f86d..5fcca701 100644 --- a/frontend/db/schema/blog.ts +++ b/frontend/db/schema/blog.ts @@ -1,6 +1,7 @@ import { relations } from 'drizzle-orm'; import { boolean, + index, integer, jsonb, pgTable, @@ -95,7 +96,11 @@ export const blogPosts = pgTable('blog_posts', { updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() .defaultNow(), -}); +}, + table => ({ + authorIdx: index('blog_posts_author_id_idx').on(table.authorId), + }) +); export const blogPostTranslations = pgTable( 'blog_post_translations', @@ -126,6 +131,7 @@ export const blogPostCategories = pgTable( }, table => ({ pk: primaryKey({ columns: [table.postId, table.categoryId] }), + categoryIdx: index('blog_post_categories_category_id_idx').on(table.categoryId), }) ); diff --git a/frontend/db/seed-blog-migration.ts b/frontend/db/seed-blog-migration.ts new file mode 100644 index 00000000..cbe4aea8 --- /dev/null +++ b/frontend/db/seed-blog-migration.ts @@ -0,0 +1,640 @@ +/** + * One-time migration script: Sanity CMS → PostgreSQL + * + * Usage: + * APP_ENV=local npx tsx db/seed-blog-migration.ts + * APP_ENV=production npx tsx db/seed-blog-migration.ts + * + * Idempotent: safe to re-run (onConflictDoNothing on slug/composite PK). + * Delete this file after migration is verified. + */ + +import 'dotenv/config'; + +import { eq } from 'drizzle-orm'; + +import { uploadImage } from '../lib/cloudinary'; +import { db } from './index'; +import { + blogAuthors, + blogAuthorTranslations, + blogCategories, + blogCategoryTranslations, + blogPostCategories, + blogPosts, + blogPostTranslations, +} from './schema'; + +// ── Types ─────────────────────────────────────────────────────── + +const LOCALES = ['uk', 'en', 'pl'] as const; + +const SANITY_API = + 'https://6y9ive6v.api.sanity.io/v2025-11-29/data/query/production'; + +type SanityBlock = { + _type: 'block'; + _key?: string; + style?: string; + listItem?: 'bullet' | 'number'; + level?: number; + children?: Array<{ + _type?: string; + text?: string; + marks?: string[]; + }>; + markDefs?: Array<{ + _key?: string; + _type?: string; + href?: string; + }>; +}; + +type SanityImage = { + _type: 'image'; + _key?: string; + url?: string; +}; + +type SanityNode = SanityBlock | SanityImage; + +type TiptapMark = { + type: string; + attrs?: Record; +}; + +type TiptapNode = { + type: string; + attrs?: Record; + content?: TiptapNode[]; + text?: string; + marks?: TiptapMark[]; +}; + +// ── Sanity REST API fetcher ───────────────────────────────────── + +async function sanityFetch(query: string): Promise { + const url = `${SANITY_API}?query=${encodeURIComponent(query)}`; + const res = await fetch(url); + + if (!res.ok) { + throw new Error(`Sanity API ${res.status}: ${await res.text()}`); + } + + const json = (await res.json()) as { result: T }; + return json.result; +} + +// ── Image helpers ─────────────────────────────────────────────── + +async function downloadImageAsBuffer(imageUrl: string): Promise { + const res = await fetch(imageUrl); + + if (!res.ok) { + throw new Error(`Failed to download image ${imageUrl}: ${res.status}`); + } + + const arrayBuffer = await res.arrayBuffer(); + return Buffer.from(arrayBuffer); +} + +async function reuploadToCloudinary( + imageUrl: string, + folder: string +): Promise<{ url: string; publicId: string }> { + const buffer = await downloadImageAsBuffer(imageUrl); + return uploadImage(buffer, { folder }); +} + +// ── Portable Text → Tiptap JSON ──────────────────────────────── + +function convertSpansToTiptap( + children: SanityBlock['children'] = [], + markDefs: SanityBlock['markDefs'] = [] +): TiptapNode[] { + const linkMap = new Map(); + for (const def of markDefs) { + if (def?._type === 'link' && def._key && def.href) { + linkMap.set(def._key, def.href); + } + } + + const nodes: TiptapNode[] = []; + + for (const child of children) { + const text = child?.text || ''; + if (!text) continue; + + const marks: TiptapMark[] = []; + + for (const mark of child.marks || []) { + if (linkMap.has(mark)) { + marks.push({ + type: 'link', + attrs: { href: linkMap.get(mark)!, target: '_blank' }, + }); + } else if (mark === 'strong') { + marks.push({ type: 'bold' }); + } else if (mark === 'em') { + marks.push({ type: 'italic' }); + } else if (mark === 'underline') { + marks.push({ type: 'underline' }); + } else if (mark === 'code') { + marks.push({ type: 'code' }); + } else if (mark === 'strike-through' || mark === 'strike') { + marks.push({ type: 'strike' }); + } + } + + const node: TiptapNode = { type: 'text', text }; + if (marks.length > 0) node.marks = marks; + nodes.push(node); + } + + return nodes; +} + +function blockToTiptapNode(block: SanityBlock): TiptapNode { + const content = convertSpansToTiptap(block.children, block.markDefs); + const style = block.style || 'normal'; + + if (style.match(/^h[1-6]$/)) { + const level = parseInt(style[1], 10); + return { + type: 'heading', + attrs: { level }, + content: content.length > 0 ? content : undefined, + }; + } + + if (style === 'blockquote') { + return { + type: 'blockquote', + content: [ + { + type: 'paragraph', + content: content.length > 0 ? content : undefined, + }, + ], + }; + } + + return { + type: 'paragraph', + content: content.length > 0 ? content : undefined, + }; +} + +async function portableTextToTiptap( + blocks: SanityNode[], + imageFolder: string +): Promise { + const content: TiptapNode[] = []; + let i = 0; + + while (i < blocks.length) { + const block = blocks[i]; + + // Inline image — re-upload to Cloudinary + if (block._type === 'image' && (block as SanityImage).url) { + try { + const { url } = await reuploadToCloudinary( + (block as SanityImage).url!, + imageFolder + ); + content.push({ + type: 'image', + attrs: { src: url }, + }); + } catch (err) { + console.error( + ` [warn] Failed to upload inline image: ${(block as SanityImage).url}`, + err + ); + } + i++; + continue; + } + + // List items — group consecutive same-type items + if (block._type === 'block' && (block as SanityBlock).listItem) { + const sanityBlock = block as SanityBlock; + const listType = + sanityBlock.listItem === 'number' ? 'orderedList' : 'bulletList'; + + const items: TiptapNode[] = []; + while ( + i < blocks.length && + blocks[i]._type === 'block' && + (blocks[i] as SanityBlock).listItem === sanityBlock.listItem + ) { + const item = blocks[i] as SanityBlock; + const itemContent = convertSpansToTiptap(item.children, item.markDefs); + items.push({ + type: 'listItem', + content: [ + { + type: 'paragraph', + content: itemContent.length > 0 ? itemContent : undefined, + }, + ], + }); + i++; + } + + content.push({ type: listType, content: items }); + continue; + } + + // Regular block + if (block._type === 'block') { + content.push(blockToTiptapNode(block as SanityBlock)); + i++; + continue; + } + + // Unknown type — skip + i++; + } + + return { type: 'doc', content }; +} + +function extractPlainText(blocks: SanityNode[] | undefined): string { + if (!Array.isArray(blocks)) return ''; + return blocks + .filter((b): b is SanityBlock => b?._type === 'block') + .map(b => (b.children || []).map(c => c.text || '').join('')) + .join('\n') + .trim(); +} + +// ── Slug helper ───────────────────────────────────────────────── + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +// ── Migration functions ───────────────────────────────────────── + +async function migrateCategories() { + console.log('\n--- Migrating categories ---'); + + const categories = await sanityFetch< + Array<{ + _id: string; + title: string; + description?: string; + orderRank?: string; + }> + >( + `*[_type == "category"] | order(orderRank asc) { _id, title, description, orderRank }` + ); + + console.log(`Found ${categories.length} categories in Sanity`); + + let inserted = 0; + + for (let idx = 0; idx < categories.length; idx++) { + const cat = categories[idx]; + const slug = slugify(cat.title); + + const [row] = await db + .insert(blogCategories) + .values({ + slug, + displayOrder: idx, + }) + .onConflictDoNothing() + .returning(); + + if (!row) { + console.log( + ` [skip] Category "${cat.title}" (slug: ${slug}) already exists` + ); + continue; + } + + // Sanity title is NOT multilingual — duplicate into 3 locale rows + const translations = LOCALES.map(locale => ({ + categoryId: row.id, + locale, + title: cat.title, + description: cat.description || null, + })); + + await db + .insert(blogCategoryTranslations) + .values(translations) + .onConflictDoNothing(); + + inserted++; + console.log(` [ok] ${cat.title} (slug: ${slug})`); + } + + console.log( + `Categories done: ${inserted} inserted, ${categories.length - inserted} skipped` + ); +} + +async function migrateAuthors(): Promise> { + console.log('\n--- Migrating authors ---'); + + // Sanity _id → our DB uuid + const idMap = new Map(); + + const authors = await sanityFetch< + Array<{ + _id: string; + slug?: { current?: string }; + name?: Record; + bio?: Record; + jobTitle?: Record; + company?: Record; + city?: Record; + imageUrl?: string; + socialMedia?: Array<{ + _key?: string; + platform?: string; + url?: string; + }>; + }> + >(`*[_type == "author"] { + _id, + slug, + name, + bio, + jobTitle, + company, + city, + "imageUrl": image.asset->url, + socialMedia[]{ _key, platform, url } + }`); + + console.log(`Found ${authors.length} authors in Sanity`); + + for (const author of authors) { + const slug = + author.slug?.current || + slugify(author.name?.en || author.name?.uk || 'unknown'); + + // Upload profile image to Cloudinary + let imageUrl: string | null = null; + let imagePublicId: string | null = null; + + if (author.imageUrl) { + try { + const result = await reuploadToCloudinary( + author.imageUrl, + 'blog/authors' + ); + imageUrl = result.url; + imagePublicId = result.publicId; + console.log(` [img] Uploaded author image: ${slug}`); + } catch (err) { + console.error( + ` [warn] Failed to upload author image for ${slug}:`, + err + ); + } + } + + const socialMedia = (author.socialMedia || []) + .filter(s => s.platform && s.url) + .map(s => ({ platform: s.platform!, url: s.url! })); + + const [row] = await db + .insert(blogAuthors) + .values({ + slug, + imageUrl, + imagePublicId, + socialMedia: JSON.stringify(socialMedia), + displayOrder: 0, + }) + .onConflictDoNothing() + .returning(); + + if (!row) { + console.log(` [skip] Author "${slug}" already exists`); + // Still need the ID for post mapping — look it up + const existing = await db + .select({ id: blogAuthors.id }) + .from(blogAuthors) + .where(eq(blogAuthors.slug, slug)); + if (existing[0]) idMap.set(author._id, existing[0].id); + continue; + } + + idMap.set(author._id, row.id); + + // Insert translations for each locale + const translations = LOCALES.map(locale => ({ + authorId: row.id, + locale, + name: + author.name?.[locale] || + author.name?.en || + author.name?.uk || + 'Unknown', + bio: extractPlainText(author.bio?.[locale]) || null, + jobTitle: author.jobTitle?.[locale] || author.jobTitle?.en || null, + company: author.company?.[locale] || author.company?.en || null, + city: author.city?.[locale] || author.city?.en || null, + })); + + await db + .insert(blogAuthorTranslations) + .values(translations) + .onConflictDoNothing(); + + console.log(` [ok] ${slug}`); + } + + console.log(`Authors done: ${idMap.size} mapped`); + return idMap; +} + +async function migratePosts(authorIdMap: Map) { + console.log('\n--- Migrating posts ---'); + + // Load our DB categories for slug → id mapping + const dbCategories = await db + .select({ id: blogCategories.id, slug: blogCategories.slug }) + .from(blogCategories); + const categorySlugMap = new Map(dbCategories.map(c => [c.slug, c.id])); + + // Fetch posts from Sanity with ALL locale bodies (not coalesced) + const posts = await sanityFetch< + Array<{ + _id: string; + slug?: { current?: string }; + title?: Record; + publishedAt?: string; + tags?: string[]; + resourceLink?: string; + mainImageUrl?: string; + categories?: Array<{ _id?: string; title?: string }>; + authorRef?: { _ref?: string }; + bodyUk?: SanityNode[]; + bodyEn?: SanityNode[]; + bodyPl?: SanityNode[]; + }> + >(`*[_type == "post" && defined(slug.current)] | order(publishedAt desc) { + _id, + slug, + title, + publishedAt, + tags, + resourceLink, + "mainImageUrl": mainImage.asset->url, + "categories": categories[]->{_id, title}, + "authorRef": author, + "bodyUk": body.uk[]{..., _type == "image" => {..., "url": asset->url}}, + "bodyEn": body.en[]{..., _type == "image" => {..., "url": asset->url}}, + "bodyPl": body.pl[]{..., _type == "image" => {..., "url": asset->url}} + }`); + + console.log(`Found ${posts.length} posts in Sanity`); + + let inserted = 0; + + for (const post of posts) { + const slug = post.slug?.current; + if (!slug) { + console.log(` [skip] Post without slug: ${post._id}`); + continue; + } + + // Upload mainImage to Cloudinary + let mainImageUrl: string | null = null; + let mainImagePublicId: string | null = null; + + if (post.mainImageUrl) { + try { + const result = await reuploadToCloudinary( + post.mainImageUrl, + 'blog/posts' + ); + mainImageUrl = result.url; + mainImagePublicId = result.publicId; + console.log(` [img] Uploaded main image: ${slug}`); + } catch (err) { + console.error( + ` [warn] Failed to upload main image for ${slug}:`, + err + ); + } + } + + // Resolve author ID via Sanity _ref → our DB uuid + const authorRef = post.authorRef?._ref; + const authorId = authorRef ? (authorIdMap.get(authorRef) ?? null) : null; + + const [row] = await db + .insert(blogPosts) + .values({ + slug, + authorId, + mainImageUrl, + mainImagePublicId, + tags: post.tags || [], + resourceLink: post.resourceLink || null, + publishedAt: post.publishedAt ? new Date(post.publishedAt) : null, + isPublished: true, + }) + .onConflictDoNothing() + .returning(); + + if (!row) { + console.log(` [skip] Post "${slug}" already exists`); + continue; + } + + // Convert body for each locale → Tiptap JSON, insert translations + const bodyByLocale = { + uk: post.bodyUk, + en: post.bodyEn, + pl: post.bodyPl, + }; + + for (const locale of LOCALES) { + const rawBody = bodyByLocale[locale]; + const title = + post.title?.[locale] || + post.title?.en || + post.title?.uk || + 'Untitled'; + + let body: TiptapNode | null = null; + if (rawBody && Array.isArray(rawBody) && rawBody.length > 0) { + body = await portableTextToTiptap(rawBody, 'blog/posts'); + } + + await db + .insert(blogPostTranslations) + .values({ + postId: row.id, + locale, + title, + body: body ? JSON.stringify(body) : null, + }) + .onConflictDoNothing(); + } + + // Insert post ↔ category junction rows + if (post.categories && post.categories.length > 0) { + const junctionRows = post.categories + .map(cat => { + const catSlug = slugify(cat.title || ''); + const categoryId = categorySlugMap.get(catSlug); + if (!categoryId) { + console.log( + ` [warn] Unknown category "${cat.title}" for post ${slug}` + ); + return null; + } + return { postId: row.id, categoryId }; + }) + .filter(Boolean) as Array<{ postId: string; categoryId: string }>; + + if (junctionRows.length > 0) { + await db + .insert(blogPostCategories) + .values(junctionRows) + .onConflictDoNothing(); + } + } + + inserted++; + const localesWithBody = LOCALES.filter(l => bodyByLocale[l]); + console.log(` [ok] ${slug} (body: ${localesWithBody.join(', ') || 'none'})`); + } + + console.log( + `Posts done: ${inserted} inserted, ${posts.length - inserted} skipped` + ); +} + +// ── Main ──────────────────────────────────────────────────────── + +async function main() { + console.log('=== Blog Migration: Sanity -> PostgreSQL ==='); + console.log(`APP_ENV: ${process.env.APP_ENV || 'not set'}`); + + await migrateCategories(); + const authorIdMap = await migrateAuthors(); + await migratePosts(authorIdMap); + + console.log('\n=== Migration complete ==='); +} + +main().catch(err => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/frontend/drizzle/0028_bitter_wrecking_crew.sql b/frontend/drizzle/0028_bitter_wrecking_crew.sql new file mode 100644 index 00000000..67f7ba9a --- /dev/null +++ b/frontend/drizzle/0028_bitter_wrecking_crew.sql @@ -0,0 +1,2 @@ +CREATE INDEX "blog_post_categories_category_id_idx" ON "blog_post_categories" USING btree ("category_id");--> statement-breakpoint +CREATE INDEX "blog_posts_author_id_idx" ON "blog_posts" USING btree ("author_id"); \ No newline at end of file diff --git a/frontend/drizzle/meta/0028_snapshot.json b/frontend/drizzle/meta/0028_snapshot.json new file mode 100644 index 00000000..2ec7d47d --- /dev/null +++ b/frontend/drizzle/meta/0028_snapshot.json @@ -0,0 +1,6769 @@ +{ + "id": "ac4e69dc-fffe-44ff-8817-92b4ee841e43", + "prevId": "2e864fcd-334b-40c0-8378-ea18ead78463", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blog_author_translations": { + "name": "blog_author_translations", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "blog_author_translations_author_id_blog_authors_id_fk": { + "name": "blog_author_translations_author_id_blog_authors_id_fk", + "tableFrom": "blog_author_translations", + "tableTo": "blog_authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_author_translations_author_id_locale_pk": { + "name": "blog_author_translations_author_id_locale_pk", + "columns": [ + "author_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_authors": { + "name": "blog_authors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_public_id": { + "name": "image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "social_media": { + "name": "social_media", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_authors_slug_unique": { + "name": "blog_authors_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_categories": { + "name": "blog_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": { + "blog_categories_slug_unique": { + "name": "blog_categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_category_translations": { + "name": "blog_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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "blog_category_translations_category_id_blog_categories_id_fk": { + "name": "blog_category_translations_category_id_blog_categories_id_fk", + "tableFrom": "blog_category_translations", + "tableTo": "blog_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_category_translations_category_id_locale_pk": { + "name": "blog_category_translations_category_id_locale_pk", + "columns": [ + "category_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_post_categories": { + "name": "blog_post_categories", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blog_post_categories_category_id_idx": { + "name": "blog_post_categories_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_post_categories_post_id_blog_posts_id_fk": { + "name": "blog_post_categories_post_id_blog_posts_id_fk", + "tableFrom": "blog_post_categories", + "tableTo": "blog_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blog_post_categories_category_id_blog_categories_id_fk": { + "name": "blog_post_categories_category_id_blog_categories_id_fk", + "tableFrom": "blog_post_categories", + "tableTo": "blog_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_post_categories_post_id_category_id_pk": { + "name": "blog_post_categories_post_id_category_id_pk", + "columns": [ + "post_id", + "category_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_post_translations": { + "name": "blog_post_translations", + "schema": "", + "columns": { + "post_id": { + "name": "post_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 + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "blog_post_translations_post_id_blog_posts_id_fk": { + "name": "blog_post_translations_post_id_blog_posts_id_fk", + "tableFrom": "blog_post_translations", + "tableTo": "blog_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_post_translations_post_id_locale_pk": { + "name": "blog_post_translations_post_id_locale_pk", + "columns": [ + "post_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "main_image_url": { + "name": "main_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "main_image_public_id": { + "name": "main_image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "resource_link": { + "name": "resource_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scheduled_publish_at": { + "name": "scheduled_publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "blog_posts_author_id_idx": { + "name": "blog_posts_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_posts_author_id_blog_authors_id_fk": { + "name": "blog_posts_author_id_blog_authors_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_posts_slug_unique": { + "name": "blog_posts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_translations": { + "name": "category_translations", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "category_translations_category_id_categories_id_fk": { + "name": "category_translations_category_id_categories_id_fk", + "tableFrom": "category_translations", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "category_translations_category_id_locale_pk": { + "name": "category_translations_category_id_locale_pk", + "columns": [ + "category_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_tokens": { + "name": "email_verification_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_tokens_user_id_idx": { + "name": "email_verification_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.point_transactions": { + "name": "point_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'quiz'" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "point_transactions_user_id_idx": { + "name": "point_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_translations": { + "name": "question_translations", + "schema": "", + "columns": { + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer_blocks": { + "name": "answer_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "question_translations_question_id_questions_id_fk": { + "name": "question_translations_question_id_questions_id_fk", + "tableFrom": "question_translations", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "question_translations_question_id_locale_pk": { + "name": "question_translations_question_id_locale_pk", + "columns": [ + "question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "questions_category_sort_order_idx": { + "name": "questions_category_sort_order_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answer_translations": { + "name": "quiz_answer_translations", + "schema": "", + "columns": { + "quiz_answer_id": { + "name": "quiz_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "answer_text": { + "name": "answer_text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk": { + "name": "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_answer_translations", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_answer_translations_quiz_answer_id_locale_pk": { + "name": "quiz_answer_translations_quiz_answer_id_locale_pk", + "columns": [ + "quiz_answer_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answers": { + "name": "quiz_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "quiz_answers_question_display_order_idx": { + "name": "quiz_answers_question_display_order_idx", + "columns": [ + { + "expression": "quiz_question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempt_answers": { + "name": "quiz_attempt_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_answer_id": { + "name": "selected_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempt_answers_attempt_idx": { + "name": "quiz_attempt_answers_attempt_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk": { + "name": "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempts": { + "name": "quiz_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_questions": { + "name": "total_questions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "time_spent_seconds": { + "name": "time_spent_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "integrity_score": { + "name": "integrity_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 100 + }, + "points_earned": { + "name": "points_earned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempts_user_id_idx": { + "name": "quiz_attempts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_user_completed_at_idx": { + "name": "quiz_attempts_user_completed_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_percentage_completed_at_idx": { + "name": "quiz_attempts_quiz_percentage_completed_at_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "percentage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_integrity_score_idx": { + "name": "quiz_attempts_quiz_integrity_score_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integrity_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_question_content": { + "name": "quiz_question_content", + "schema": "", + "columns": { + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question_text": { + "name": "question_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_question_content_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_question_content_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_question_content", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_question_content_quiz_question_id_locale_pk": { + "name": "quiz_question_content_quiz_question_id_locale_pk", + "columns": [ + "quiz_question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_questions": { + "name": "quiz_questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_question_id": { + "name": "source_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_questions_quiz_display_order_idx": { + "name": "quiz_questions_quiz_display_order_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_translations": { + "name": "quiz_translations", + "schema": "", + "columns": { + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_translations_quiz_id_quizzes_id_fk": { + "name": "quiz_translations_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_translations", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_translations_quiz_id_locale_pk": { + "name": "quiz_translations_quiz_id_locale_pk", + "columns": [ + "quiz_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quizzes": { + "name": "quizzes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "questions_count": { + "name": "questions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "time_limit_seconds": { + "name": "time_limit_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quizzes_slug_idx": { + "name": "quizzes_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.active_sessions": { + "name": "active_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "active_sessions_last_activity_idx": { + "name": "active_sessions_last_activity_idx", + "columns": [ + { + "expression": "last_activity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_log_dedupe_key_uq": { + "name": "admin_audit_log_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_order_id_idx": { + "name": "admin_audit_log_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_actor_user_id_idx": { + "name": "admin_audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_occurred_at_idx": { + "name": "admin_audit_log_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_order_id_orders_id_fk": { + "name": "admin_audit_log_order_id_orders_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_audit_log_actor_user_id_users_id_fk": { + "name": "admin_audit_log_actor_user_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_rate_limits": { + "name": "api_rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_started_at": { + "name": "window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_rate_limits_updated_at_idx": { + "name": "api_rate_limits_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_rate_limits_count_non_negative": { + "name": "api_rate_limits_count_non_negative", + "value": "\"api_rate_limits\".\"count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.internal_job_state": { + "name": "internal_job_state", + "schema": "", + "columns": { + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "next_allowed_at": { + "name": "next_allowed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_moves": { + "name": "inventory_moves", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "move_key": { + "name": "move_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "inventory_move_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_moves_move_key_uq": { + "name": "inventory_moves_move_key_uq", + "columns": [ + { + "expression": "move_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inventory_moves_quantity_gt_0": { + "name": "inventory_moves_quantity_gt_0", + "value": "\"inventory_moves\".\"quantity\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.monobank_events": { + "name": "monobank_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monobank'" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ccy": { + "name": "ccy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "normalized_payload": { + "name": "normalized_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_result": { + "name": "applied_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_error_code": { + "name": "applied_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_error_message": { + "name": "applied_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_sha256": { + "name": "raw_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_events_event_key_unique": { + "name": "monobank_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_events_provider_check": { + "name": "monobank_events_provider_check", + "value": "\"monobank_events\".\"provider\" in ('monobank')" + } + }, + "isRLSEnabled": false + }, + "public.monobank_payment_cancels": { + "name": "monobank_payment_cancels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ext_ref": { + "name": "ext_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_response": { + "name": "psp_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_payment_cancels_ext_ref_unique": { + "name": "monobank_payment_cancels_ext_ref_unique", + "columns": [ + { + "expression": "ext_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_payment_cancels_order_id_idx": { + "name": "monobank_payment_cancels_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_payment_cancels_attempt_id_idx": { + "name": "monobank_payment_cancels_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_payment_cancels_status_check": { + "name": "monobank_payment_cancels_status_check", + "value": "\"monobank_payment_cancels\".\"status\" in ('requested','processing','success','failure')" + } + }, + "isRLSEnabled": false + }, + "public.monobank_refunds": { + "name": "monobank_refunds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monobank'" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ext_ref": { + "name": "ext_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'UAH'" + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_refunds_ext_ref_unique": { + "name": "monobank_refunds_ext_ref_unique", + "columns": [ + { + "expression": "ext_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_refunds_provider_check": { + "name": "monobank_refunds_provider_check", + "value": "\"monobank_refunds\".\"provider\" in ('monobank')" + }, + "monobank_refunds_status_check": { + "name": "monobank_refunds_status_check", + "value": "\"monobank_refunds\".\"status\" in ('requested','processing','success','failure','needs_review')" + }, + "monobank_refunds_amount_minor_non_negative": { + "name": "monobank_refunds_amount_minor_non_negative", + "value": "\"monobank_refunds\".\"amount_minor\" >= 0" + }, + "monobank_refunds_currency_uah": { + "name": "monobank_refunds_currency_uah", + "value": "\"monobank_refunds\".\"currency\" = 'UAH'" + } + }, + "isRLSEnabled": false + }, + "public.notification_outbox": { + "name": "notification_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "template_key": { + "name": "template_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_domain": { + "name": "source_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_owner": { + "name": "lease_owner", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dead_lettered_at": { + "name": "dead_lettered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_outbox_dedupe_key_uq": { + "name": "notification_outbox_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_next_attempt_idx": { + "name": "notification_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_lease_expires_idx": { + "name": "notification_outbox_status_lease_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_order_created_idx": { + "name": "notification_outbox_order_created_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_template_status_idx": { + "name": "notification_outbox_template_status_idx", + "columns": [ + { + "expression": "template_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_outbox_order_id_orders_id_fk": { + "name": "notification_outbox_order_id_orders_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notification_outbox_source_domain_chk": { + "name": "notification_outbox_source_domain_chk", + "value": "\"notification_outbox\".\"source_domain\" in ('shipping_event','payment_event')" + }, + "notification_outbox_status_chk": { + "name": "notification_outbox_status_chk", + "value": "\"notification_outbox\".\"status\" in ('pending','processing','sent','failed','dead_letter')" + }, + "notification_outbox_attempt_count_non_negative_chk": { + "name": "notification_outbox_attempt_count_non_negative_chk", + "value": "\"notification_outbox\".\"attempt_count\" >= 0" + }, + "notification_outbox_max_attempts_positive_chk": { + "name": "notification_outbox_max_attempts_positive_chk", + "value": "\"notification_outbox\".\"max_attempts\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.np_cities": { + "name": "np_cities", + "schema": "", + "columns": { + "ref": { + "name": "ref", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name_ua": { + "name": "name_ua", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_ru": { + "name": "name_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settlement_type": { + "name": "settlement_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_sync_run_id": { + "name": "last_sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "np_cities_active_name_idx": { + "name": "np_cities_active_name_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_ua", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_cities_last_sync_run_idx": { + "name": "np_cities_last_sync_run_idx", + "columns": [ + { + "expression": "last_sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_cities_active_name_prefix_idx": { + "name": "np_cities_active_name_prefix_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_ua", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.np_warehouses": { + "name": "np_warehouses", + "schema": "", + "columns": { + "ref": { + "name": "ref", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "city_ref": { + "name": "city_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settlement_ref": { + "name": "settlement_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_ru": { + "name": "name_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_ru": { + "name": "address_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_post_machine": { + "name": "is_post_machine", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_sync_run_id": { + "name": "last_sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "np_warehouses_settlement_active_idx": { + "name": "np_warehouses_settlement_active_idx", + "columns": [ + { + "expression": "settlement_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_warehouses_city_active_idx": { + "name": "np_warehouses_city_active_idx", + "columns": [ + { + "expression": "city_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_warehouses_active_name_idx": { + "name": "np_warehouses_active_name_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_warehouses_last_sync_run_idx": { + "name": "np_warehouses_last_sync_run_idx", + "columns": [ + { + "expression": "last_sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "np_warehouses_settlement_ref_np_cities_ref_fk": { + "name": "np_warehouses_settlement_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "tableTo": "np_cities", + "columnsFrom": [ + "settlement_ref" + ], + "columnsTo": [ + "ref" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_items": { + "name": "order_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_size": { + "name": "selected_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "selected_color": { + "name": "selected_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_total_minor": { + "name": "line_total_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price": { + "name": "unit_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "line_total": { + "name": "line_total", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "product_title": { + "name": "product_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_slug": { + "name": "product_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "order_items_order_id_idx": { + "name": "order_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_items_order_variant_uq": { + "name": "order_items_order_variant_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_size", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_color", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_items_quantity_positive": { + "name": "order_items_quantity_positive", + "value": "\"order_items\".\"quantity\" > 0" + }, + "order_items_unit_price_minor_non_negative": { + "name": "order_items_unit_price_minor_non_negative", + "value": "\"order_items\".\"unit_price_minor\" >= 0" + }, + "order_items_line_total_minor_non_negative": { + "name": "order_items_line_total_minor_non_negative", + "value": "\"order_items\".\"line_total_minor\" >= 0" + }, + "order_items_line_total_consistent": { + "name": "order_items_line_total_consistent", + "value": "\"order_items\".\"line_total_minor\" = \"order_items\".\"unit_price_minor\" * \"order_items\".\"quantity\"" + }, + "order_items_unit_price_mirror_consistent": { + "name": "order_items_unit_price_mirror_consistent", + "value": "\"order_items\".\"unit_price\" = (\"order_items\".\"unit_price_minor\"::numeric / 100)" + }, + "order_items_line_total_mirror_consistent": { + "name": "order_items_line_total_mirror_consistent", + "value": "\"order_items\".\"line_total\" = (\"order_items\".\"line_total_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.order_legal_consents": { + "name": "order_legal_consents", + "schema": "", + "columns": { + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "privacy_accepted": { + "name": "privacy_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "terms_version": { + "name": "terms_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "privacy_version": { + "name": "privacy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'checkout'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "order_legal_consents_consented_idx": { + "name": "order_legal_consents_consented_idx", + "columns": [ + { + "expression": "consented_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_legal_consents_order_id_orders_id_fk": { + "name": "order_legal_consents_order_id_orders_id_fk", + "tableFrom": "order_legal_consents", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_legal_consents_terms_accepted_chk": { + "name": "order_legal_consents_terms_accepted_chk", + "value": "\"order_legal_consents\".\"terms_accepted\" = true" + }, + "order_legal_consents_privacy_accepted_chk": { + "name": "order_legal_consents_privacy_accepted_chk", + "value": "\"order_legal_consents\".\"privacy_accepted\" = true" + } + }, + "isRLSEnabled": false + }, + "public.order_shipping": { + "name": "order_shipping", + "schema": "", + "columns": { + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "shipping_address": { + "name": "shipping_address", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "order_shipping_updated_idx": { + "name": "order_shipping_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_amount_minor": { + "name": "total_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "fulfillment_mode": { + "name": "fulfillment_mode", + "type": "fulfillment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ua_np'" + }, + "quote_status": { + "name": "quote_status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "quote_version": { + "name": "quote_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "items_subtotal_minor": { + "name": "items_subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quote_accepted_at": { + "name": "quote_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quote_payment_deadline_at": { + "name": "quote_payment_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "shipping_required": { + "name": "shipping_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "shipping_payer": { + "name": "shipping_payer", + "type": "shipping_payer", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_provider": { + "name": "shipping_provider", + "type": "shipping_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_method_code": { + "name": "shipping_method_code", + "type": "shipping_method_code", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_amount_minor": { + "name": "shipping_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_status": { + "name": "shipping_status", + "type": "shipping_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shipping_provider_ref": { + "name": "shipping_provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_charge_id": { + "name": "psp_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_payment_method": { + "name": "psp_payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_status_reason": { + "name": "psp_status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_metadata": { + "name": "psp_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "order_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CREATED'" + }, + "inventory_status": { + "name": "inventory_status", + "type": "inventory_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_request_hash": { + "name": "idempotency_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stock_restored": { + "name": "stock_restored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "restocked_at": { + "name": "restocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sweep_claimed_at": { + "name": "sweep_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_claim_expires_at": { + "name": "sweep_claim_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_run_id": { + "name": "sweep_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sweep_claimed_by": { + "name": "sweep_claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "orders_sweep_claim_expires_idx": { + "name": "orders_sweep_claim_expires_idx", + "columns": [ + { + "expression": "sweep_claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orders_user_id_created_at": { + "name": "idx_orders_user_id_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_shipping_status_idx": { + "name": "orders_shipping_status_idx", + "columns": [ + { + "expression": "shipping_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_deadline_idx": { + "name": "orders_quote_status_deadline_idx", + "columns": [ + { + "expression": "fulfillment_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_payment_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_updated_idx": { + "name": "orders_quote_status_updated_idx", + "columns": [ + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "orders_payment_provider_valid": { + "name": "orders_payment_provider_valid", + "value": "\"orders\".\"payment_provider\" in ('stripe', 'monobank', 'none')" + }, + "orders_total_amount_minor_non_negative": { + "name": "orders_total_amount_minor_non_negative", + "value": "\"orders\".\"total_amount_minor\" >= 0" + }, + "orders_items_subtotal_minor_non_negative": { + "name": "orders_items_subtotal_minor_non_negative", + "value": "\"orders\".\"items_subtotal_minor\" >= 0" + }, + "orders_shipping_quote_minor_non_negative": { + "name": "orders_shipping_quote_minor_non_negative", + "value": "\"orders\".\"shipping_quote_minor\" is null or \"orders\".\"shipping_quote_minor\" >= 0" + }, + "orders_payment_intent_id_null_when_none": { + "name": "orders_payment_intent_id_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_intent_id\" IS NULL" + }, + "orders_psp_fields_null_when_none": { + "name": "orders_psp_fields_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR (\n \"orders\".\"psp_charge_id\" IS NULL AND\n \"orders\".\"psp_payment_method\" IS NULL AND\n \"orders\".\"psp_status_reason\" IS NULL\n )" + }, + "orders_total_amount_mirror_consistent": { + "name": "orders_total_amount_mirror_consistent", + "value": "\"orders\".\"total_amount\" = (\"orders\".\"total_amount_minor\"::numeric / 100)" + }, + "orders_payment_status_valid_when_none": { + "name": "orders_payment_status_valid_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_status\" in ('paid','failed')" + }, + "orders_shipping_null_when_not_required_chk": { + "name": "orders_shipping_null_when_not_required_chk", + "value": "\n \"orders\".\"shipping_required\" IS TRUE\n OR (\n \"orders\".\"shipping_provider\" IS NULL\n AND \"orders\".\"shipping_method_code\" IS NULL\n AND \"orders\".\"shipping_status\" IS NULL\n )\n " + }, + "orders_shipping_present_when_required_chk": { + "name": "orders_shipping_present_when_required_chk", + "value": "\n \"orders\".\"shipping_required\" IS DISTINCT FROM TRUE\n OR (\n \"orders\".\"shipping_provider\" IS NOT NULL\n AND \"orders\".\"shipping_method_code\" IS NOT NULL\n AND \"orders\".\"shipping_status\" IS NOT NULL\n )\n " + }, + "orders_shipping_amount_minor_non_negative_chk": { + "name": "orders_shipping_amount_minor_non_negative_chk", + "value": "\"orders\".\"shipping_amount_minor\" IS NULL OR \"orders\".\"shipping_amount_minor\" >= 0" + }, + "orders_shipping_payer_null_when_not_required_chk": { + "name": "orders_shipping_payer_null_when_not_required_chk", + "value": "\"orders\".\"shipping_required\" IS TRUE OR \"orders\".\"shipping_payer\" IS NULL" + }, + "orders_shipping_payer_present_when_required_chk": { + "name": "orders_shipping_payer_present_when_required_chk", + "value": "\"orders\".\"shipping_required\" IS DISTINCT FROM TRUE OR \"orders\".\"shipping_payer\" IS NOT NULL" + }, + "orders_intl_provider_restriction_chk": { + "name": "orders_intl_provider_restriction_chk", + "value": "\"orders\".\"fulfillment_mode\" <> 'intl' OR \"orders\".\"payment_provider\" in ('stripe', 'none')" + } + }, + "isRLSEnabled": false + }, + "public.payment_attempts": { + "name": "payment_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "expected_amount_minor": { + "name": "expected_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_url": { + "name": "checkout_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "janitor_claimed_until": { + "name": "janitor_claimed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "janitor_claimed_by": { + "name": "janitor_claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payment_attempts_order_provider_attempt_unique": { + "name": "payment_attempts_order_provider_attempt_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_provider_pi_unique": { + "name": "payment_attempts_provider_pi_unique", + "columns": [ + { + "expression": "provider_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_order_provider_status_idx": { + "name": "payment_attempts_order_provider_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_order_provider_active_unique": { + "name": "payment_attempts_order_provider_active_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_provider_status_updated_idx": { + "name": "payment_attempts_provider_status_updated_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_janitor_claim_idx": { + "name": "payment_attempts_janitor_claim_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "janitor_claimed_until", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "payment_attempts_provider_check": { + "name": "payment_attempts_provider_check", + "value": "\"payment_attempts\".\"provider\" in ('stripe','monobank')" + }, + "payment_attempts_status_check": { + "name": "payment_attempts_status_check", + "value": "\"payment_attempts\".\"status\" in ('creating','active','succeeded','failed','canceled')" + }, + "payment_attempts_attempt_number_check": { + "name": "payment_attempts_attempt_number_check", + "value": "\"payment_attempts\".\"attempt_number\" >= 1" + }, + "payment_attempts_expected_amount_minor_non_negative": { + "name": "payment_attempts_expected_amount_minor_non_negative", + "value": "\"payment_attempts\".\"expected_amount_minor\" is null or \"payment_attempts\".\"expected_amount_minor\" >= 0" + }, + "payment_attempts_mono_currency_uah": { + "name": "payment_attempts_mono_currency_uah", + "value": "\"payment_attempts\".\"provider\" <> 'monobank' OR \"payment_attempts\".\"currency\" = 'UAH'" + } + }, + "isRLSEnabled": false + }, + "public.payment_events": { + "name": "payment_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_charge_id": { + "name": "provider_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_events_dedupe_key_uq": { + "name": "payment_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_order_id_idx": { + "name": "payment_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_attempt_id_idx": { + "name": "payment_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_event_ref_idx": { + "name": "payment_events_event_ref_idx", + "columns": [ + { + "expression": "event_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_occurred_at_idx": { + "name": "payment_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_events_attempt_id_payment_attempts_id_fk": { + "name": "payment_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "payment_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_prices": { + "name": "product_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price_minor": { + "name": "price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "original_price_minor": { + "name": "original_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_prices_product_id_idx": { + "name": "product_prices_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_prices_product_currency_uq": { + "name": "product_prices_product_currency_uq", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "product_prices_price_positive": { + "name": "product_prices_price_positive", + "value": "\"product_prices\".\"price_minor\" > 0" + }, + "product_prices_original_price_valid": { + "name": "product_prices_original_price_valid", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price_minor\" > \"product_prices\".\"price_minor\"" + }, + "product_prices_price_mirror_consistent": { + "name": "product_prices_price_mirror_consistent", + "value": "\"product_prices\".\"price\" = (\"product_prices\".\"price_minor\"::numeric / 100)" + }, + "product_prices_original_price_null_coupled": { + "name": "product_prices_original_price_null_coupled", + "value": "(\"product_prices\".\"original_price_minor\" is null) = (\"product_prices\".\"original_price\" is null)" + }, + "product_prices_original_price_mirror_consistent": { + "name": "product_prices_original_price_mirror_consistent", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price\" = (\"product_prices\".\"original_price_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_public_id": { + "name": "image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "colors": { + "name": "colors", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "sizes": { + "name": "sizes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "badge": { + "name": "badge", + "type": "product_badge", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NONE'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stock": { + "name": "stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "products_slug_unique": { + "name": "products_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "products_stock_non_negative": { + "name": "products_stock_non_negative", + "value": "\"products\".\"stock\" >= 0" + }, + "products_currency_usd_only": { + "name": "products_currency_usd_only", + "value": "\"products\".\"currency\" = 'USD'" + }, + "products_price_positive": { + "name": "products_price_positive", + "value": "\"products\".\"price\" > 0" + }, + "products_original_price_valid": { + "name": "products_original_price_valid", + "value": "\"products\".\"original_price\" is null or \"products\".\"original_price\" > \"products\".\"price\"" + } + }, + "isRLSEnabled": false + }, + "public.return_items": { + "name": "return_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "return_request_id": { + "name": "return_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_total_minor": { + "name": "line_total_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "return_items_idempotency_key_uq": { + "name": "return_items_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_return_request_idx": { + "name": "return_items_return_request_idx", + "columns": [ + { + "expression": "return_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_order_id_idx": { + "name": "return_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_product_id_idx": { + "name": "return_items_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_items_return_request_id_return_requests_id_fk": { + "name": "return_items_return_request_id_return_requests_id_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_id_orders_id_fk": { + "name": "return_items_order_id_orders_id_fk", + "tableFrom": "return_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_item_id_order_items_id_fk": { + "name": "return_items_order_item_id_order_items_id_fk", + "tableFrom": "return_items", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_product_id_products_id_fk": { + "name": "return_items_product_id_products_id_fk", + "tableFrom": "return_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_return_request_order_fk": { + "name": "return_items_return_request_order_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id", + "order_id" + ], + "columnsTo": [ + "id", + "order_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_items_quantity_positive_chk": { + "name": "return_items_quantity_positive_chk", + "value": "\"return_items\".\"quantity\" > 0" + }, + "return_items_unit_price_minor_non_negative_chk": { + "name": "return_items_unit_price_minor_non_negative_chk", + "value": "\"return_items\".\"unit_price_minor\" >= 0" + }, + "return_items_line_total_minor_non_negative_chk": { + "name": "return_items_line_total_minor_non_negative_chk", + "value": "\"return_items\".\"line_total_minor\" >= 0" + }, + "return_items_line_total_consistent_chk": { + "name": "return_items_line_total_consistent_chk", + "value": "\"return_items\".\"line_total_minor\" = (\"return_items\".\"unit_price_minor\" * \"return_items\".\"quantity\")" + } + }, + "isRLSEnabled": false + }, + "public.return_requests": { + "name": "return_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "return_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_restock": { + "name": "policy_restock", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "refund_amount_minor": { + "name": "refund_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refunded_by": { + "name": "refunded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refund_provider_ref": { + "name": "refund_provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "return_requests_order_id_uq": { + "name": "return_requests_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_id_order_id_uq": { + "name": "return_requests_id_order_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_idempotency_key_uq": { + "name": "return_requests_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_status_created_idx": { + "name": "return_requests_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_user_id_created_idx": { + "name": "return_requests_user_id_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_requests_order_id_orders_id_fk": { + "name": "return_requests_order_id_orders_id_fk", + "tableFrom": "return_requests", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_requests_user_id_users_id_fk": { + "name": "return_requests_user_id_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_approved_by_users_id_fk": { + "name": "return_requests_approved_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_rejected_by_users_id_fk": { + "name": "return_requests_rejected_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "rejected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_received_by_users_id_fk": { + "name": "return_requests_received_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "received_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_refunded_by_users_id_fk": { + "name": "return_requests_refunded_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "refunded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_requests_refund_amount_minor_non_negative_chk": { + "name": "return_requests_refund_amount_minor_non_negative_chk", + "value": "\"return_requests\".\"refund_amount_minor\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.shipping_events": { + "name": "shipping_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shipment_id": { + "name": "shipment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_from": { + "name": "status_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_to": { + "name": "status_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_events_dedupe_key_uq": { + "name": "shipping_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_order_id_idx": { + "name": "shipping_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_shipment_id_idx": { + "name": "shipping_events_shipment_id_idx", + "columns": [ + { + "expression": "shipment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_occurred_at_idx": { + "name": "shipping_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_events_order_id_orders_id_fk": { + "name": "shipping_events_order_id_orders_id_fk", + "tableFrom": "shipping_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_events_shipment_id_shipping_shipments_id_fk": { + "name": "shipping_events_shipment_id_shipping_shipments_id_fk", + "tableFrom": "shipping_events", + "tableTo": "shipping_shipments", + "columnsFrom": [ + "shipment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shipping_quotes": { + "name": "shipping_quotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "offered_by": { + "name": "offered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "offered_at": { + "name": "offered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_quotes_order_version_uq": { + "name": "shipping_quotes_order_version_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_status_idx": { + "name": "shipping_quotes_order_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_status_expires_idx": { + "name": "shipping_quotes_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_updated_idx": { + "name": "shipping_quotes_order_updated_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_quotes_order_id_orders_id_fk": { + "name": "shipping_quotes_order_id_orders_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_quotes_offered_by_users_id_fk": { + "name": "shipping_quotes_offered_by_users_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "users", + "columnsFrom": [ + "offered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_quotes_version_positive_chk": { + "name": "shipping_quotes_version_positive_chk", + "value": "\"shipping_quotes\".\"version\" >= 1" + }, + "shipping_quotes_quote_minor_non_negative_chk": { + "name": "shipping_quotes_quote_minor_non_negative_chk", + "value": "\"shipping_quotes\".\"shipping_quote_minor\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.shipping_shipments": { + "name": "shipping_shipments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "shipping_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nova_poshta'" + }, + "status": { + "name": "status", + "type": "shipping_shipment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_shipments_order_id_uq": { + "name": "shipping_shipments_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_queue_idx": { + "name": "shipping_shipments_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_shipments_attempt_count_non_negative_chk": { + "name": "shipping_shipments_attempt_count_non_negative_chk", + "value": "\"shipping_shipments\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_events": { + "name": "stripe_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_events_event_id_idx": { + "name": "stripe_events_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_provider_provider_id_unique": { + "name": "users_provider_provider_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.fulfillment_mode": { + "name": "fulfillment_mode", + "schema": "public", + "values": [ + "ua_np", + "intl" + ] + }, + "public.inventory_move_type": { + "name": "inventory_move_type", + "schema": "public", + "values": [ + "reserve", + "release" + ] + }, + "public.inventory_status": { + "name": "inventory_status", + "schema": "public", + "values": [ + "none", + "reserving", + "reserved", + "release_pending", + "released", + "failed" + ] + }, + "public.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "email", + "sms" + ] + }, + "public.order_status": { + "name": "order_status", + "schema": "public", + "values": [ + "CREATED", + "INVENTORY_RESERVED", + "INVENTORY_FAILED", + "PAID", + "CANCELED" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "requires_payment", + "paid", + "failed", + "refunded", + "needs_review" + ] + }, + "public.product_badge": { + "name": "product_badge", + "schema": "public", + "values": [ + "NEW", + "SALE", + "NONE" + ] + }, + "public.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "none", + "requested", + "offered", + "accepted", + "declined", + "expired", + "requires_requote" + ] + }, + "public.return_request_status": { + "name": "return_request_status", + "schema": "public", + "values": [ + "requested", + "approved", + "rejected", + "received", + "refunded" + ] + }, + "public.shipping_method_code": { + "name": "shipping_method_code", + "schema": "public", + "values": [ + "NP_WAREHOUSE", + "NP_LOCKER", + "NP_COURIER" + ] + }, + "public.shipping_payer": { + "name": "shipping_payer", + "schema": "public", + "values": [ + "customer", + "merchant" + ] + }, + "public.shipping_provider": { + "name": "shipping_provider", + "schema": "public", + "values": [ + "nova_poshta", + "ukrposhta" + ] + }, + "public.shipping_shipment_status": { + "name": "shipping_shipment_status", + "schema": "public", + "values": [ + "queued", + "processing", + "succeeded", + "failed", + "needs_attention" + ] + }, + "public.shipping_status": { + "name": "shipping_status", + "schema": "public", + "values": [ + "pending", + "queued", + "creating_label", + "label_created", + "shipped", + "delivered", + "cancelled", + "needs_attention" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json index 52af9b9e..7a2e719e 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1772560628660, "tag": "0027_material_bloodscream", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1772572818358, + "tag": "0028_bitter_wrecking_crew", + "breakpoints": true } ] } \ No newline at end of file From f909b9a5c9d3d0050c5dbcac5ac488681b12118f Mon Sep 17 00:00:00 2001 From: Lesia Soloviova <106915140+LesiaUKR@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:23:20 +0000 Subject: [PATCH 07/25] (SP: 3) [Frontend] Drizzle query layer + swap blog routes from Sanity to PostgreSQL (#391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(blog): resolve 500 error on blog post pages * fix(blog): remove unsafe GROQ coalesce fallbacks and revert CDN * feat(blog): add DB schema indexes + Sanity => PostgreSQL migration script - Add FK indexes on blog_posts.author_id and blog_post_categories.category_id - Add one-time migration script: fetches Sanity data via REST API, re-uploads images to Cloudinary, converts Portable Text → Tiptap JSON, inserts into 7 blog tables (4 categories, 3 authors, 21 posts) - Drizzle migration 0028 for index changes Closes #384, #385 * feat(blog): replace Sanity CMS with Drizzle query layer for all blog routes Swap every blog page, API route, and header component from Sanity GROQ queries to Drizzle ORM against PostgreSQL. Adds Tiptap JSON renderer, shared text extraction, and typed query layer for posts/authors/categories. Fixes runtime crash where /api/blog-author returned Portable Text bio objects that React tried to render as children. * fix: resolve eslint formatting issues * fix(blog): enable proper Next image optimization and harden blog category handling --- frontend/actions/quiz.ts | 3 +- .../app/[locale]/blog/[slug]/PostDetails.tsx | 446 ++----------- frontend/app/[locale]/blog/[slug]/page.tsx | 15 +- .../blog/category/[category]/page.tsx | 112 +--- frontend/app/[locale]/blog/page.tsx | 54 +- frontend/app/[locale]/layout.tsx | 18 +- frontend/app/api/blog-author/route.ts | 23 +- frontend/app/api/blog-search/route.ts | 29 +- frontend/components/blog/BlogCard.tsx | 28 +- .../components/blog/BlogCategoryLinks.tsx | 14 +- frontend/components/blog/BlogFilters.tsx | 82 +-- frontend/components/blog/BlogGrid.tsx | 2 +- frontend/components/blog/BlogHeaderSearch.tsx | 31 +- frontend/components/blog/BlogNavLinks.tsx | 94 --- frontend/components/blog/BlogPostRenderer.tsx | 179 ++++++ frontend/components/header/AppChrome.tsx | 2 +- frontend/components/header/AppMobileMenu.tsx | 14 +- frontend/components/header/DesktopNav.tsx | 3 +- frontend/components/header/MainSwitcher.tsx | 2 +- frontend/components/header/MobileActions.tsx | 3 +- frontend/components/header/UnifiedHeader.tsx | 2 +- frontend/components/quiz/QuizzesSection.tsx | 3 +- frontend/db/queries/blog/blog-authors.ts | 59 ++ frontend/db/queries/blog/blog-categories.ts | 43 ++ frontend/db/queries/blog/blog-posts.ts | 270 ++++++++ frontend/db/schema/blog.ts | 54 +- frontend/db/seed-blog-migration.ts | 14 +- frontend/drizzle/meta/0027_snapshot.json | 608 +++++------------- frontend/drizzle/meta/0028_snapshot.json | 608 +++++------------- frontend/drizzle/meta/_journal.json | 2 +- frontend/lib/blog/image.ts | 23 +- frontend/lib/blog/text.ts | 9 + frontend/lib/services/orders/checkout.ts | 5 +- 33 files changed, 1042 insertions(+), 1812 deletions(-) delete mode 100644 frontend/components/blog/BlogNavLinks.tsx create mode 100644 frontend/components/blog/BlogPostRenderer.tsx create mode 100644 frontend/db/queries/blog/blog-authors.ts create mode 100644 frontend/db/queries/blog/blog-categories.ts create mode 100644 frontend/db/queries/blog/blog-posts.ts create mode 100644 frontend/lib/blog/text.ts diff --git a/frontend/actions/quiz.ts b/frontend/actions/quiz.ts index 761fe18a..4b68b5b3 100644 --- a/frontend/actions/quiz.ts +++ b/frontend/actions/quiz.ts @@ -10,9 +10,10 @@ import { quizAttempts, quizQuestions, } from '@/db/schema/quiz'; -import { getCurrentUser } from '@/lib/auth'; import { ACHIEVEMENTS, computeAchievements } from '@/lib/achievements'; +import { getCurrentUser } from '@/lib/auth'; import { getUserStatsForAchievements } from '@/lib/user-stats'; + import { createNotification } from './notifications'; export interface UserAnswer { diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index fdd39a92..ebf76440 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -1,303 +1,14 @@ -import groq from 'groq'; import Image from 'next/image'; import { notFound } from 'next/navigation'; import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { client } from '@/client'; +import BlogPostRenderer from '@/components/blog/BlogPostRenderer'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; +import { getBlogPostBySlug, getBlogPosts } from '@/db/queries/blog/blog-posts'; import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; import { shouldBypassImageOptimization } from '@/lib/blog/image'; - -type SocialLink = { - _key?: string; - platform?: string; - url?: string; -}; - -type Author = { - name?: string; - company?: string; - jobTitle?: string; - city?: string; - image?: string; - bio?: any; - socialMedia?: SocialLink[]; -}; - -type Post = { - _id?: string; - title?: string; - publishedAt?: string; - mainImage?: string; - categories?: string[]; - tags?: string[]; - resourceLink?: string; - author?: Author; - body?: any[]; - slug?: { current?: string }; -}; - -function plainTextFromPortableText(value: any): string { - if (!Array.isArray(value)) return ''; - return value - .filter(b => b?._type === 'block') - .map(b => (b.children || []).map((c: any) => c.text || '').join('')) - .join('\n') - .trim(); -} - -function linkifyText(text: string) { - const urlRegex = /(https?:\/\/[^\s]+)/g; - const parts = text.split(urlRegex); - return parts.map((part, index) => { - if (!part) return null; - if (urlRegex.test(part)) { - return ( - - {part} - - ); - } - return {part}; - }); -} - -function renderPortableTextSpans( - children: Array<{ _type?: string; text?: string; marks?: string[] }> = [], - markDefs: Array<{ _key?: string; _type?: string; href?: string }> = [] -) { - const linkMap = new Map( - markDefs - .filter(def => def?._type === 'link' && def?._key && def?.href) - .map(def => [def._key as string, def.href as string]) - ); - - return children.map((child, index) => { - const text = child?.text || ''; - if (!text) return null; - const marks = child?.marks || []; - - let node: React.ReactNode = marks.length === 0 ? linkifyText(text) : text; - - for (const mark of marks) { - if (linkMap.has(mark)) { - const href = linkMap.get(mark)!; - node = ( - - {node} - - ); - continue; - } - if (mark === 'strong') { - node = {node}; - continue; - } - if (mark === 'em') { - node = {node}; - continue; - } - if (mark === 'underline') { - node = {node}; - continue; - } - if (mark === 'code') { - node = ( - - {node} - - ); - continue; - } - if (mark === 'strike-through' || mark === 'strike') { - node = {node}; - } - } - - return {node}; - }); -} - -function renderPortableTextBlock(block: any, index: number): React.ReactNode { - const children = renderPortableTextSpans(block.children, block.markDefs); - const style = block?.style || 'normal'; - - if (style === 'h1') { - return ( -

- {children} -

- ); - } - if (style === 'h2') { - return ( -

- {children} -

- ); - } - if (style === 'h3') { - return ( -

- {children} -

- ); - } - if (style === 'h4') { - return ( -

- {children} -

- ); - } - if (style === 'h5') { - return ( -
- {children} -
- ); - } - if (style === 'h6') { - return ( -
- {children} -
- ); - } - if (style === 'blockquote') { - return ( -
- {children} -
- ); - } - - return ( -

- {children} -

- ); -} - -function renderPortableText( - body: any[], - postTitle?: string -): React.ReactNode[] { - const nodes: React.ReactNode[] = []; - let i = 0; - - while (i < body.length) { - const block = body[i]; - - if (block?._type === 'block' && block.listItem) { - const listType = block.listItem === 'number' ? 'ol' : 'ul'; - const level = block.level ?? 1; - const items: React.ReactNode[] = []; - let j = i; - - while ( - j < body.length && - body[j]?._type === 'block' && - body[j].listItem === block.listItem && - (body[j].level ?? 1) === level - ) { - const item = body[j]; - items.push( -
  • - {renderPortableTextSpans(item.children, item.markDefs)} -
  • - ); - j += 1; - } - - const listClass = - listType === 'ol' ? 'my-4 list-decimal pl-6' : 'my-4 list-disc pl-6'; - const levelClass = level > 1 ? 'ml-6' : ''; - nodes.push( - listType === 'ol' ? ( -
      - {items} -
    - ) : ( -
      - {items} -
    - ) - ); - i = j; - continue; - } - - if (block?._type === 'block') { - nodes.push(renderPortableTextBlock(block, i)); - i += 1; - continue; - } - - if (block?._type === 'image' && block?.url) { - nodes.push( - {postTitle - ); - i += 1; - continue; - } - - i += 1; - } - - return nodes; -} +import { extractPlainText } from '@/lib/blog/text'; function seededShuffle(items: T[], seed: number) { const result = [...items]; @@ -318,56 +29,15 @@ function hashString(input: string) { return hash; } -const query = groq` - *[_type=="post" && slug.current==$slug][0]{ - _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), - publishedAt, - "mainImage": mainImage.asset->url, - "categories": categories[]->title, - tags, - resourceLink, - - "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl), - "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl), - "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl), - "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl), - "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl), - "image": image.asset->url, - socialMedia[]{ _key, platform, url } - }, - - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ - ..., - _type == "image" => { - ..., - "url": asset->url - } - } - } -`; -const recommendedQuery = groq` - *[_type=="post" && defined(slug.current) && slug.current != $slug]{ - _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), - publishedAt, - "mainImage": mainImage.asset->url, - slug, - "categories": categories[]->title, - "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl), - "image": image.asset->url - }, - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ - ..., - _type == "image" => { - ..., - "url": asset->url - } - } - } -`; +function getCategoryLabel(categoryName: string, t: (key: string) => string) { + const key = categoryName.toLowerCase(); + if (key === 'growth') return t('categories.career'); + if (key === 'tech') return t('categories.tech'); + if (key === 'career') return t('categories.career'); + if (key === 'insights') return t('categories.insights'); + if (key === 'news') return t('categories.news'); + return categoryName; +} export default async function PostDetails({ slug, @@ -382,49 +52,29 @@ export default async function PostDetails({ const slugParam = String(slug || '').trim(); if (!slugParam) return notFound(); - const post: Post | null = await client.fetch(query, { - slug: slugParam, - locale, - }); - const recommendedAll: Post[] = await client.fetch(recommendedQuery, { - slug: slugParam, - locale, - }); + const [post, allPosts] = await Promise.all([ + getBlogPostBySlug(slugParam, locale), + getBlogPosts(locale), + ]); + + if (!post) return notFound(); + const recommendedPosts = seededShuffle( - recommendedAll, + allPosts.filter(p => p.slug !== slugParam), hashString(slugParam) ).slice(0, 3); - if (!post?.title) return notFound(); - - const authorBio = plainTextFromPortableText(post.author?.bio); const authorName = post.author?.name; - const authorMetaParts = [ - post.author?.jobTitle, - post.author?.company, - post.author?.city, - ].filter(Boolean) as string[]; - const authorMeta = authorMetaParts.join(' · '); - const categoryLabel = post.categories?.[0]; - const categoryDisplay = categoryLabel - ? getCategoryLabel(categoryLabel, t) - : null; + const category = post.categories?.[0]; + const categoryDisplay = category ? getCategoryLabel(category.title, t) : null; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL; const postUrl = baseUrl ? `${baseUrl}/${locale}/blog/${slugParam}` : null; const blogUrl = baseUrl ? `${baseUrl}/${locale}/blog` : null; - const description = plainTextFromPortableText(post.body).slice(0, 160); - const categoryHref = categoryLabel - ? `/blog/category/${categoryLabel - .toLowerCase() - .replace(/[^a-z0-9\\s-]/g, '') - .replace(/\\s+/g, '-')}` - : null; + const description = extractPlainText(post.body).slice(0, 160); + const categoryHref = category ? `/blog/category/${category.slug}` : null; const categoryUrl = - baseUrl && categoryLabel - ? `${baseUrl}/${locale}/blog/category/${categoryLabel - .toLowerCase() - .replace(/[^a-z0-9\\s-]/g, '') - .replace(/\\s+/g, '-')}` + baseUrl && category + ? `${baseUrl}/${locale}/blog/category/${category.slug}` : null; const breadcrumbsItems = [ { @@ -432,10 +82,10 @@ export default async function PostDetails({ href: '/blog', url: blogUrl, }, - ...(categoryLabel + ...(category ? [ { - name: categoryDisplay || categoryLabel, + name: categoryDisplay || category.title, href: categoryHref, url: categoryUrl, }, @@ -532,13 +182,13 @@ export default async function PostDetails({
    - {categoryLabel && ( + {category && (
    - {categoryDisplay || categoryLabel} + {categoryDisplay || category.title}
    )} @@ -566,8 +216,6 @@ export default async function PostDetails({ )}
    - {(post.tags?.length || 0) > 0 && null} - {post.mainImage && (
    - {renderPortableText(post.body || [], post.title)} +
    @@ -601,13 +249,13 @@ export default async function PostDetails({ {recommendedPosts.map(item => { const itemCategory = item.categories?.[0]; const itemCategoryDisplay = itemCategory - ? getCategoryLabel(itemCategory, t) + ? getCategoryLabel(itemCategory.title, t) : null; return ( {item.mainImage && ( @@ -616,9 +264,7 @@ export default async function PostDetails({ src={item.mainImage} alt={item.title || 'Post image'} fill - unoptimized={shouldBypassImageOptimization( - item.mainImage - )} + unoptimized={shouldBypassImageOptimization(item.mainImage)} className="object-cover transition-transform duration-300 group-hover:scale-[1.03]" /> @@ -626,9 +272,9 @@ export default async function PostDetails({

    {item.title}

    - {item.body && ( + {item.body != null && (

    - {plainTextFromPortableText(item.body)} + {extractPlainText(item.body)}

    )} {(item.author?.name || @@ -641,9 +287,7 @@ export default async function PostDetails({ src={item.author.image} alt={item.author.name || 'Author'} fill - unoptimized={shouldBypassImageOptimization( - item.author.image - )} + unoptimized={shouldBypassImageOptimization(item.author.image)} className="object-cover" /> @@ -676,21 +320,7 @@ export default async function PostDetails({ )} - - {post.resourceLink && null} - - {(authorBio || authorName || authorMeta) && null} ); } - -function getCategoryLabel(categoryName: string, t: (key: string) => string) { - const key = categoryName.toLowerCase(); - if (key === 'growth') return t('categories.career'); - if (key === 'tech') return t('categories.tech'); - if (key === 'career') return t('categories.career'); - if (key === 'insights') return t('categories.insights'); - if (key === 'news') return t('categories.news'); - return categoryName; -} diff --git a/frontend/app/[locale]/blog/[slug]/page.tsx b/frontend/app/[locale]/blog/[slug]/page.tsx index dac1e0d5..265c6870 100644 --- a/frontend/app/[locale]/blog/[slug]/page.tsx +++ b/frontend/app/[locale]/blog/[slug]/page.tsx @@ -1,7 +1,6 @@ -import groq from 'groq'; import { setRequestLocale } from 'next-intl/server'; -import { client } from '@/client'; +import { getBlogPostBySlug } from '@/db/queries/blog/blog-posts'; import PostDetails from './PostDetails'; @@ -13,16 +12,8 @@ export async function generateMetadata({ params: Promise<{ slug: string; locale: string }>; }) { const { slug, locale } = await params; - - const post = await client.fetch( - groq`*[_type == "post" && slug.current == $slug][0]{ - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl) - }`, - { slug, locale } - ); - - const title = typeof post?.title === 'string' ? post.title : 'Post'; - return { title }; + const post = await getBlogPostBySlug(slug, locale); + return { title: post?.title ?? 'Post' }; } export default async function Page({ diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx index d5c1874c..a0c257e5 100644 --- a/frontend/app/[locale]/blog/category/[category]/page.tsx +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -1,45 +1,26 @@ -import groq from 'groq'; import Image from 'next/image'; import { notFound } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; -import { client } from '@/client'; import { BlogCategoryGrid } from '@/components/blog/BlogCategoryGrid'; import { FeaturedPostCtaButton } from '@/components/blog/FeaturedPostCtaButton'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; +import { getBlogCategories } from '@/db/queries/blog/blog-categories'; +import { getBlogPostsByCategory } from '@/db/queries/blog/blog-posts'; import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; -import { shouldBypassImageOptimization } from '@/lib/blog/image'; export const revalidate = 3600; -type Author = { - name?: string; - image?: string; -}; - -type Post = { - _id: string; - title: string; - slug: { current: string }; - publishedAt?: string; - categories?: string[]; - mainImage?: string; - body?: any[]; - author?: Author; -}; - -type Category = { - _id: string; - title: string; -}; - -const categoriesQuery = groq` - *[_type == "category"] | order(orderRank asc) { - _id, - title - } -`; +function getCategoryLabel(categoryName: string, t: (key: string) => string) { + const key = categoryName.toLowerCase(); + if (key === 'growth') return t('categories.career'); + if (key === 'tech') return t('categories.tech'); + if (key === 'career') return t('categories.career'); + if (key === 'insights') return t('categories.insights'); + if (key === 'news') return t('categories.news'); + return categoryName; +} export default async function BlogCategoryPage({ params, @@ -49,39 +30,16 @@ export default async function BlogCategoryPage({ const { locale, category } = await params; const t = await getTranslations({ locale, namespace: 'blog' }); const tNav = await getTranslations({ locale, namespace: 'navigation' }); - const categoryKey = String(category || '').toLowerCase(); - const categories: Category[] = await client.fetch(categoriesQuery); - const matchedCategory = categories.find( - item => slugify(item.title) === categoryKey - ); - if (!matchedCategory) return notFound(); - const categoryTitle = matchedCategory.title; - const categoryDisplay = getCategoryLabel(categoryTitle, t); + const [categories, posts] = await Promise.all([ + getBlogCategories(locale), + getBlogPostsByCategory(category, locale), + ]); - const posts: Post[] = await client.fetch( - groq` - *[_type == "post" && defined(slug.current) && $category in categories[]->title] - | order(coalesce(publishedAt, _createdAt) desc) { - _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), - slug, - publishedAt, - "categories": categories[]->title, - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ - ..., - children[]{ text } - }, - "mainImage": mainImage.asset->url, - "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl), - "image": image.asset->url - } - } - `, - { locale, category: categoryTitle } - ); + const matchedCategory = categories.find(c => c.slug === category); + if (!matchedCategory) return notFound(); + const categoryDisplay = getCategoryLabel(matchedCategory.title, t); const featuredPost = posts[0]; const restPosts = posts.slice(1); const featuredDate = formatBlogDate(featuredPost?.publishedAt); @@ -120,18 +78,16 @@ export default async function BlogCategoryPage({ alt={featuredPost.title} width={1400} height={800} - unoptimized={shouldBypassImageOptimization( - featuredPost.mainImage - )} + unoptimized className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]" priority={false} />
    - {featuredPost.categories?.[0] && ( + {featuredPost.categories?.[0]?.title && (
    - {featuredPost.categories[0]} + {featuredPost.categories[0].title}
    )}

    @@ -144,9 +100,7 @@ export default async function BlogCategoryPage({ alt={featuredPost.author.name || 'Author'} width={28} height={28} - unoptimized={shouldBypassImageOptimization( - featuredPost.author.image - )} + unoptimized className="h-7 w-7 rounded-full object-cover" /> )} @@ -162,7 +116,7 @@ export default async function BlogCategoryPage({

    @@ -170,7 +124,7 @@ export default async function BlogCategoryPage({ )}
    - +
    {!posts.length && (

    {t('noPosts')}

    @@ -179,21 +133,3 @@ export default async function BlogCategoryPage({ ); } - -function slugify(value: string) { - return value - .toLowerCase() - .trim() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-'); -} - -function getCategoryLabel(categoryName: string, t: (key: string) => string) { - const key = categoryName.toLowerCase(); - if (key === 'growth') return t('categories.career'); - if (key === 'tech') return t('categories.tech'); - if (key === 'career') return t('categories.career'); - if (key === 'insights') return t('categories.insights'); - if (key === 'news') return t('categories.news'); - return categoryName; -} diff --git a/frontend/app/[locale]/blog/page.tsx b/frontend/app/[locale]/blog/page.tsx index b1c61756..6562b358 100644 --- a/frontend/app/[locale]/blog/page.tsx +++ b/frontend/app/[locale]/blog/page.tsx @@ -1,10 +1,10 @@ -import groq from 'groq'; import { getTranslations } from 'next-intl/server'; -import { client } from '@/client'; import BlogFilters from '@/components/blog/BlogFilters'; import { BlogPageHeader } from '@/components/blog/BlogPageHeader'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; +import { getBlogCategories } from '@/db/queries/blog/blog-categories'; +import { getBlogPosts } from '@/db/queries/blog/blog-posts'; export const revalidate = 3600; @@ -30,52 +30,12 @@ export default async function BlogPage({ const { locale } = await params; const t = await getTranslations({ locale, namespace: 'blog' }); - const posts = await client.fetch( - groq` - *[_type == "post" && defined(slug.current)] - | order(coalesce(publishedAt, _createdAt) desc) { - _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), - slug, - publishedAt, - tags, - resourceLink, + const [posts, categories] = await Promise.all([ + getBlogPosts(locale), + getBlogCategories(locale), + ]); - "categories": categories[]->title, - - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ - ..., - children[]{ - text - } - }, - "mainImage": mainImage.asset->url, - "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl), - "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl), - "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl), - "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl), - "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl), - "image": image.asset->url, - socialMedia[]{ - _key, - platform, - url - } - } - } - `, - { locale } - ); - const categories = await client.fetch( - groq` - *[_type == "category"] | order(orderRank asc) { - _id, - title - } - ` - ); - const featuredPost = posts?.[0]; + const featuredPost = posts[0]; return ( diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index ef202148..3cd94956 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -1,33 +1,19 @@ -import groq from 'groq'; -import { unstable_cache } from 'next/cache'; import { notFound } from 'next/navigation'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; import type React from 'react'; import { Toaster } from 'sonner'; -import { client } from '@/client'; import { AppChrome } from '@/components/header/AppChrome'; import { MainSwitcher } from '@/components/header/MainSwitcher'; import { CookieBanner } from '@/components/shared/CookieBanner'; import Footer from '@/components/shared/Footer'; import { ScrollWatcher } from '@/components/shared/ScrollWatcher'; import { ThemeProvider } from '@/components/theme/ThemeProvider'; +import { getCachedBlogCategories } from '@/db/queries/blog/blog-categories'; import { AuthProvider } from '@/hooks/useAuth'; import { locales } from '@/i18n/config'; -const getCachedBlogCategories = unstable_cache( - async () => - client.fetch>(groq` - *[_type == "category"] | order(orderRank asc) { - _id, - title - } - `), - ['blog-categories'], - { revalidate: 3600, tags: ['blog-categories'] } -); - export default async function LocaleLayout({ children, params, @@ -41,7 +27,7 @@ export default async function LocaleLayout({ const [messages, blogCategories] = await Promise.all([ getMessages({ locale }), - getCachedBlogCategories(), + getCachedBlogCategories(locale), ]); const enableAdmin = diff --git a/frontend/app/api/blog-author/route.ts b/frontend/app/api/blog-author/route.ts index a9522040..3452bafd 100644 --- a/frontend/app/api/blog-author/route.ts +++ b/frontend/app/api/blog-author/route.ts @@ -1,28 +1,9 @@ -import groq from 'groq'; import { NextResponse } from 'next/server'; -import { client } from '@/client'; +import { getBlogAuthorByName } from '@/db/queries/blog/blog-authors'; export const revalidate = 0; -const authorQuery = groq` - *[_type == "author" && ( - name[$locale] == $name || - name[lower($locale)] == $name || - name.en == $name || - name.pl == $name || - name.uk == $name - )][0]{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), - "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl, company), - "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl, jobTitle), - "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl, city), - "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl, bio), - "image": image.asset->url, - socialMedia[]{ _key, platform, url } - } -`; - export async function GET(request: Request) { const { searchParams } = new URL(request.url); const name = (searchParams.get('name') || '').trim(); @@ -32,7 +13,7 @@ export async function GET(request: Request) { return NextResponse.json(null, { status: 400 }); } - const author = await client.fetch(authorQuery, { name, locale }); + const author = await getBlogAuthorByName(name, locale); return NextResponse.json(author || null, { headers: { 'Cache-Control': 'no-store' }, diff --git a/frontend/app/api/blog-search/route.ts b/frontend/app/api/blog-search/route.ts index 6e1c53d5..372a52c3 100644 --- a/frontend/app/api/blog-search/route.ts +++ b/frontend/app/api/blog-search/route.ts @@ -1,25 +1,18 @@ -import groq from 'groq'; import { NextResponse } from 'next/server'; -import { client } from '@/client'; - -const searchQuery = groq` - *[_type == "post" && defined(slug.current)] | order(publishedAt desc) { - _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl), - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl)[]{ - ..., - children[]{ text } - }, - slug - } -`; +import { getBlogPosts } from '@/db/queries/blog/blog-posts'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const locale = searchParams.get('locale') || 'en'; - const items = await client - .withConfig({ useCdn: false }) - .fetch(searchQuery, { locale }); - return NextResponse.json(items || []); + const posts = await getBlogPosts(locale); + + const items = posts.map(p => ({ + id: p.id, + title: p.title, + body: p.body, + slug: p.slug, + })); + + return NextResponse.json(items); } diff --git a/frontend/components/blog/BlogCard.tsx b/frontend/components/blog/BlogCard.tsx index fb07afb7..9d79b347 100644 --- a/frontend/components/blog/BlogCard.tsx +++ b/frontend/components/blog/BlogCard.tsx @@ -1,19 +1,15 @@ 'use client'; import Image from 'next/image'; -import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { useMemo } from 'react'; +import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; import { shouldBypassImageOptimization } from '@/lib/blog/image'; +import { extractPlainText } from '@/lib/blog/text'; -import type { - Author, - PortableTextBlock, - PortableTextSpan, - Post, -} from './BlogFilters'; +import type { Author, Post } from './BlogFilters'; export default function BlogCard({ post, @@ -43,27 +39,23 @@ export default function BlogCard({ return categoryTranslations[key] || categoryName; }; - const excerpt = - (post.body ?? []) - .filter((b): b is PortableTextBlock => b._type === 'block') - .map(b => - (b.children ?? []).map((c: PortableTextSpan) => c.text ?? '').join(' ') - ) - .join('\n') - .slice(0, 160) || ''; + const excerpt = extractPlainText(post.body).slice(0, 160); + const formattedDate = useMemo( () => formatBlogDate(post.publishedAt), [post.publishedAt] ); const rawCategory = - post.categories?.[0] === 'Growth' ? 'Career' : post.categories?.[0]; + post.categories?.[0].title === 'Growth' + ? 'Career' + : post.categories?.[0].title; const categoryLabel = rawCategory ? getCategoryLabel(rawCategory) : undefined; return (
    {post.mainImage && ( ({ ...category, - slug: slugify(category.title || ''), displayTitle: category.title === 'Growth' ? 'Career' : category.title, })) .filter(category => category.slug); @@ -80,7 +80,7 @@ export function BlogCategoryLinks({ const isActive = pathname === href; return ( ); } - -function slugify(value: string) { - return value - .toLowerCase() - .trim() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-'); -} diff --git a/frontend/components/blog/BlogFilters.tsx b/frontend/components/blog/BlogFilters.tsx index 49bbd879..bd8787e7 100644 --- a/frontend/components/blog/BlogFilters.tsx +++ b/frontend/components/blog/BlogFilters.tsx @@ -1,5 +1,4 @@ 'use client'; - import { Dribbble, Facebook, @@ -23,28 +22,9 @@ import { usePathname, useRouter } from '@/i18n/routing'; import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; import { shouldBypassImageOptimization } from '@/lib/blog/image'; - -export type PortableTextSpan = { - _type: 'span'; - text?: string; -}; - -export type PortableTextBlock = { - _type: 'block'; - _key?: string; - children?: PortableTextSpan[]; -}; - -export type PortableTextImage = { - _type: 'image'; - _key?: string; - url?: string; -}; - -export type PortableText = Array; +import { extractPlainText } from '@/lib/blog/text'; export type SocialLink = { - _key?: string; platform?: string; url?: string; }; @@ -55,24 +35,25 @@ export type Author = { company?: string; jobTitle?: string; city?: string; - bio?: PortableText; + bio?: string | null; socialMedia?: SocialLink[]; }; export type Post = { - _id: string; + id: string; title: string; - slug: { current: string }; + slug: string; publishedAt?: string; tags?: string[]; - categories?: string[]; + categories?: { slug: string; title: string }[]; resourceLink?: string; mainImage?: string; - body?: PortableText; + body?: any; author?: Author; }; + type Category = { - _id: string; + id: string; title: string; }; @@ -141,23 +122,12 @@ function normalizeAuthor(input: string) { return (input || '').trim().toLowerCase().replace(/\s+/g, ' '); } -function plainTextFromPortableText(value?: PortableText): string { - if (!Array.isArray(value)) return ''; - return value - .filter(block => block?._type === 'block') - .map(block => - (block.children || []).map(child => child.text || '').join(' ') - ) - .join('\n') - .trim(); -} - function normalizeSearchText(value: string) { return value.toLowerCase().replace(/\s+/g, ' ').trim(); } -function plainTextExcerpt(value?: PortableText): string { - return plainTextFromPortableText(value); +function plainTextExcerpt(value?: any): string { + return extractPlainText(value).trim(); } /** @@ -258,7 +228,7 @@ export default function BlogFilters({ const map = new Map(); for (const p of posts) { for (const c of p.categories || []) { - const raw = (c || '').trim(); + const raw = (c?.title || '').trim(); const norm = normalizeTag(raw); if (norm && !map.has(norm)) map.set(norm, raw); } @@ -414,15 +384,15 @@ export default function BlogFilters({ } if (resolvedCategory) { - const postCategories = (post.categories || []).map(normalizeTag); + const postCategories = (post.categories || []) + .map(c => normalizeTag(c?.title || '')) + .filter(Boolean); if (!postCategories.includes(resolvedCategory.norm)) return false; } if (searchQueryNormalized) { const titleText = normalizeSearchText(post.title); - const bodyText = normalizeSearchText( - plainTextFromPortableText(post.body) - ); + const bodyText = normalizeSearchText(extractPlainText(post.body)); const words = searchQueryNormalized.split(/\s+/).filter(Boolean); if ( !words.some( @@ -452,9 +422,8 @@ export default function BlogFilters({ if (authorProfile?.name === resolvedName) return authorProfile.data; return resolvedAuthor?.data || null; }, [authorProfile, resolvedAuthor?.data, resolvedAuthor?.name]); - const authorBioText = useMemo(() => { - return plainTextFromPortableText(selectedAuthorData?.bio); - }, [selectedAuthorData]); + + const authorBioText = selectedAuthorData?.bio ?? ''; useEffect(() => { if (parsedPage <= totalPages) return; @@ -484,10 +453,7 @@ export default function BlogFilters({
    {featuredPost.mainImage && ( - +
    )}
    - {featuredPost.categories?.[0] && ( + {featuredPost.categories?.[0]?.title && (
    {getCategoryLabel( - featuredPost.categories[0] === 'Growth' + featuredPost.categories[0].title === 'Growth' ? 'Career' - : featuredPost.categories[0] + : featuredPost.categories[0].title )}
    )}
    {featuredPost.title} @@ -533,7 +499,7 @@ export default function BlogFilters({ {formatBlogDate(featuredPost.publishedAt)} {t('readMore')} @@ -609,7 +575,7 @@ export default function BlogFilters({ .filter(item => item?.url) .map((item, index) => ( {posts.map(post => ( }>; - slug?: { current?: string }; + body?: unknown; + slug?: string; }; type SearchResult = PostSearchItem & { snippet?: string }; function extractSnippet(body: PostSearchItem['body'], query: string) { - const text = (body || []) - .filter(block => block?._type === 'block') - .map(block => - (block.children || []).map(child => child.text || '').join(' ') - ) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); + const text = extractPlainText(body).replace(/\s+/g, ' ').trim(); if (!text) return ''; const lower = text.toLowerCase(); const idx = lower.indexOf(query.toLowerCase()); @@ -118,14 +111,7 @@ export function BlogHeaderSearch() { return items .filter(item => { const title = normalizeSearchText(item.title || ''); - const bodyText = normalizeSearchText( - (item.body || []) - .filter(block => block?._type === 'block') - .map(block => - (block.children || []).map(child => child.text || '').join(' ') - ) - .join(' ') - ); + const bodyText = normalizeSearchText(extractPlainText(item.body)); return words.some( word => title.includes(word) || bodyText.includes(word) ); @@ -158,6 +144,7 @@ export function BlogHeaderSearch() { setOpen(prev => { const next = !prev; if (next) startLoading(); + else setValue(''); return next; }) } @@ -211,10 +198,10 @@ export function BlogHeaderSearch() {
    {results.map(result => ( +
    + ) : null} + + {!isConfigLoading && !isCheckingReadiness && !isReadyToPay ? ( +
    + +
    + ) : null} + + {isSubmitting ? ( +

    + {t('monobankGooglePay.submitting')} +

    + ) : null} + + {uiMessage ? ( +

    + {uiMessage} +

    + ) : null} + +
    + + {t('actions.backToCart')} + +
    +
    + ); +} diff --git a/frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx b/frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx new file mode 100644 index 00000000..b153b505 --- /dev/null +++ b/frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx @@ -0,0 +1,173 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; + +import { ClearCartOnMount } from '@/components/shop/ClearCartOnMount'; +import { Link } from '@/i18n/routing'; +import { OrderNotFoundError } from '@/lib/services/errors'; +import { getOrderSummary } from '@/lib/services/orders'; +import { formatMoney } from '@/lib/shop/currency'; +import { + SHOP_FOCUS, + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, +} from '@/lib/shop/ui-classes'; +import { cn } from '@/lib/utils'; +import { orderIdParamSchema } from '@/lib/validation/shop'; + +import MonobankGooglePayClient from '../MonobankGooglePayClient'; + +type SearchParams = Record; + +type PaymentPageProps = { + params: Promise<{ locale: string; orderId: string }>; + searchParams?: Promise; +}; + +export const metadata: Metadata = { + title: 'Monobank Google Pay | DevLovers', + description: 'Complete your payment with Google Pay via Monobank.', +}; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +const SHOP_OUTLINE_BTN = cn( + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, + SHOP_FOCUS +); + +function getStringParam(params: SearchParams | undefined, key: string): string { + const raw = params?.[key]; + if (!raw) return ''; + if (Array.isArray(raw)) return raw[0] ?? ''; + return raw; +} + +function parseStatusToken(params: SearchParams | undefined): string | null { + const value = getStringParam(params, 'statusToken').trim(); + return value.length ? value : null; +} + +function shouldClearCart(params: SearchParams | undefined): boolean { + const value = getStringParam(params, 'clearCart'); + return value === '1' || value === 'true'; +} + +function parseOrderId(rawOrderId: string): string | null { + const parsed = orderIdParamSchema.safeParse({ id: rawOrderId }); + if (!parsed.success) return null; + return parsed.data.id; +} + +export default async function MonobankGooglePayPage(props: PaymentPageProps) { + const { locale, orderId: rawOrderId } = await props.params; + const searchParams = props.searchParams ? await props.searchParams : undefined; + + const t = await getTranslations('shop.checkout'); + const orderId = parseOrderId(rawOrderId); + const statusToken = parseStatusToken(searchParams); + const clearCart = shouldClearCart(searchParams); + + if (!orderId) { + return ( +
    +
    +

    + {t('errors.invalidOrder')} +

    +

    + {t('missingOrder.message')} +

    +
    + + {t('actions.backToCart')} + +
    +
    +
    + ); + } + + let order: Awaited> | null = null; + let loadState: 'ok' | 'not_found' | 'error' = 'ok'; + + try { + order = await getOrderSummary(orderId); + } catch (error) { + loadState = error instanceof OrderNotFoundError ? 'not_found' : 'error'; + } + + if (loadState === 'not_found') { + return ( +
    +
    +

    + {t('errors.orderNotFound')} +

    +

    + {t('notFoundOrder.message')} +

    +
    + + {t('actions.backToCart')} + +
    +
    +
    + ); + } + + if (loadState === 'error' || !order) { + return ( +
    +
    +

    + {t('errors.unableToLoad')} +

    +

    + {t('errors.tryAgainLater')} +

    +
    +
    + ); + } + + return ( +
    + + +
    +

    + {t('payment.title')} +

    + +

    + {t('payment.payForOrder', { orderId: order.id.slice(0, 8) })} +

    + +

    + {t('monobankGooglePay.supportedDevices')} +

    + +
    +
    + + {t('payment.amountDue')} + + + {formatMoney(order.totalAmountMinor, order.currency, locale)} + +
    +

    + {order.currency} +

    +
    + +
    + +
    +
    +
    + ); +} diff --git a/frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx b/frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx new file mode 100644 index 00000000..6677e9fb --- /dev/null +++ b/frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Link, useRouter } from '@/i18n/routing'; +import { + type CurrencyCode, + currencyValues, + formatMoneyCode, +} from '@/lib/shop/currency'; +import { + SHOP_FOCUS, + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, +} from '@/lib/shop/ui-classes'; +import { cn } from '@/lib/utils'; + +type MonobankReturnStatusProps = { + orderId: string; + statusToken: string | null; + locale: string; +}; + +type LiteOrderStatus = { + id: string; + paymentStatus: string; + totalAmountMinor: number; + currency: CurrencyCode; + itemsCount: number; +}; + +const POLL_DELAY_MS = 2_500; +const TERMINAL_NON_PAID = new Set([ + 'failed', + 'refunded', + 'canceled', + 'needs_review', +]); +const PENDING_STATUSES = new Set(['pending', 'requires_payment']); + +const SHOP_OUTLINE_BTN = cn( + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, + SHOP_FOCUS +); + +function normalizeToken(token: string | null): string | null { + if (!token) return null; + const normalized = token.trim(); + return normalized.length ? normalized : null; +} + +function getStatusLabelKey(status: string): string { + if (status === 'paid') return 'paymentStatus.paid'; + if (status === 'failed') return 'paymentStatus.failed'; + if (status === 'refunded') return 'paymentStatus.refunded'; + if (status === 'pending') return 'paymentStatus.pending'; + if (status === 'requires_payment') return 'paymentStatus.confirming'; + if (status === 'needs_review') return 'paymentStatus.needsReview'; + return 'paymentStatus.unknown'; +} + +function parseStatusPayload(payload: unknown): LiteOrderStatus | null { + if (!payload || typeof payload !== 'object') return null; + const root = payload as Record; + if ( + typeof root.id !== 'string' || + typeof root.paymentStatus !== 'string' || + typeof root.totalAmountMinor !== 'number' || + !currencyValues.includes(root.currency as CurrencyCode) || + typeof root.itemsCount !== 'number' + ) { + return null; + } + + return { + id: root.id, + paymentStatus: root.paymentStatus, + totalAmountMinor: root.totalAmountMinor, + currency: root.currency as CurrencyCode, + itemsCount: root.itemsCount, + }; +} + +export default function MonobankReturnStatus({ + orderId, + statusToken, + locale, +}: MonobankReturnStatusProps) { + const t = useTranslations('shop.checkout'); + const router = useRouter(); + + const [status, setStatus] = useState(null); + const [isPolling, setIsPolling] = useState(true); + const [pollError, setPollError] = useState(null); + const [refreshSeed, setRefreshSeed] = useState(0); + + const normalizedToken = normalizeToken(statusToken); + + const fetchStatus = useCallback(async (): Promise => { + const params = new URLSearchParams({ view: 'lite' }); + if (normalizedToken) params.set('statusToken', normalizedToken); + + const response = await fetch( + `/api/shop/orders/${encodeURIComponent(orderId)}/status?${params.toString()}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + } + ); + + const payload = await response.json().catch(() => null); + if (!response.ok) return null; + + return parseStatusPayload(payload); + }, [normalizedToken, orderId]); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + + async function poll() { + if (cancelled) return; + + try { + const nextStatus = await fetchStatus(); + if (!nextStatus) { + if (!cancelled) { + setPollError(t('errors.tryAgainLater')); + timer = setTimeout(poll, POLL_DELAY_MS); + } + return; + } + + if (cancelled) return; + + setStatus(nextStatus); + setPollError(null); + + if (nextStatus.paymentStatus === 'paid') { + router.replace( + `/shop/checkout/success?orderId=${encodeURIComponent( + orderId + )}&clearCart=1` + ); + return; + } + + if (TERMINAL_NON_PAID.has(nextStatus.paymentStatus)) { + router.replace( + `/shop/checkout/error?orderId=${encodeURIComponent(orderId)}` + ); + return; + } + + if (!PENDING_STATUSES.has(nextStatus.paymentStatus)) { + timer = setTimeout(poll, POLL_DELAY_MS); + return; + } + + timer = setTimeout(poll, POLL_DELAY_MS); + } catch { + if (!cancelled) { + setPollError(t('errors.tryAgainLater')); + timer = setTimeout(poll, POLL_DELAY_MS); + } + } + } + + void poll().finally(() => { + if (!cancelled) setIsPolling(false); + }); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [fetchStatus, orderId, refreshSeed, router, t]); + + const statusLabel = useMemo(() => { + if (!status) return t('paymentStatus.confirming'); + return t(getStatusLabelKey(status.paymentStatus) as any); + }, [status, t]); + + return ( +
    +

    + {t('monobankReturn.processing')} +

    + +

    + {t('monobankReturn.checking')} +

    + +
    +
    +
    +
    {t('error.orderLabel')}
    +
    {orderId}
    +
    + +
    +
    {t('error.statusLabel')}
    +
    {statusLabel}
    +
    + + {status ? ( +
    +
    + {t('success.totalAmount')} +
    +
    + {formatMoneyCode( + status.totalAmountMinor, + status.currency, + locale + )} +
    +
    + ) : null} +
    +
    + + {pollError ? ( +

    + {pollError} +

    + ) : null} + +
    + + + + {t('actions.backToCart')} + +
    +
    + ); +} diff --git a/frontend/app/[locale]/shop/checkout/return/monobank/page.tsx b/frontend/app/[locale]/shop/checkout/return/monobank/page.tsx new file mode 100644 index 00000000..863434e3 --- /dev/null +++ b/frontend/app/[locale]/shop/checkout/return/monobank/page.tsx @@ -0,0 +1,101 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; + +import { ClearCartOnMount } from '@/components/shop/ClearCartOnMount'; +import { Link } from '@/i18n/routing'; +import { + SHOP_FOCUS, + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, +} from '@/lib/shop/ui-classes'; +import { cn } from '@/lib/utils'; +import { orderIdParamSchema } from '@/lib/validation/shop'; + +import MonobankReturnStatus from './MonobankReturnStatus'; + +type SearchParams = Record; + +export const metadata: Metadata = { + title: 'Monobank Payment Status | DevLovers', + description: 'Waiting for Monobank webhook confirmation.', +}; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +const SHOP_OUTLINE_BTN = cn( + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, + SHOP_FOCUS +); + +function getStringParam(params: SearchParams, key: string): string { + const raw = params[key]; + if (!raw) return ''; + if (Array.isArray(raw)) return raw[0] ?? ''; + return raw; +} + +function parseOrderId(searchParams: SearchParams): string | null { + const orderId = getStringParam(searchParams, 'orderId'); + const parsed = orderIdParamSchema.safeParse({ id: orderId }); + if (!parsed.success) return null; + return parsed.data.id; +} + +function parseStatusToken(searchParams: SearchParams): string | null { + const raw = getStringParam(searchParams, 'statusToken').trim(); + return raw.length ? raw : null; +} + +function shouldClearCart(searchParams: SearchParams): boolean { + const raw = getStringParam(searchParams, 'clearCart'); + return raw === '1' || raw === 'true'; +} + +export default async function MonobankReturnPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }>; + searchParams: Promise; +}) { + const { locale } = await params; + const resolvedSearchParams = await searchParams; + const t = await getTranslations('shop.checkout'); + + const orderId = parseOrderId(resolvedSearchParams); + const statusToken = parseStatusToken(resolvedSearchParams); + const clearCart = shouldClearCart(resolvedSearchParams); + + if (!orderId) { + return ( +
    +
    +

    + {t('errors.missingOrderId')} +

    +

    + {t('missingOrder.message')} +

    +
    + + {t('actions.backToCart')} + +
    +
    +
    + ); + } + + return ( +
    + + +
    + ); +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0bd62f53..a40a98cd 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -461,6 +461,11 @@ "label": "Payment method", "stripe": "Stripe", "monobank": "Monobank", + "monobankGooglePay": "Google Pay (Monobank)", + "monobankInvoice": "Card/Invoice (Monobank)", + "monobankGooglePayHint": "Google Pay available on supported devices", + "monobankGooglePayFallbackHint": "If Google Pay not available, use invoice", + "monobankGooglePayUnavailable": "Google Pay is currently unavailable for this order.", "monobankUahOnlyHint": "Monobank is available only for UAH checkout.", "monobankUnavailable": "Monobank is unavailable in this environment.", "noAvailable": "No payment methods are currently available." @@ -718,6 +723,22 @@ "completePayment": "Complete payment to finish your order" } }, + "monobankGooglePay": { + "supportedDevices": "Google Pay available on supported devices", + "useInvoiceFallback": "If Google Pay not available, use invoice", + "loading": "Preparing Google Pay...", + "submitting": "Processing payment...", + "unableToInit": "Unable to initialize Google Pay. You can continue with invoice.", + "invoiceFallback": "Pay via invoice", + "invoiceLoading": "Opening invoice...", + "invoiceFallbackFailed": "Unable to start invoice payment right now.", + "cancelled": "Google Pay was canceled. You can try again or use invoice." + }, + "monobankReturn": { + "processing": "Processing payment...", + "checking": "Waiting for webhook confirmation before finalizing your order.", + "refresh": "Refresh status" + }, "actions": { "backToProducts": "Back to products", "goToCart": "Go to cart", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 9f270c06..0780da7d 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -463,7 +463,12 @@ "monobank": "Monobank", "monobankUahOnlyHint": "Monobank jest dostępny tylko dla płatności w UAH.", "monobankUnavailable": "Monobank jest niedostępny w tym środowisku.", - "noAvailable": "Brak dostępnych metod płatności." + "noAvailable": "Brak dostępnych metod płatności.", + "monobankGooglePay": "Google Pay (Monobank)", + "monobankInvoice": "Card/Invoice (Monobank)", + "monobankGooglePayHint": "Google Pay available on supported devices", + "monobankGooglePayFallbackHint": "If Google Pay not available, use invoice", + "monobankGooglePayUnavailable": "Google Pay is currently unavailable for this order." } }, "actions": { @@ -723,6 +728,22 @@ "goToCart": "Przejdź do koszyka", "backToCart": "Wróć do koszyka", "continueShopping": "Kontynuuj zakupy" + }, + "monobankGooglePay": { + "supportedDevices": "Google Pay available on supported devices", + "useInvoiceFallback": "If Google Pay not available, use invoice", + "loading": "Preparing Google Pay...", + "submitting": "Processing payment...", + "unableToInit": "Unable to initialize Google Pay. You can continue with invoice.", + "invoiceFallback": "Pay via invoice", + "invoiceLoading": "Opening invoice...", + "invoiceFallbackFailed": "Unable to start invoice payment right now.", + "cancelled": "Google Pay was canceled. You can try again or use invoice." + }, + "monobankReturn": { + "processing": "Processing payment...", + "checking": "Waiting for webhook confirmation before finalizing your order.", + "refresh": "Refresh status" } }, "orders": { diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index dbf081fa..f4dfa04d 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -463,7 +463,12 @@ "monobank": "Monobank", "monobankUahOnlyHint": "Monobank доступний лише для оплати в UAH.", "monobankUnavailable": "Monobank недоступний у цьому середовищі.", - "noAvailable": "Наразі немає доступних способів оплати." + "noAvailable": "Наразі немає доступних способів оплати.", + "monobankGooglePay": "Google Pay (Monobank)", + "monobankInvoice": "Card/Invoice (Monobank)", + "monobankGooglePayHint": "Google Pay available on supported devices", + "monobankGooglePayFallbackHint": "If Google Pay not available, use invoice", + "monobankGooglePayUnavailable": "Google Pay is currently unavailable for this order." } }, "actions": { @@ -723,6 +728,22 @@ "goToCart": "Перейти до кошика", "backToCart": "Назад до кошика", "continueShopping": "Продовжити покупки" + }, + "monobankGooglePay": { + "supportedDevices": "Google Pay available on supported devices", + "useInvoiceFallback": "If Google Pay not available, use invoice", + "loading": "Preparing Google Pay...", + "submitting": "Processing payment...", + "unableToInit": "Unable to initialize Google Pay. You can continue with invoice.", + "invoiceFallback": "Pay via invoice", + "invoiceLoading": "Opening invoice...", + "invoiceFallbackFailed": "Unable to start invoice payment right now.", + "cancelled": "Google Pay was canceled. You can try again or use invoice." + }, + "monobankReturn": { + "processing": "Processing payment...", + "checking": "Waiting for webhook confirmation before finalizing your order.", + "refresh": "Refresh status" } }, "orders": { From 955209aba259b20ebba96ae85ddfe5e3b3d02a0a Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 3 Mar 2026 18:43:13 -0800 Subject: [PATCH 13/25] (SP: 2)[Wallets] propagate Monobank Google Pay wallet attribution and lock lite status contract --- .../lib/services/orders/monobank-wallet.ts | 34 +++++++-- .../lib/services/orders/monobank-webhook.ts | 72 ++++++++++++++++--- .../monobank-google-pay-submit-route.test.ts | 7 ++ .../tests/shop/monobank-webhook-apply.test.ts | 68 +++++++++++++++++- .../lib/tests/shop/order-status-token.test.ts | 3 +- 5 files changed, 168 insertions(+), 16 deletions(-) diff --git a/frontend/lib/services/orders/monobank-wallet.ts b/frontend/lib/services/orders/monobank-wallet.ts index b03411a9..f175d6b1 100644 --- a/frontend/lib/services/orders/monobank-wallet.ts +++ b/frontend/lib/services/orders/monobank-wallet.ts @@ -7,9 +7,9 @@ import { orders, paymentAttempts } from '@/db/schema'; import { MONO_CCY, MONO_CURRENCY, + type MonobankWalletPaymentResult, PspError, walletPayment, - type MonobankWalletPaymentResult, } from '@/lib/psp/monobank'; import { IdempotencyConflictError, @@ -69,6 +69,13 @@ function asRecord(value: unknown): Record { return value as Record; } +function readWalletMetadata(meta: Record): Record { + const monobank = asRecord(meta.monobank); + const monobankWallet = asRecord(monobank.wallet); + if (Object.keys(monobankWallet).length > 0) return monobankWallet; + return asRecord(meta.wallet); +} + function parseIsoDateOrNull(value: unknown): Date | null { if (typeof value !== 'string' || !value.trim()) return null; const ms = Date.parse(value); @@ -81,7 +88,7 @@ function readReplayResult( reused: boolean ): MonobankWalletSubmitResult { const meta = asRecord(attempt.metadata); - const wallet = asRecord(meta.wallet); + const wallet = readWalletMetadata(meta); const submitOutcome = wallet.submitOutcome === 'unknown' ? 'unknown' : 'submitted'; @@ -250,6 +257,11 @@ async function createCreatingAttempt(args: { } const now = new Date(); + const walletMetadata = { + requested: 'google_pay', + submitOutcome: 'creating', + lastSubmitAt: now.toISOString(), + }; const inserted = await db .insert(paymentAttempts) .values({ @@ -261,10 +273,10 @@ async function createCreatingAttempt(args: { expectedAmountMinor: args.expectedAmountMinor, idempotencyKey: args.idempotencyKey, metadata: { - wallet: { - submitOutcome: 'creating', - lastSubmitAt: now.toISOString(), + monobank: { + wallet: walletMetadata, }, + wallet: walletMetadata, }, }) .returning(); @@ -280,8 +292,17 @@ function mergeWalletMetadata( ): Record { const meta = asRecord(current); const wallet = asRecord(meta.wallet); + const monobank = asRecord(meta.monobank); + const monobankWallet = asRecord(monobank.wallet); return { ...meta, + monobank: { + ...monobank, + wallet: { + ...monobankWallet, + ...patch, + }, + }, wallet: { ...wallet, ...patch, @@ -306,6 +327,7 @@ async function persistAttemptSubmitted(args: { (args.pspResult.redirectUrl ? 'redirect_required' : 'submitted'); const nextMetadata = mergeWalletMetadata(args.attempt.metadata, { + requested: 'google_pay', submitOutcome: 'submitted', syncStatus, invoiceId, @@ -338,6 +360,7 @@ async function persistAttemptUnknown(args: { }): Promise { const now = new Date(); const nextMetadata = mergeWalletMetadata(args.attempt.metadata, { + requested: 'google_pay', submitOutcome: 'unknown', syncStatus: 'unknown', unknownReason: args.errorCode, @@ -363,6 +386,7 @@ async function persistAttemptRejected(args: { }): Promise { const now = new Date(); const nextMetadata = mergeWalletMetadata(args.attempt.metadata, { + requested: 'google_pay', submitOutcome: 'rejected', syncStatus: 'rejected', rejectCode: args.errorCode, diff --git a/frontend/lib/services/orders/monobank-webhook.ts b/frontend/lib/services/orders/monobank-webhook.ts index d5ea4289..0b0feed4 100644 --- a/frontend/lib/services/orders/monobank-webhook.ts +++ b/frontend/lib/services/orders/monobank-webhook.ts @@ -67,6 +67,7 @@ type AttemptRow = Pick< | 'expectedAmountMinor' | 'providerPaymentIntentId' | 'providerModifiedAt' + | 'metadata' >; type OrderRow = Pick< @@ -88,6 +89,12 @@ type PaymentStatusTarget = Parameters< typeof guardedPaymentStatusUpdate >[0]['to']; +type WalletAttribution = { + provider: 'monobank'; + type: 'google_pay'; + source: 'attempt'; +}; + const CLAIM_TTL_MS = (() => { const raw = process.env.MONO_WEBHOOK_CLAIM_TTL_MS; const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; @@ -102,6 +109,38 @@ const INSTANCE_ID = (() => { return value.length > 64 ? value.slice(0, 64) : value; })(); +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function resolveWalletAttributionFromAttempt( + attempt: AttemptRow +): WalletAttribution | null { + const metadata = asRecord(attempt.metadata); + const monobank = asRecord(metadata.monobank); + const monobankWallet = asRecord(monobank.wallet); + const legacyWallet = asRecord(metadata.wallet); + + const requestedRaw = + typeof monobankWallet.requested === 'string' + ? monobankWallet.requested + : typeof legacyWallet.requested === 'string' + ? legacyWallet.requested + : ''; + const requested = requestedRaw.trim().toLowerCase(); + + if (requested === 'google_pay') { + return { + provider: 'monobank', + type: 'google_pay', + source: 'attempt', + }; + } + + return null; +} + function toIssueMessage(error: unknown): string { const msg = error instanceof Error @@ -343,7 +382,8 @@ async function fetchAttemptForWebhook(args: { status as "status", expected_amount_minor as "expectedAmountMinor", provider_payment_intent_id as "providerPaymentIntentId", - provider_modified_at as "providerModifiedAt" + provider_modified_at as "providerModifiedAt", + metadata as "metadata" from payment_attempts where provider = 'monobank' and ( @@ -460,8 +500,11 @@ async function persistEventOutcome(args: { .where(eq(monobankEvents.id, args.eventId)); } -function buildMergedMetaSql(normalized: NormalizedWebhook) { - const metadataPatch = { +function buildMergedMetaSql( + normalized: NormalizedWebhook, + walletAttribution: WalletAttribution | null +) { + const metadataPatch: Record = { monobank: { invoiceId: normalized.invoiceId, status: normalized.status, @@ -470,6 +513,9 @@ function buildMergedMetaSql(normalized: NormalizedWebhook) { reference: normalized.reference ?? null, }, }; + if (walletAttribution) { + metadataPatch.wallet = walletAttribution; + } return sql`coalesce(${orders.pspMetadata}, '{}'::jsonb) || ${JSON.stringify( metadataPatch @@ -487,7 +533,17 @@ async function atomicMarkPaidOrderAndSucceedAttempt(args: { enqueueShipment: boolean; canonicalDualWriteEnabled: boolean; canonicalEventDedupeKey: string; + walletAttribution: WalletAttribution | null; }): Promise<{ ok: boolean; shipmentQueued: boolean }> { + const paymentEventPayload: Record = { + monobankEventId: args.eventId, + invoiceId: args.invoiceId, + status: 'success', + }; + if (args.walletAttribution) { + paymentEventPayload.wallet = args.walletAttribution; + } + const res = args.canonicalDualWriteEnabled ? await db.execute(sql` with updated_order as ( @@ -555,11 +611,7 @@ async function atomicMarkPaidOrderAndSucceedAttempt(args: { null, uo.total_amount_minor::bigint, uo.currency, - ${JSON.stringify({ - monobankEventId: args.eventId, - invoiceId: args.invoiceId, - status: 'success', - })}::jsonb, + ${JSON.stringify(paymentEventPayload)}::jsonb, ${args.canonicalEventDedupeKey}, ${args.now}, ${args.now} @@ -887,7 +939,8 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { providerModifiedAt, attemptProviderModifiedAt ); - const mergedMetaSql = buildMergedMetaSql(normalized); + const walletAttribution = resolveWalletAttributionFromAttempt(attemptRow); + const mergedMetaSql = buildMergedMetaSql(normalized, walletAttribution); if ( providerModifiedAt && @@ -1135,6 +1188,7 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { monobankEventId: eventId, invoiceId: normalized.invoiceId, }), + walletAttribution, }); if (!atomicResult.ok) { diff --git a/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts index fbe9733e..e9649329 100644 --- a/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts +++ b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts @@ -255,6 +255,13 @@ describe.sequential('monobank google pay submit route', () => { .from(paymentAttempts) .where(inArray(paymentAttempts.orderId, [orderJsonToken, orderRawToken])); + for (const attempt of attempts) { + const metadata = (attempt.metadata ?? {}) as Record; + const wallet = metadata.monobank?.wallet; + expect(wallet?.requested).toBe('google_pay'); + expect(wallet?.submitOutcome).toBe('submitted'); + } + const serializedMeta = JSON.stringify(attempts); expect(serializedMeta).not.toContain(jsonTokenMarker); expect(serializedMeta).not.toContain(rawTokenMarker); diff --git a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts index b1b496cb..7ba752a8 100644 --- a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts +++ b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts @@ -7,8 +7,8 @@ import { db } from '@/db'; import { monobankEvents, orders, - paymentEvents, paymentAttempts, + paymentEvents, shippingShipments, } from '@/db/schema'; import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; @@ -47,6 +47,7 @@ async function insertOrderAndAttempt(args: { | 'released' | 'failed'; withShippingNp?: boolean; + attemptMetadata?: Record; }) { const orderId = crypto.randomUUID(); await db.insert(orders).values({ @@ -82,6 +83,7 @@ async function insertOrderAndAttempt(args: { expectedAmountMinor: args.amountMinor, idempotencyKey: buildMonobankAttemptIdempotencyKey(orderId, 1), providerPaymentIntentId: args.invoiceId, + metadata: args.attemptMetadata ?? {}, } as any); return { orderId, attemptId }; @@ -227,6 +229,70 @@ describe.sequential('monobank webhook apply (persist-first)', () => { } }); + it('copies wallet attribution from attempt metadata and performs no outbound network calls', async () => { + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + attemptMetadata: { + monobank: { + wallet: { + requested: 'google_pay', + }, + }, + }, + }); + + const rawBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + rawSha256: sha256HexUtf8(rawBody), + requestId: 'req_wallet_attr_1', + mode: 'apply', + }); + + expect(res.appliedResult).toBe('applied'); + + const [order] = await db + .select({ pspMetadata: orders.pspMetadata }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect((order?.pspMetadata as any)?.wallet).toEqual({ + provider: 'monobank', + type: 'google_pay', + source: 'attempt', + }); + + const [event] = await db + .select({ payload: paymentEvents.payload }) + .from(paymentEvents) + .where(eq(paymentEvents.orderId, orderId)) + .limit(1); + + expect((event?.payload as any)?.wallet).toEqual({ + provider: 'monobank', + type: 'google_pay', + source: 'attempt', + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + await cleanup(orderId, invoiceId); + } + }); + it('does not enqueue shipment when inventory is not committed', async () => { const invoiceId = `inv_${crypto.randomUUID()}`; const { orderId } = await insertOrderAndAttempt({ diff --git a/frontend/lib/tests/shop/order-status-token.test.ts b/frontend/lib/tests/shop/order-status-token.test.ts index 0df03938..e5904538 100644 --- a/frontend/lib/tests/shop/order-status-token.test.ts +++ b/frontend/lib/tests/shop/order-status-token.test.ts @@ -100,7 +100,7 @@ describe('order status token access control', () => { 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( + `http://localhost/api/shop/orders/${orderId}/status?view=lite&statusToken=${encodeURIComponent( token )}` ); @@ -112,6 +112,7 @@ describe('order status token access control', () => { expect(json.currency).toBe('UAH'); expect(json.totalAmountMinor).toBe(1000); expect(json.paymentStatus).toBe('pending'); + expect(json.itemsCount).toBe(0); expect(typeof json.updatedAt).toBe('string'); expect(json.order).toBeUndefined(); expect(json.attempt).toBeUndefined(); From bf40c852d42d45c4ea81725d7e71529bfe85cd5d Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 3 Mar 2026 19:04:24 -0800 Subject: [PATCH 14/25] (SP: 1)[Wallets] add Stripe wallet attribution and harden well-known bypass --- .../app/api/shop/webhooks/stripe/route.ts | 42 ++++ .../shop/stripe-webhook-psp-fields.test.ts | 190 +++++++++++++++++- frontend/proxy.ts | 13 +- 3 files changed, 242 insertions(+), 3 deletions(-) diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index e3a63a6f..8188320a 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -289,6 +289,39 @@ function resolvePaymentMethod( return paymentMethodFromIntent ?? paymentMethodFromCharge ?? null; } +type StripeWalletType = 'apple_pay' | 'google_pay' | null; +type StripeWalletAttribution = { + provider: 'stripe'; + type: StripeWalletType; + source: 'event'; +}; + +function resolveStripeWalletType( + paymentIntent?: Stripe.PaymentIntent, + charge?: Stripe.Charge +): StripeWalletType { + const chargeWithWallet = charge ?? getLatestCharge(paymentIntent as any); + const walletTypeRaw = (chargeWithWallet as any)?.payment_method_details?.card + ?.wallet?.type; + + if (walletTypeRaw === 'apple_pay' || walletTypeRaw === 'google_pay') { + return walletTypeRaw; + } + + return null; +} + +function buildStripeWalletAttribution(args: { + paymentIntent?: Stripe.PaymentIntent; + charge?: Stripe.Charge; +}): StripeWalletAttribution { + return { + provider: 'stripe', + type: resolveStripeWalletType(args.paymentIntent, args.charge), + source: 'event', + }; +} + function buildPspMetadata(params: { eventType: string; paymentIntent?: Stripe.PaymentIntent; @@ -1140,6 +1173,10 @@ export async function POST(request: NextRequest) { const amountMatches = stripeAmount === orderAmountMinor; const currencyMatches = stripeCurrency?.toUpperCase() === order.currency.toUpperCase(); + const walletAttribution = buildStripeWalletAttribution({ + paymentIntent, + charge: getLatestCharge(paymentIntent as any), + }); if (stripeAmount == null || !amountMatches || !currencyMatches) { const mismatchReason = @@ -1157,6 +1194,7 @@ export async function POST(request: NextRequest) { paymentIntent, charge: chargeForIntent, extra: { + wallet: walletAttribution, mismatch: { reason: mismatchReason, eventId: event.id, @@ -1221,6 +1259,9 @@ export async function POST(request: NextRequest) { eventType, paymentIntent, charge: chargeForIntent ?? undefined, + extra: { + wallet: walletAttribution, + }, }); const nextMeta = mergePspMetadata({ prevMeta: order.pspMetadata, @@ -1256,6 +1297,7 @@ export async function POST(request: NextRequest) { paymentIntentId, chargeId: latestChargeId ?? chargeForIntent?.id ?? null, paymentIntentStatus: paymentIntent?.status ?? null, + wallet: walletAttribution, }, }; const applyResult = diff --git a/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts index 7a3cd55e..1a422df0 100644 --- a/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts @@ -20,11 +20,12 @@ vi.mock('@/lib/psp/stripe', async () => { return { ...actual, verifyWebhookSignature: vi.fn(), + retrieveCharge: vi.fn(), }; }); import { POST as webhookPOST } from '@/app/api/shop/webhooks/stripe/route'; -import { verifyWebhookSignature } from '@/lib/psp/stripe'; +import { retrieveCharge, verifyWebhookSignature } from '@/lib/psp/stripe'; function logTestCleanupFailed(meta: Record, error: unknown) { console.error('[test cleanup failed]', { @@ -234,7 +235,11 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { payment_intent: paymentIntentId, payment_method_details: { type: 'card', - card: { brand: 'visa', last4: '4242' }, + card: { + brand: 'visa', + last4: '4242', + wallet: { type: 'apple_pay' }, + }, }, }, ], @@ -244,6 +249,7 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { }; vi.mocked(verifyWebhookSignature).mockReturnValue(event as any); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); const rawBody = JSON.stringify({ any: 'payload' }); const req = makeWebhookRequest(rawBody); @@ -298,6 +304,11 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { Object.keys((updated1[0].pspMetadata ?? {}) as Record) .length ).toBeGreaterThan(0); + expect((updated1[0].pspMetadata as any)?.wallet).toEqual({ + provider: 'stripe', + type: 'apple_pay', + source: 'event', + }); const ev1 = await db .select({ eventId: stripeEvents.eventId }) @@ -310,12 +321,18 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { id: paymentEvents.id, eventName: paymentEvents.eventName, eventRef: paymentEvents.eventRef, + payload: paymentEvents.payload, }) .from(paymentEvents) .where(eq(paymentEvents.orderId, orderId)); expect(canonical1.length).toBe(1); expect(canonical1[0]?.eventName).toBe('paid_applied'); expect(canonical1[0]?.eventRef).toBe(eventId); + expect((canonical1[0]?.payload as any)?.wallet).toEqual({ + provider: 'stripe', + type: 'apple_pay', + source: 'event', + }); const queued1 = await db .select({ id: shippingShipments.id }) @@ -365,7 +382,176 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { .from(shippingShipments) .where(eq(shippingShipments.orderId, orderId)); expect(queued2.length).toBe(1); + expect(vi.mocked(retrieveCharge)).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + await cleanup({ orderId, productId, eventId }); + } + }, 30_000); + + it('payment_intent.succeeded extracts google_pay wallet attribution into order and canonical event payload', async () => { + const productId = randomUUID(); + const priceId = randomUUID(); + + const orderId = randomUUID(); + const idemKey = `idem_${randomUUID()}`; + + const paymentIntentId = `pi_test_${randomUUID() + .replace(/-/g, '') + .slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const chargeId = `ch_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + const title = 'Webhook PSP Test Product GPay'; + const slug = `webhook-psp-gpay-${productId.slice(0, 8)}`; + const sku = `SKU-GP-${productId.slice(0, 8)}`; + + await db.insert(products).values({ + id: productId, + slug, + title, + description: 'webhook test', + imageUrl: 'https://res.cloudinary.com/devlovers/image/upload/v1/test.png', + imagePublicId: null, + price: '9.00', + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + sku, + }); + + await db.insert(productPrices).values({ + id: priceId, + productId, + currency: 'USD', + priceMinor: 900, + originalPriceMinor: null, + price: '9.00', + originalPrice: null, + }); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 900, + totalAmount: '9.00', + currency: 'USD', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingAmountMinor: null, + shippingStatus: 'pending', + paymentStatus: 'requires_payment', + paymentProvider: 'stripe', + paymentIntentId, + idempotencyKey: idemKey, + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + }); + + await db.insert(orderItems).values({ + id: randomUUID(), + orderId, + productId, + quantity: 1, + unitPriceMinor: 900, + lineTotalMinor: 900, + unitPrice: '9.00', + lineTotal: '9.00', + productTitle: title, + productSlug: slug, + productSku: sku, + }); + + const event = { + id: eventId, + object: 'event', + type: 'payment_intent.succeeded', + data: { + object: { + id: paymentIntentId, + object: 'payment_intent', + amount: 900, + currency: 'usd', + status: 'succeeded', + metadata: { orderId }, + charges: { + object: 'list', + data: [ + { + id: chargeId, + object: 'charge', + payment_intent: paymentIntentId, + payment_method_details: { + type: 'card', + card: { + brand: 'visa', + last4: '4242', + wallet: { type: 'google_pay' }, + }, + }, + }, + ], + }, + }, + }, + }; + + vi.mocked(verifyWebhookSignature).mockReturnValue(event as any); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const rawBody = JSON.stringify({ any: 'payload_google_wallet' }); + const req = makeWebhookRequest(rawBody); + + try { + const res = await webhookPOST(req); + expect(res.status).toBeGreaterThanOrEqual(200); + expect(res.status).toBeLessThan(300); + + const [updated] = await db + .select({ + paymentStatus: orders.paymentStatus, + pspMetadata: orders.pspMetadata, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(updated?.paymentStatus).toBe('paid'); + expect((updated?.pspMetadata as any)?.wallet).toEqual({ + provider: 'stripe', + type: 'google_pay', + source: 'event', + }); + + const [canonical] = await db + .select({ + eventName: paymentEvents.eventName, + payload: paymentEvents.payload, + }) + .from(paymentEvents) + .where(eq(paymentEvents.orderId, orderId)) + .limit(1); + + expect(canonical?.eventName).toBe('paid_applied'); + expect((canonical?.payload as any)?.wallet).toEqual({ + provider: 'stripe', + type: 'google_pay', + source: 'event', + }); + + expect(vi.mocked(retrieveCharge)).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); } finally { + fetchSpy.mockRestore(); await cleanup({ orderId, productId, eventId }); } }, 30_000); diff --git a/frontend/proxy.ts b/frontend/proxy.ts index ab2d29c7..21f8677c 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -85,6 +85,13 @@ function getScopeFromPathname(pathname: string): 'shop' | 'site' { } export function proxy(req: NextRequest) { + // TODO(wallets-phase8): replace placeholder bytes in + // frontend/public/.well-known/apple-developer-merchantid-domain-association + // with the exact Stripe/Apple file before production enablement. + if (req.nextUrl.pathname.startsWith('/.well-known/')) { + return NextResponse.next(); + } + if (req.nextUrl.pathname === '/') { return NextResponse.redirect(new URL('/en', req.url)); } @@ -109,5 +116,9 @@ export function proxy(req: NextRequest) { } export const config = { - matcher: ['/', '/(uk|en|pl)/:path*', '/((?!api|_next|.*\\..*).*)'], + matcher: [ + '/', + '/(uk|en|pl)/:path*', + '/((?!api|_next|\\.well-known(?:/|$)|.*\\..*).*)', + ], }; From e26c1222225a382f1a4aa18f46ba835c3ed16206 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 4 Mar 2026 17:43:53 -0800 Subject: [PATCH 15/25] (SP: 2)[Wallets] remove Stripe webhook fetch and treat Monobank 429 as pending unknown --- .../app/api/shop/webhooks/stripe/route.ts | 4 +- frontend/lib/psp/monobank.ts | 17 ++- .../tests/shop/monobank-api-methods.test.ts | 23 +++- .../monobank-google-pay-submit-route.test.ts | 28 +++++ .../shop/stripe-webhook-refund-full.test.ts | 117 +++++++++++++----- frontend/proxy.ts | 3 - 6 files changed, 153 insertions(+), 39 deletions(-) diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 8188320a..f1600707 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -8,7 +8,7 @@ import { db } from '@/db'; import { orders, stripeEvents } from '@/db/schema'; import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; import { logError, logInfo, logWarn } from '@/lib/logging'; -import { retrieveCharge, verifyWebhookSignature } from '@/lib/psp/stripe'; +import { verifyWebhookSignature } from '@/lib/psp/stripe'; import { guardNonBrowserOnly } from '@/lib/security/origin'; import { enforceRateLimit, @@ -1714,8 +1714,6 @@ export async function POST(request: NextRequest) { if (typeof refund.charge === 'object' && refund.charge) { effectiveCharge = refund.charge as Stripe.Charge; - } else if (typeof refund.charge === 'string' && refund.charge.trim()) { - effectiveCharge = await retrieveCharge(refund.charge.trim()); } const amt = diff --git a/frontend/lib/psp/monobank.ts b/frontend/lib/psp/monobank.ts index 48466048..1b39785e 100644 --- a/frontend/lib/psp/monobank.ts +++ b/frontend/lib/psp/monobank.ts @@ -532,6 +532,20 @@ async function requestMono( }); } + if (status === 429) { + throw new PspError('PSP_UPSTREAM', 'Monobank upstream rate limit', { + endpoint, + method: args.method, + httpStatus: status, + durationMs, + ...(parsed.monoCode ? { monoCode: parsed.monoCode } : {}), + ...(parsed.monoMessage ? { monoMessage: parsed.monoMessage } : {}), + ...(parsed.responseSnippet + ? { responseSnippet: parsed.responseSnippet } + : {}), + }); + } + if (status >= 400 && status < 500) { throw new PspError('PSP_BAD_REQUEST', 'Monobank request rejected', { endpoint, @@ -770,7 +784,8 @@ export async function walletPayment( throw new Error('Monobank wallet payment returned invalid payload'); } - const raw = res.data as MonobankWalletPaymentResponse & Record; + const raw = res.data as MonobankWalletPaymentResponse & + Record; const invoiceId = typeof raw.invoiceId === 'string' && raw.invoiceId.trim() ? raw.invoiceId.trim() diff --git a/frontend/lib/tests/shop/monobank-api-methods.test.ts b/frontend/lib/tests/shop/monobank-api-methods.test.ts index 0bd6d626..0a828745 100644 --- a/frontend/lib/tests/shop/monobank-api-methods.test.ts +++ b/frontend/lib/tests/shop/monobank-api-methods.test.ts @@ -337,7 +337,7 @@ describe('monobank api methods', () => { }); }); - it('walletPayment maps 400 and 500 deterministically', async () => { + it('walletPayment maps 400, 429, and 500 deterministically', async () => { const badRequestFetch = vi.fn(async () => makeResponse(400, JSON.stringify({ errorCode: 'X', message: 'bad' })) ); @@ -360,6 +360,27 @@ describe('monobank api methods', () => { } ); + const rateLimitedFetch = vi.fn(async () => + makeResponse(429, JSON.stringify({ errorCode: 'R', message: 'rate' })) + ); + globalThis.fetch = rateLimitedFetch as any; + + await expectPspError( + () => + walletPayment({ + cardToken: 'token', + amountMinor: 100, + ccy: 980, + redirectUrl: 'https://shop.test/return', + webHookUrl: 'https://shop.test/webhook', + }), + 'PSP_UPSTREAM', + { + httpStatus: 429, + monoCode: 'R', + } + ); + const upstreamFetch = vi.fn(async () => makeResponse(500, 'server error')); globalThis.fetch = upstreamFetch as any; diff --git a/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts index e9649329..5a35623c 100644 --- a/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts +++ b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts @@ -473,4 +473,32 @@ describe.sequential('monobank google pay submit route', () => { await cleanupOrder(orderId); } }); + + it('returns pending/unknown on transient 429 upstream without retries', async () => { + const orderId = crypto.randomUUID(); + await insertOrder({ id: orderId }); + + walletPaymentMock.mockRejectedValueOnce( + new PspErrorCtor('PSP_UPSTREAM', 'rate_limited', { httpStatus: 429 }) + ); + + try { + const res = await postRoute( + makeSubmitRequest({ + orderId, + idempotencyKey: 'mono_submit_unknown_429_0001', + body: JSON.stringify({ gToken: 'token-429' }), + }), + { params: Promise.resolve({ id: orderId }) } + ); + + expect(res.status).toBe(202); + const json: any = await res.json(); + expect(json.submitOutcome).toBe('unknown'); + expect(json.status).toBe('pending'); + expect(walletPaymentMock).toHaveBeenCalledTimes(1); + } finally { + await cleanupOrder(orderId); + } + }); }); 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 8ed5730e..30e6e39e 100644 --- a/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts @@ -200,12 +200,21 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(events.length).toBe(1); }, 30_000); - it('full refund (charge.refund.updated) WITHOUT metadata.orderId resolves by paymentIntentId (via retrieveCharge), sets terminal status, calls restock once', async () => { + it('full refund (charge.refund.updated) WITHOUT metadata.orderId resolves by paymentIntentId, sets terminal status, calls restock once', async () => { inserted = await insertPaidOrder(); const eventId = `evt_${crypto.randomUUID()}`; const chargeId = `ch_${crypto.randomUUID()}`; const refundId = `re_${crypto.randomUUID()}`; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const expandedCharge = makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, + amount: 2500, + amountRefunded: 2500, + refunds: [{ id: refundId, amount: 2500 }], + }); const refund = { id: refundId, @@ -213,21 +222,11 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded amount: 2500, status: 'succeeded', reason: null, - charge: chargeId, + charge: expandedCharge, payment_intent: inserted.paymentIntentId, metadata: {}, }; - retrieveChargeMock.mockResolvedValue( - makeCharge({ - chargeId, - paymentIntentId: inserted.paymentIntentId, - amount: 2500, - amountRefunded: 2500, - refunds: [{ id: refundId, amount: 2500 }], - }) - ); - verifyWebhookSignatureMock.mockReturnValue({ id: eventId, type: 'charge.refund.updated', @@ -253,13 +252,68 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row.stockRestored).toBe(true); expect(row.pspChargeId).toBe(chargeId); - expect(retrieveChargeMock).toHaveBeenCalledTimes(1); - expect(retrieveChargeMock).toHaveBeenCalledWith(chargeId); + expect(retrieveChargeMock).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); expect(restockOrderMock).toHaveBeenCalledTimes(1); expect(restockOrderMock).toHaveBeenCalledWith(inserted.orderId, { reason: 'refunded', }); + + fetchSpy.mockRestore(); + }, 30_000); + + it('charge.refund.updated fail-closed when refund.charge is a string id: no retrieve/no fetch/no terminal refund/no restock', async () => { + inserted = await insertPaidOrder(); + + const eventId = `evt_${crypto.randomUUID()}`; + const chargeId = `ch_${crypto.randomUUID()}`; + const refundId = `re_${crypto.randomUUID()}`; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const refund = { + id: refundId, + object: 'refund', + amount: 2500, + status: 'succeeded', + reason: null, + charge: chargeId, + payment_intent: inserted.paymentIntentId, + metadata: {}, + }; + + verifyWebhookSignatureMock.mockReturnValue({ + id: eventId, + type: 'charge.refund.updated', + data: { object: refund }, + } as unknown as Stripe.Event); + + try { + const res = await POST(makeRequest()); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + code: 'REFUND_FULLNESS_UNDETERMINED', + }); + + const [row] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(eq(orders.id, inserted.orderId)) + .limit(1); + + expect(row.paymentStatus).toBe('paid'); + expect(row.status).toBe('PAID'); + expect(row.stockRestored).toBe(false); + expect(retrieveChargeMock).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(restockOrderMock).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } }, 30_000); it('partial refund is ignored (no paymentStatus/status change, no restock)', async () => { @@ -375,36 +429,35 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded const eventId = `evt_${crypto.randomUUID()}`; const chargeId = `ch_${crypto.randomUUID()}`; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); const refund1Id = `re_${crypto.randomUUID()}`; const refund2Id = `re_${crypto.randomUUID()}`; const refund3Id = `re_${crypto.randomUUID()}`; + const expandedCharge = makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, + amount: 2500, + amountRefunded: 2500, + refunds: [ + { id: refund1Id, amount: 1000 }, + { id: refund2Id, amount: 1000 }, + { id: refund3Id, amount: 500 }, + ], + }); + const refund = { id: refund3Id, object: 'refund', amount: 500, status: 'succeeded', reason: null, - charge: chargeId, + charge: expandedCharge, payment_intent: inserted.paymentIntentId, metadata: {}, }; - retrieveChargeMock.mockResolvedValue( - makeCharge({ - chargeId, - paymentIntentId: inserted.paymentIntentId, - amount: 2500, - amountRefunded: 2500, - refunds: [ - { id: refund1Id, amount: 1000 }, - { id: refund2Id, amount: 1000 }, - { id: refund3Id, amount: 500 }, - ], - }) - ); - verifyWebhookSignatureMock.mockReturnValue({ id: eventId, type: 'charge.refund.updated', @@ -428,10 +481,12 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row.status).toBe('CANCELED'); expect(row.stockRestored).toBe(true); - expect(retrieveChargeMock).toHaveBeenCalledTimes(1); - expect(retrieveChargeMock).toHaveBeenCalledWith(chargeId); + expect(retrieveChargeMock).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); expect(restockOrderMock).toHaveBeenCalledTimes(1); + + fetchSpy.mockRestore(); }, 30_000); it('charge.refunded: fallback to sum(refunds) when amount_refunded is missing (still detects full refund)', async () => { inserted = await insertPaidOrder(); diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 21f8677c..dae0ce43 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -85,9 +85,6 @@ function getScopeFromPathname(pathname: string): 'shop' | 'site' { } export function proxy(req: NextRequest) { - // TODO(wallets-phase8): replace placeholder bytes in - // frontend/public/.well-known/apple-developer-merchantid-domain-association - // with the exact Stripe/Apple file before production enablement. if (req.nextUrl.pathname.startsWith('/.well-known/')) { return NextResponse.next(); } From a1af9816feaf20c6a6d552bb6d2aaa08af8a79aa Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Thu, 5 Mar 2026 16:03:51 -0800 Subject: [PATCH 16/25] (SP: 2)[Wallets] restore NP warehouses caching + correct CityRef usage; harden sync payload encoding --- .gitattributes | 1 - .../app/[locale]/shop/cart/CartPageClient.tsx | 223 +++++++++++++++--- .../shop/internal/shipping/np/sync/route.ts | 28 ++- .../shop/shipping/nova-poshta-catalog.ts | 8 +- .../shop/shipping/nova-poshta-client.ts | 110 +++++++-- ...le-developer-merchantid-domain-association | 2 - frontend/scripts/np-mock-server.mjs | 82 ++++++- 7 files changed, 393 insertions(+), 61 deletions(-) delete mode 100644 .gitattributes delete mode 100644 frontend/public/.well-known/apple-developer-merchantid-domain-association diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 81c32805..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -frontend/public/.well-known/apple-developer-merchantid-domain-association -text diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 8deb8698..728f868e 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -90,7 +90,6 @@ function resolveInitialProvider(args: { const canUseStripe = args.stripeEnabled; const canUseMonobank = args.monobankEnabled && isUah; - // Monobank-first for UAH checkout. if (canUseMonobank) return 'monobank'; if (canUseStripe) return 'stripe'; return 'stripe'; @@ -127,6 +126,73 @@ type ShippingWarehouse = { address: string | null; }; +function normalizeLookupValue(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +function normalizeShippingCity(raw: unknown): ShippingCity | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null; + } + + const item = raw as Record; + + const ref = typeof item.ref === 'string' ? item.ref.trim() : ''; + + const rawName = + typeof item.nameUa === 'string' + ? item.nameUa + : typeof item.name_ua === 'string' + ? item.name_ua + : typeof item.name === 'string' + ? item.name + : typeof item.present === 'string' + ? item.present + : ''; + + const nameUa = rawName.trim(); + + if (!ref || !nameUa) { + return null; + } + + return { + ref, + nameUa, + }; +} + +function parseShippingCitiesResponse(data: unknown): { + available: boolean | null; + items: ShippingCity[]; +} { + if (Array.isArray(data)) { + return { + available: null, + items: data + .map(normalizeShippingCity) + .filter((item): item is ShippingCity => item !== null), + }; + } + + if (!data || typeof data !== 'object') { + return { + available: null, + items: [], + }; + } + + const obj = data as Record; + const itemsRaw = Array.isArray(obj.items) ? obj.items : []; + + return { + available: typeof obj.available === 'boolean' ? obj.available : null, + items: itemsRaw + .map(normalizeShippingCity) + .filter((item): item is ShippingCity => item !== null), + }; +} + function isWarehouseMethod( methodCode: CheckoutDeliveryMethodCode | null ): boolean { @@ -272,6 +338,10 @@ export default function CartPage({ if (key) return safeT(key, code ?? 'SHIPPING_INVALID'); return safeT('delivery.validation.invalid', code ?? 'SHIPPING_INVALID'); }; + const clearCheckoutUiErrors = () => { + setDeliveryUiError(null); + setCheckoutError(null); + }; useEffect(() => { if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) { @@ -482,8 +552,10 @@ export default function CartPage({ let cancelled = false; const controller = new AbortController(); + const timeoutId = setTimeout(async () => { setCitiesLoading(true); + try { const qs = new URLSearchParams({ q: query, @@ -492,16 +564,20 @@ export default function CartPage({ ...(country ? { country } : {}), }); - const response = await fetch(`/api/shop/shipping/np/cities?${qs}`, { - method: 'GET', - headers: { Accept: 'application/json' }, - cache: 'no-store', - signal: controller.signal, - }); + const response = await fetch( + `/api/shop/shipping/np/cities?${qs.toString()}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + } + ); const data = await response.json().catch(() => null); + const parsed = parseShippingCitiesResponse(data); - if (!response.ok || !data || data.available === false) { + if (!response.ok || parsed.available === false) { if (!cancelled) { setCityOptions([]); } @@ -509,10 +585,21 @@ export default function CartPage({ } if (!cancelled) { - const next = Array.isArray(data.items) - ? (data.items as ShippingCity[]) - : []; - setCityOptions(next); + const next = parsed.items; + const normalizedQuery = normalizeLookupValue(query); + + const exactMatches = next.filter( + city => normalizeLookupValue(city.nameUa) === normalizedQuery + ); + + if (exactMatches.length === 1) { + const exactCity = exactMatches[0]!; + setSelectedCityRef(exactCity.ref); + setSelectedCityName(exactCity.nameUa); + setCityOptions([]); + } else { + setCityOptions(next); + } } } catch { if (!cancelled) { @@ -788,7 +875,9 @@ export default function CartPage({ selectedPaymentMethod === 'monobank_google_pay' && !canUseMonobankGooglePay ) { - setCheckoutError(t('checkout.paymentMethod.monobankGooglePayUnavailable')); + setCheckoutError( + t('checkout.paymentMethod.monobankGooglePayUnavailable') + ); return; } if (shippingMethodsLoading) { @@ -927,7 +1016,10 @@ export default function CartPage({ window.location.assign(monobankPageUrl); return; } - if (paymentProvider === 'monobank' && checkoutPaymentMethod === 'monobank_google_pay') { + if ( + paymentProvider === 'monobank' && + checkoutPaymentMethod === 'monobank_google_pay' + ) { if (!statusToken) { setCheckoutError(t('checkout.errors.unexpectedResponse')); return; @@ -1301,9 +1393,10 @@ export default function CartPage({ name="delivery-method" value={method.methodCode} checked={selectedShippingMethod === method.methodCode} - onChange={() => - setSelectedShippingMethod(method.methodCode) - } + onChange={() => { + clearCheckoutUiErrors(); + setSelectedShippingMethod(method.methodCode); + }} className="h-4 w-4" /> @@ -1324,7 +1417,10 @@ export default function CartPage({ id="shipping-city-search" type="text" value={cityQuery} + autoComplete="off" + spellCheck={false} onChange={event => { + clearCheckoutUiErrors(); setCityQuery(event.target.value); setSelectedCityRef(null); setSelectedCityName(null); @@ -1354,9 +1450,11 @@ export default function CartPage({ key={city.ref} type="button" onClick={() => { + clearCheckoutUiErrors(); setSelectedCityRef(city.ref); setSelectedCityName(city.nameUa); setCityQuery(city.nameUa); + setCityOptions([]); }} className="hover:bg-secondary block w-full rounded px-2 py-1 text-left text-xs" > @@ -1365,6 +1463,18 @@ export default function CartPage({ ))} ) : null} + + {!citiesLoading && + cityQuery.trim().length >= 2 && + !selectedCityRef && + cityOptions.length === 0 ? ( +

    + {safeT( + 'delivery.city.noResults', + 'Міста не знайдено. Перевірте назву або локальні дані Nova Poshta' + )} +

    + ) : null} {isWarehouseSelectionMethod ? ( @@ -1387,15 +1497,35 @@ export default function CartPage({ type="text" value={warehouseQuery} onChange={event => { + clearCheckoutUiErrors(); setWarehouseQuery(event.target.value); setSelectedWarehouseRef(null); setSelectedWarehouseName(null); }} - placeholder={t('delivery.warehouse.placeholder')} + placeholder={ + selectedCityRef + ? t('delivery.warehouse.placeholder') + : safeT( + 'delivery.warehouse.selectCityFirst', + 'Спочатку оберіть місто' + ) + } className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" disabled={!selectedCityRef} /> + {!selectedCityRef ? ( +

    + {safeT( + 'delivery.warehouse.cityRequired', + 'Щоб вибрати відділення, спочатку оберіть місто зі списку' + )} +

    + ) : null} + {selectedWarehouseRef ? (

    {t('delivery.warehouse.selected', { @@ -1418,6 +1548,7 @@ export default function CartPage({ key={warehouse.ref} type="button" onClick={() => { + clearCheckoutUiErrors(); setSelectedWarehouseRef(warehouse.ref); setSelectedWarehouseName(warehouse.name); setWarehouseQuery( @@ -1449,9 +1580,10 @@ export default function CartPage({ id="shipping-address-1" type="text" value={courierAddressLine1} - onChange={event => - setCourierAddressLine1(event.target.value) - } + onChange={event => { + clearCheckoutUiErrors(); + setCourierAddressLine1(event.target.value); + }} placeholder={t( 'delivery.courierAddress.line1Placeholder' )} @@ -1460,9 +1592,10 @@ export default function CartPage({ - setCourierAddressLine2(event.target.value) - } + onChange={event => { + clearCheckoutUiErrors(); + setCourierAddressLine2(event.target.value); + }} placeholder={t( 'delivery.courierAddress.line2Placeholder' )} @@ -1482,7 +1615,10 @@ export default function CartPage({ id="recipient-name" type="text" value={recipientName} - onChange={event => setRecipientName(event.target.value)} + onChange={event => { + clearCheckoutUiErrors(); + setRecipientName(event.target.value); + }} placeholder={t('delivery.recipientName.placeholder')} className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" /> @@ -1499,7 +1635,10 @@ export default function CartPage({ id="recipient-phone" type="tel" value={recipientPhone} - onChange={event => setRecipientPhone(event.target.value)} + onChange={event => { + clearCheckoutUiErrors(); + setRecipientPhone(event.target.value); + }} placeholder={t('delivery.recipientPhone.placeholder')} className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" /> @@ -1516,7 +1655,10 @@ export default function CartPage({ id="recipient-email" type="email" value={recipientEmail} - onChange={event => setRecipientEmail(event.target.value)} + onChange={event => { + clearCheckoutUiErrors(); + setRecipientEmail(event.target.value); + }} placeholder={t('delivery.recipientEmail.placeholder')} className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" /> @@ -1532,7 +1674,10 @@ export default function CartPage({