From 051f6582796e8e711d16d67bb69df94c4db65d52 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Tue, 3 Mar 2026 22:40:07 +0200 Subject: [PATCH 01/12] fix(blog): resolve 500 error on blog post pages --- .../app/[locale]/blog/[slug]/PostDetails.tsx | 3 +- frontend/app/[locale]/blog/[slug]/page.tsx | 16 +- 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 +- 8 files changed, 7027 insertions(+), 16 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..3c604cd8 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'; @@ -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..ac7d6ab2 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,7 +16,7 @@ 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, title) }`, { slug, locale } ); @@ -41,5 +32,6 @@ export default async function Page({ params: Promise<{ slug: string; locale: string }>; }) { const { slug, locale } = await params; + setRequestLocale(locale); return ; } 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 68c8cf4c07d4d87e82741a08bbec96424ed404bd Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Tue, 3 Mar 2026 23:00:07 +0200 Subject: [PATCH 02/12] fix(blog): remove unsafe GROQ coalesce fallbacks and revert CDN --- .../app/[locale]/blog/[slug]/PostDetails.tsx | 20 +++++++++---------- frontend/app/[locale]/blog/[slug]/page.tsx | 7 +++---- .../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 +- 6 files changed, 26 insertions(+), 27 deletions(-) diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index 3c604cd8..fdd39a92 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -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" => { ..., diff --git a/frontend/app/[locale]/blog/[slug]/page.tsx b/frontend/app/[locale]/blog/[slug]/page.tsx index ac7d6ab2..dac1e0d5 100644 --- a/frontend/app/[locale]/blog/[slug]/page.tsx +++ b/frontend/app/[locale]/blog/[slug]/page.tsx @@ -16,14 +16,13 @@ export async function generateMetadata({ 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, 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({ 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', }); From 11f4f882cd20a5041f4d213db35032812242884d Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Wed, 4 Mar 2026 10:18:42 +0000 Subject: [PATCH 03/12] feat(blog): add DB schema indexes + Sanity => PostgreSQL migration script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 2983786b0a617aeba67046510bd19f90e7d14382 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Wed, 4 Mar 2026 22:50:50 +0000 Subject: [PATCH 04/12] 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. --- .../app/[locale]/blog/[slug]/PostDetails.tsx | 449 ++---------------- 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 | 17 +- frontend/app/api/blog-author/route.ts | 22 +- frontend/app/api/blog-search/route.ts | 29 +- frontend/components/blog/BlogCard.tsx | 22 +- .../components/blog/BlogCategoryLinks.tsx | 14 +- frontend/components/blog/BlogFilters.tsx | 75 +-- frontend/components/blog/BlogGrid.tsx | 2 +- frontend/components/blog/BlogHeaderSearch.tsx | 31 +- frontend/components/blog/BlogNavLinks.tsx | 94 ---- frontend/components/blog/BlogPostRenderer.tsx | 182 +++++++ 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/db/queries/blog/blog-authors.ts | 59 +++ frontend/db/queries/blog/blog-categories.ts | 36 ++ frontend/db/queries/blog/blog-posts.ts | 273 +++++++++++ frontend/lib/blog/image.ts | 23 +- frontend/lib/blog/text.ts | 9 + 25 files changed, 705 insertions(+), 839 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/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index fdd39a92..51e6ae4c 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -1,303 +1,13 @@ -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 +28,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 +51,33 @@ 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) + 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, '-')}` + 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 +85,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 +185,13 @@ export default async function PostDetails({
    - {categoryLabel && ( + {category && (
    - {categoryDisplay || categoryLabel} + {categoryDisplay || category.title}
    )} @@ -566,15 +219,13 @@ export default async function PostDetails({ )}
    - {(post.tags?.length || 0) > 0 && null} - {post.mainImage && (
    {post.title
    @@ -582,7 +233,7 @@ export default async function PostDetails({
    - {renderPortableText(post.body || [], post.title)} +
    @@ -601,13 +252,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 +267,7 @@ export default async function PostDetails({ src={item.mainImage} alt={item.title || 'Post image'} fill - unoptimized={shouldBypassImageOptimization( - item.mainImage - )} + unoptimized className="object-cover transition-transform duration-300 group-hover:scale-[1.03]" /> @@ -626,9 +275,9 @@ export default async function PostDetails({

    {item.title}

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

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

    )} {(item.author?.name || @@ -641,9 +290,7 @@ export default async function PostDetails({ src={item.author.image} alt={item.author.name || 'Author'} fill - unoptimized={shouldBypassImageOptimization( - item.author.image - )} + unoptimized className="object-cover" /> @@ -676,21 +323,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..b19eae6a 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 - ); + + const [categories, posts] = await Promise.all([ + getBlogCategories(locale), + getBlogPostsByCategory(category, locale), + ]); + const matchedCategory = categories.find(c => c.slug === category); if (!matchedCategory) return notFound(); - const categoryTitle = matchedCategory.title; - const categoryDisplay = getCategoryLabel(categoryTitle, t); - - 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 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..7ea7a038 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -1,32 +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 { getBlogCategories } 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, @@ -41,7 +28,7 @@ export default async function LocaleLayout({ const [messages, blogCategories] = await Promise.all([ getMessages({ locale }), - getCachedBlogCategories(), + getBlogCategories(locale), ]); const enableAdmin = diff --git a/frontend/app/api/blog-author/route.ts b/frontend/app/api/blog-author/route.ts index a9522040..f337403f 100644 --- a/frontend/app/api/blog-author/route.ts +++ b/frontend/app/api/blog-author/route.ts @@ -1,27 +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); @@ -32,7 +14,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..8d576fa2 100644 --- a/frontend/components/blog/BlogCard.tsx +++ b/frontend/components/blog/BlogCard.tsx @@ -1,20 +1,20 @@ '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'; + export default function BlogCard({ post, onAuthorSelect, @@ -43,27 +43,21 @@ 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..7f9c0d63 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,13 @@ export default function BlogFilters({ } if (resolvedCategory) { - const postCategories = (post.categories || []).map(normalizeTag); + const postCategories = (post.categories || []).map(c => normalizeTag(c.title)); 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 +420,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; @@ -485,7 +452,7 @@ export default function BlogFilters({
    {featuredPost.mainImage && (
    @@ -507,15 +474,15 @@ export default function BlogFilters({ {featuredPost.categories?.[0] && (
    {getCategoryLabel( - featuredPost.categories[0] === 'Growth' + featuredPost.categories[0]?.title === 'Growth' ? 'Career' - : featuredPost.categories[0] + : featuredPost.categories[0]?.title )}
    )}
    {featuredPost.title} @@ -533,7 +500,7 @@ export default function BlogFilters({ {formatBlogDate(featuredPost.publishedAt)} {t('readMore')} @@ -609,7 +576,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 => ( +
    + ) : ( + + )} + + {error &&

    {error}

    } +
    + ); +} diff --git a/frontend/components/admin/blog/BlogPostForm.tsx b/frontend/components/admin/blog/BlogPostForm.tsx new file mode 100644 index 00000000..c05081c5 --- /dev/null +++ b/frontend/components/admin/blog/BlogPostForm.tsx @@ -0,0 +1,518 @@ +'use client'; + +import type { JSONContent } from '@tiptap/core'; +import { useRef,useState } from 'react'; + +import type { + AdminBlogAuthorOption, + AdminBlogCategoryOption, + AdminBlogPostFull, +} from '@/db/queries/blog/admin-blog'; +import { useRouter } from '@/i18n/routing'; +import { slugify } from '@/lib/shop/slug'; + +import { type AdminLocale, LocaleTabs } from '../quiz/LocaleTabs'; +import { BlogImageUpload } from './BlogImageUpload'; +import { BlogPublishControls, type PublishMode } from './BlogPublishControls'; +import { BlogTiptapEditor } from './BlogTiptapEditor'; +import { InlineBlogAuthorForm } from './InlineBlogAuthorForm'; +import { InlineBlogCategoryForm } from './InlineBlogCategoryForm'; + +const LOCALES: AdminLocale[] = ['en', 'uk', 'pl']; + +interface BlogTranslation { + title: string; + body: JSONContent | null; +} + +const emptyTranslations = (): Record => ({ + en: { title: '', body: null }, + uk: { title: '', body: null }, + pl: { title: '', body: null }, +}); + +interface BlogPostFormProps { + authors: AdminBlogAuthorOption[]; + categories: AdminBlogCategoryOption[]; + csrfTokenPost: string; + csrfTokenCategory: string; + csrfTokenAuthor: string; + csrfTokenImage: string; + postId?: string; + initialData?: AdminBlogPostFull; +} + +export function BlogPostForm({ + authors: initialAuthors, + categories: initialCategories, + csrfTokenPost, + csrfTokenCategory, + csrfTokenAuthor, + csrfTokenImage, + postId, + initialData, +}: BlogPostFormProps) { + const router = useRouter(); + const isEditMode = !!initialData; + + const [activeLocale, setActiveLocale] = useState('en'); + const [translations, setTranslations] = useState(() => { + if (!initialData) return emptyTranslations(); + const t = initialData.translations; + return { + en: { title: t.en?.title ?? '', body: (t.en?.body as JSONContent) ?? null }, + uk: { title: t.uk?.title ?? '', body: (t.uk?.body as JSONContent) ?? null }, + pl: { title: t.pl?.title ?? '', body: (t.pl?.body as JSONContent) ?? null }, + }; + }); + + const [slug, setSlug] = useState(initialData?.slug ?? ''); + const [slugTouched, setSlugTouched] = useState(!!initialData); + + const [authors, setAuthors] = useState(initialAuthors); + const [authorId, setAuthorId] = useState(initialData?.authorId ?? ''); + const [showNewAuthor, setShowNewAuthor] = useState(false); + + const [categories, setCategories] = useState(initialCategories); + const [selectedCategoryIds, setSelectedCategoryIds] = useState( + initialData?.categoryIds ?? [] + ); + const [showNewCategory, setShowNewCategory] = useState(false); + + const [mainImage, setMainImage] = useState<{ + url: string; + publicId: string; + } | null>( + initialData?.mainImageUrl + ? { url: initialData.mainImageUrl, publicId: initialData.mainImagePublicId ?? '' } + : null + ); + + const [tagsInput, setTagsInput] = useState(initialData?.tags?.join(', ') ?? ''); + const [resourceLink, setResourceLink] = useState(initialData?.resourceLink ?? ''); + + const [publishMode, setPublishMode] = useState(() => { + if (!initialData || !initialData.isPublished) return 'draft'; + if (initialData.scheduledPublishAt) return 'schedule'; + return 'publish'; + }); + const [scheduledDate, setScheduledDate] = useState(() => { + if (!initialData?.scheduledPublishAt) return ''; + return new Date(initialData.scheduledPublishAt).toISOString().slice(0, 16); + }); + + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Dirty tracking (edit mode only) + const initialSnapshot = useRef(() => { + if (!initialData) return null; + return JSON.stringify({ + translations: Object.fromEntries( + LOCALES.map(l => [ + l, + { + title: initialData.translations[l]?.title ?? '', + body: initialData.translations[l]?.body ?? null, + }, + ]) + ), + slug: initialData.slug, + authorId: initialData.authorId ?? '', + categoryIds: [...(initialData.categoryIds ?? [])].sort(), + mainImageUrl: initialData.mainImageUrl ?? null, + tagsInput: initialData.tags?.join(', ') ?? '', + resourceLink: initialData.resourceLink ?? '', + publishMode: !initialData.isPublished + ? 'draft' + : initialData.scheduledPublishAt + ? 'schedule' + : 'publish', + scheduledDate: initialData.scheduledPublishAt + ? new Date(initialData.scheduledPublishAt).toISOString().slice(0, 16) + : '', + }); + }); + + function isDirty(): boolean { + if (!isEditMode) return true; + const snap = initialSnapshot.current(); + if (!snap) return true; + const current = JSON.stringify({ + translations: Object.fromEntries( + LOCALES.map(l => [ + l, + { + title: translations[l].title, + body: translations[l].body, + }, + ]) + ), + slug, + authorId, + categoryIds: [...selectedCategoryIds].sort(), + mainImageUrl: mainImage?.url ?? null, + tagsInput, + resourceLink, + publishMode, + scheduledDate, + }); + return current !== snap; + } + + function handleTitleChange(value: string) { + setTranslations(prev => ({ + ...prev, + [activeLocale]: { ...prev[activeLocale], title: value }, + })); + + if (activeLocale === 'en' && !slugTouched) { + setSlug(slugify(value)); + } + } + + function handleBodyChange(locale: AdminLocale, json: JSONContent) { + setTranslations(prev => ({ + ...prev, + [locale]: { ...prev[locale], body: json }, + })); + } + + function handleCategoryToggle(categoryId: string) { + setSelectedCategoryIds(prev => + prev.includes(categoryId) + ? prev.filter(id => id !== categoryId) + : [...prev, categoryId] + ); + } + + function handleCategoryCreated(cat: { + id: string; + slug: string; + title: string; + }) { + setCategories(prev => [...prev, cat]); + setSelectedCategoryIds(prev => [...prev, cat.id]); + setShowNewCategory(false); + } + + function handleAuthorCreated(author: { id: string; name: string }) { + setAuthors(prev => [...prev, author]); + setAuthorId(author.id); + setShowNewAuthor(false); + } + + function getMissingTitles() { + return LOCALES.filter(l => !translations[l].title.trim()); +} + + function getMissingBodies() { + return LOCALES.filter(l => { + const body = translations[l].body; + if (!body) return true; + const content = body.content; + if (!content || content.length === 0) return true; + if ( + content.length === 1 && + content[0].type === 'paragraph' && + (!content[0].content || content[0].content.length === 0) + ) + return true; + return false; + }); +} + + function isFormValid() { + if (getMissingTitles().length > 0) return false; + if (getMissingBodies().length > 0) return false; + if (!slug.trim()) return false; + if (publishMode === 'schedule' && !scheduledDate) return false; + return true; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + + const missingTitles = getMissingTitles(); + if (missingTitles.length > 0) { + setError( + `Title required for: ${missingTitles.map(l => l.toUpperCase()).join(', ')}` + ); + return; + } + + const missingBodies = getMissingBodies(); + if (missingBodies.length > 0) { + setError( + `Body required for: ${missingBodies.map(l => l.toUpperCase()).join(', ')}` + ); + return; + } + + if (!slug.trim()) { + setError('Slug is required'); + return; + } + if (publishMode === 'schedule' && !scheduledDate) { + setError('Scheduled date is required'); + return; + } + + setSubmitting(true); + try { + const tags = tagsInput + .split(',') + .map(t => t.trim()) + .filter(Boolean); + + const body = { + slug: slug.trim(), + authorId: authorId || null, + mainImageUrl: mainImage?.url ?? null, + mainImagePublicId: mainImage?.publicId ?? null, + tags, + resourceLink: resourceLink.trim() || null, + translations: { + en: { + title: translations.en.title.trim(), + body: translations.en.body, + }, + uk: { + title: translations.uk.title.trim(), + body: translations.uk.body, + }, + pl: { + title: translations.pl.title.trim(), + body: translations.pl.body, + }, + }, + categoryIds: selectedCategoryIds, + publishMode, + scheduledPublishAt: + publishMode === 'schedule' ? scheduledDate : null, + }; + + const url = isEditMode ? `/api/admin/blog/${postId}` : '/api/admin/blog'; + const method = isEditMode ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfTokenPost, + }, + body: JSON.stringify(body), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error ?? (isEditMode ? 'Failed to update post' : 'Failed to create post')); + return; + } + + router.push('/admin/blog'); + } catch { + setError('Network error'); + } finally { + setSubmitting(false); + } + } + + return ( +
    + {/* Title + Locale tabs */} +
    +
    + + +
    + handleTitleChange(e.target.value)} + placeholder={`Post title (${activeLocale.toUpperCase()})`} + className="border-border bg-background text-foreground w-full rounded-md border px-3 py-2 text-sm" + /> +
    + + {/* Slug */} +
    + + { + setSlug(e.target.value); + setSlugTouched(true); + }} + placeholder="post-slug" + className="border-border bg-background text-foreground w-full max-w-sm rounded-md border px-3 py-2 text-sm" + /> +

    + Auto-generated from EN title. Edit manually if needed. +

    +
    + + {/* Author */} +
    + +
    + + +
    + + {showNewAuthor && ( + setShowNewAuthor(false)} + /> + )} +
    + + {/* Categories */} +
    + +
    + {categories.map(cat => ( + + ))} + +
    + + {showNewCategory && ( + setShowNewCategory(false)} + /> + )} +
    + + {/* Main image */} +
    + + +
    + + {/* Body editor (per locale, only active visible) */} +
    + + {LOCALES.map(locale => ( +
    + handleBodyChange(locale, json)} + csrfToken={csrfTokenImage} + /> +
    + ))} +
    + + {/* Tags */} +
    + + setTagsInput(e.target.value)} + placeholder="react, nextjs, typescript (comma-separated)" + className="border-border bg-background text-foreground w-full rounded-md border px-3 py-2 text-sm" + /> +
    + + {/* Resource link */} +
    + + setResourceLink(e.target.value)} + placeholder="https://..." + className="border-border bg-background text-foreground w-full rounded-md border px-3 py-2 text-sm" + /> +
    + + + + {error &&

    {error}

    } + + {/* Submit */} + + + ); +} diff --git a/frontend/components/admin/blog/BlogPostListTable.tsx b/frontend/components/admin/blog/BlogPostListTable.tsx new file mode 100644 index 00000000..7f3cdcee --- /dev/null +++ b/frontend/components/admin/blog/BlogPostListTable.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import type { AdminBlogListItem } from '@/db/queries/blog/admin-blog'; +import { Link } from '@/i18n/routing'; +import { cn } from '@/lib/utils'; + +const TH = + 'px-3 py-2 text-left text-xs font-semibold text-foreground whitespace-nowrap'; +const TD = 'px-3 py-2 text-sm'; + +type BlogStatus = 'draft' | 'published' | 'scheduled'; + +function getStatus(post: AdminBlogListItem): BlogStatus { + if (!post.isPublished) return 'draft'; + if (post.scheduledPublishAt && new Date(post.scheduledPublishAt) > new Date()) + return 'scheduled'; + return 'published'; +} + +function StatusBadge({ status }: { status: BlogStatus }) { + return ( + + {status === 'draft' && 'Draft'} + {status === 'published' && 'Published'} + {status === 'scheduled' && 'Scheduled'} + + ); +} + +function formatDate(date: Date | null): string { + if (!date) return '-'; + return new Date(date).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + +interface BlogPostListTableProps { + posts: AdminBlogListItem[]; + csrfTokenDelete: string; + csrfTokenPublish: string; +} + +export function BlogPostListTable({ + posts, + csrfTokenDelete, + csrfTokenPublish, +}: BlogPostListTableProps) { + const router = useRouter(); + const [deletingId, setDeletingId] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + const canDelete = (post: AdminBlogListItem) => !post.isPublished; + + async function handleDelete(postId: string) { + if (!confirm('Delete this draft post?')) return; + + setDeletingId(postId); + try { + const res = await fetch(`/api/admin/blog/${postId}`, { + method: 'DELETE', + headers: { 'x-csrf-token': csrfTokenDelete }, + }); + if (res.ok) { + router.refresh(); + } + } finally { + setDeletingId(null); + } + } + + async function handleTogglePublish(postId: string, currentlyPublished: boolean) { + const action = currentlyPublished ? 'Unpublish' : 'Publish'; + if (!confirm(`${action} this post?`)) return; + + setTogglingId(postId); + try { + const res = await fetch(`/api/admin/blog/${postId}`, { + method: 'PATCH', + headers: { 'x-csrf-token': csrfTokenPublish }, + }); + if (res.ok) { + router.refresh(); + } + } finally { + setTogglingId(null); + } + } + + + if (posts.length === 0) { + return ( +
    + No blog posts found +
    + ); + } + + return ( + <> + {/* Mobile cards */} +
    + {posts.map(post => { + const status = getStatus(post); + return ( +
    +
    +
    +
    + {post.title} +
    +
    + {post.authorName ?? '-'} +
    +
    + +
    + +
    +
    +
    Published
    +
    + {formatDate(post.publishedAt)} +
    +
    +
    +
    Updated
    +
    + {formatDate(post.updatedAt)} +
    +
    +
    + +
    + + + Preview + + + Edit + + {canDelete(post) && ( + + )} +
    +
    + ); + })} +
    + + {/* Desktop table */} +
    + + + + + + + + + + + + + {posts.map(post => { + const status = getStatus(post); + return ( + + + + + + + + + ); + })} + +
    TitleAuthorStatusPublishedUpdatedActions
    +
    + {post.title} +
    +
    + {post.authorName ?? '-'} + + + + {formatDate(post.publishedAt)} + + {formatDate(post.updatedAt)} + +
    + + + Preview + + + Edit + + +
    +
    +
    + + ); +} diff --git a/frontend/components/admin/blog/BlogPublishControls.tsx b/frontend/components/admin/blog/BlogPublishControls.tsx new file mode 100644 index 00000000..005e0547 --- /dev/null +++ b/frontend/components/admin/blog/BlogPublishControls.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { cn } from '@/lib/utils'; + +export type PublishMode = 'draft' | 'publish' | 'schedule'; + +interface BlogPublishControlsProps { + mode: PublishMode; + scheduledDate: string; + onModeChange: (mode: PublishMode) => void; + onScheduledDateChange: (date: string) => void; + currentStatus?: 'draft' | 'published' | 'scheduled'; +} + +const STATUS_BADGE: Record = { + draft: { + label: 'Draft', + className: 'bg-amber-500/10 text-amber-500', + }, + published: { + label: 'Published', + className: 'bg-emerald-500/10 text-emerald-500', + }, + scheduled: { + label: 'Scheduled', + className: 'bg-sky-500/10 text-sky-500', + }, +}; + +function RadioOption({ + value, + label, + description, + checked, + onChange, +}: { + value: PublishMode; + label: string; + description: string; + checked: boolean; + onChange: (value: PublishMode) => void; +}) { + return ( + + ); +} + +export function BlogPublishControls({ + mode, + scheduledDate, + onModeChange, + onScheduledDateChange, + currentStatus, +}: BlogPublishControlsProps) { + return ( +
    +
    + + Publish status + + {currentStatus && ( + + Currently: {STATUS_BADGE[currentStatus].label} + + )} +
    + +
    + + + +
    + + {mode === 'schedule' && ( + onScheduledDateChange(e.target.value)} + className="border-border bg-background text-foreground w-full rounded-md border px-3 py-2 text-sm" + /> + )} +
    + ); +} diff --git a/frontend/components/admin/blog/BlogTiptapEditor.tsx b/frontend/components/admin/blog/BlogTiptapEditor.tsx new file mode 100644 index 00000000..c4d96ee3 --- /dev/null +++ b/frontend/components/admin/blog/BlogTiptapEditor.tsx @@ -0,0 +1,286 @@ +'use client'; + +import 'highlight.js/styles/github-dark.css'; + +import type { JSONContent } from '@tiptap/core'; +import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; +import Image from '@tiptap/extension-image'; +import Link from '@tiptap/extension-link'; +import TaskItem from '@tiptap/extension-task-item'; +import TaskList from '@tiptap/extension-task-list'; +import { EditorContent, useEditor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { common, createLowlight } from 'lowlight'; +import { useRef, useState } from 'react'; + +import { cn } from '@/lib/utils'; + +const lowlight = createLowlight(common); + +const EXTENSIONS = [ + StarterKit.configure({ + codeBlock: false, + }), + CodeBlockLowlight.configure({ lowlight }), + Image.configure({ inline: false }), + Link.configure({ openOnClick: false, autolink: true }), + TaskList, + TaskItem.configure({ nested: true }), +]; + +interface BlogTiptapEditorProps { + content: JSONContent | null; + onChange: (json: JSONContent) => void; + csrfToken: string; +} + +function ToolbarButton({ + active, + disabled, + onClick, + children, + title, +}: { + active?: boolean; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; + title: string; +}) { + return ( + + ); +} + +function ToolbarDivider() { + return ; +} + +export function BlogTiptapEditor({ + content, + onChange, + csrfToken, +}: BlogTiptapEditorProps) { + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + const editor = useEditor({ + extensions: EXTENSIONS, + content: content ?? { type: 'doc', content: [{ type: 'paragraph' }] }, + immediatelyRender: false, + onUpdate: ({ editor }) => { + onChange(editor.getJSON()); + }, + }); + + function handleLinkClick() { + if (!editor) return; + + if (editor.isActive('link')) { + editor.chain().focus().unsetLink().run(); + return; + } + + const url = window.prompt('URL:'); + if (!url) return; + + editor.chain().focus().setLink({ href: url }).run(); + } + + async function handleImageUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file || !editor) return; + + setUploading(true); + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('csrf_token', csrfToken); + + const res = await fetch('/api/admin/blog/images', { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + body: formData, + }); + + const data = await res.json(); + if (!res.ok) return; + + editor.chain().focus().setImage({ src: data.url }).run(); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + if (!editor) return null; + + return ( +
    +
    + {/* Text formatting */} + editor.chain().focus().toggleBold().run()} + title="Bold" + > + B + + editor.chain().focus().toggleItalic().run()} + title="Italic" + > + I + + editor.chain().focus().toggleStrike().run()} + title="Strikethrough" + > + S + + editor.chain().focus().toggleCode().run()} + title="Inline code" + > + {''} + + + + + {/* Structure */} + + editor.chain().focus().toggleHeading({ level: 2 }).run() + } + title="Heading 2" + > + H2 + + + editor.chain().focus().toggleHeading({ level: 3 }).run() + } + title="Heading 3" + > + H3 + + editor.chain().focus().toggleBlockquote().run()} + title="Blockquote" + > + “” + + editor.chain().focus().setHorizontalRule().run()} + title="Horizontal rule" + > + ― + + + + + {/* Lists */} + editor.chain().focus().toggleBulletList().run()} + title="Bullet list" + > + • List + + editor.chain().focus().toggleOrderedList().run()} + title="Numbered list" + > + 1. List + + editor.chain().focus().toggleTaskList().run()} + title="Checklist" + > + ✓ List + + editor.chain().focus().toggleCodeBlock().run()} + title="Code block" + > + Code + + + + + {/* Media */} + + Link + + fileInputRef.current?.click()} + title="Insert image" + > + {uploading ? 'Uploading...' : 'Image'} + + + +
    + + +
    + ); +} diff --git a/frontend/components/admin/blog/InlineBlogAuthorForm.tsx b/frontend/components/admin/blog/InlineBlogAuthorForm.tsx new file mode 100644 index 00000000..84f1b9e3 --- /dev/null +++ b/frontend/components/admin/blog/InlineBlogAuthorForm.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useState } from 'react'; + +import { slugify } from '@/lib/shop/slug'; + +interface InlineBlogAuthorFormProps { + csrfToken: string; + onCreated: (author: { id: string; name: string }) => void; + onCancel: () => void; +} + +export function InlineBlogAuthorForm({ + csrfToken, + onCreated, + onCancel, +}: InlineBlogAuthorFormProps) { + const [slug, setSlug] = useState(''); + const [slugTouched, setSlugTouched] = useState(false); + const [names, setNames] = useState({ en: '', uk: '', pl: '' }); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + function handleNameChange(locale: 'en' | 'uk' | 'pl', value: string) { + setNames(prev => ({ ...prev, [locale]: value })); + if (locale === 'en' && !slugTouched) { + setSlug(slugify(value)); + } + } + + async function handleSubmit() { + setError(''); + + if (!names.en.trim() || !names.uk.trim() || !names.pl.trim()) { + setError('All 3 locale names are required'); + return; + } + if (!slug.trim()) { + setError('Slug is required'); + return; + } + + setSaving(true); + try { + const res = await fetch('/api/admin/blog/authors', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken, + }, + body: JSON.stringify({ + slug, + translations: { + en: { name: names.en.trim() }, + uk: { name: names.uk.trim() }, + pl: { name: names.pl.trim() }, + }, + }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error ?? 'Failed to create author'); + return; + } + + onCreated(data.author); + } catch { + setError('Network error'); + } finally { + setSaving(false); + } + } + + return ( +
    +

    New Author

    + +
    + {(['en', 'uk', 'pl'] as const).map(locale => ( +
    + + handleNameChange(locale, e.target.value)} + className="border-border bg-background text-foreground w-full rounded-md border px-3 py-1.5 text-sm" + placeholder={`Author name (${locale})`} + /> +
    + ))} +
    + +
    + + { + setSlug(e.target.value); + setSlugTouched(true); + }} + className="border-border bg-background text-foreground w-full max-w-xs rounded-md border px-3 py-1.5 text-sm" + placeholder="author-slug" + /> +
    + + {error &&

    {error}

    } + +
    + + +
    +
    + ); +} diff --git a/frontend/components/admin/blog/InlineBlogCategoryForm.tsx b/frontend/components/admin/blog/InlineBlogCategoryForm.tsx new file mode 100644 index 00000000..5a0ec513 --- /dev/null +++ b/frontend/components/admin/blog/InlineBlogCategoryForm.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useState } from 'react'; + +import { slugify } from '@/lib/shop/slug'; + +interface InlineBlogCategoryFormProps { + csrfToken: string; + onCreated: (category: { id: string; slug: string; title: string }) => void; + onCancel: () => void; +} + +export function InlineBlogCategoryForm({ + csrfToken, + onCreated, + onCancel, +}: InlineBlogCategoryFormProps) { + const [slug, setSlug] = useState(''); + const [slugTouched, setSlugTouched] = useState(false); + const [titles, setTitles] = useState({ en: '', uk: '', pl: '' }); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + function handleTitleChange(locale: 'en' | 'uk' | 'pl', value: string) { + setTitles(prev => ({ ...prev, [locale]: value })); + if (locale === 'en' && !slugTouched) { + setSlug(slugify(value)); + } + } + + async function handleSubmit() { + setError(''); + + if (!titles.en.trim() || !titles.uk.trim() || !titles.pl.trim()) { + setError('All 3 locale titles are required'); + return; + } + if (!slug.trim()) { + setError('Slug is required'); + return; + } + + setSaving(true); + try { + const res = await fetch('/api/admin/blog/categories', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken, + }, + body: JSON.stringify({ + slug, + translations: { + en: { title: titles.en.trim() }, + uk: { title: titles.uk.trim() }, + pl: { title: titles.pl.trim() }, + }, + }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error ?? 'Failed to create category'); + return; + } + + onCreated(data.category); + } catch { + setError('Network error'); + } finally { + setSaving(false); + } + } + + return ( +
    +

    New Blog Category

    + +
    + {(['en', 'uk', 'pl'] as const).map(locale => ( +
    + + handleTitleChange(locale, e.target.value)} + className="border-border bg-background text-foreground w-full rounded-md border px-3 py-1.5 text-sm" + placeholder={`Category title (${locale})`} + /> +
    + ))} +
    + +
    + + { + setSlug(e.target.value); + setSlugTouched(true); + }} + className="border-border bg-background text-foreground w-full max-w-xs rounded-md border px-3 py-1.5 text-sm" + placeholder="category-slug" + /> +
    + + {error &&

    {error}

    } + +
    + + +
    +
    + ); +} diff --git a/frontend/components/blog/BlogPostRenderer.tsx b/frontend/components/blog/BlogPostRenderer.tsx index e6976001..326116e3 100644 --- a/frontend/components/blog/BlogPostRenderer.tsx +++ b/frontend/components/blog/BlogPostRenderer.tsx @@ -1,5 +1,7 @@ import Image from 'next/image'; +import { cn } from '@/lib/utils'; + // Tiptap JSON node shape interface TiptapNode { type?: string; @@ -130,6 +132,28 @@ function renderNode(node: TiptapNode, index: number): React.ReactNode { case 'listItem': return
  • {children}
  • ; + case 'taskList': + return ( +
      + {children} +
    + ); + + case 'taskItem': { + const checked = node.attrs?.checked ?? false; + return ( +
  • + + {checked ? '✔' : '○'} + + {children} +
  • + ); + } + case 'codeBlock': return (
     {
    +  const rows = await db
    +    .select({
    +      id: blogPosts.id,
    +      slug: blogPosts.slug,
    +      title: blogPostTranslations.title,
    +      authorName: blogAuthorTranslations.name,
    +      isPublished: blogPosts.isPublished,
    +      publishedAt: blogPosts.publishedAt,
    +      scheduledPublishAt: blogPosts.scheduledPublishAt,
    +      createdAt: blogPosts.createdAt,
    +      updatedAt: blogPosts.updatedAt,
    +    })
    +    .from(blogPosts)
    +    .leftJoin(
    +      blogPostTranslations,
    +      sql`${blogPostTranslations.postId} = ${blogPosts.id} AND ${blogPostTranslations.locale} = ${ADMIN_LOCALE}`
    +    )
    +    .leftJoin(blogAuthors, eq(blogAuthors.id, blogPosts.authorId))
    +    .leftJoin(
    +      blogAuthorTranslations,
    +      sql`${blogAuthorTranslations.authorId} = ${blogAuthors.id} AND ${blogAuthorTranslations.locale} = ${ADMIN_LOCALE}`
    +    )
    +    .orderBy(
    +      sql`${blogPosts.isPublished} ASC`,
    +      sql`${blogPosts.updatedAt} DESC`
    +    );
    +
    +  return rows.map(row => ({
    +    id: row.id,
    +    slug: row.slug,
    +    title: row.title ?? '(untitled)',
    +    authorName: row.authorName,
    +    isPublished: row.isPublished,
    +    publishedAt: row.publishedAt,
    +    scheduledPublishAt: row.scheduledPublishAt,
    +    createdAt: row.createdAt,
    +    updatedAt: row.updatedAt,
    +  }));
    +}
    +
    +// ── Detail (for editing) ────────────────────────────────────────
    +
    +export interface AdminBlogTranslation {
    +  title: string;
    +  body: unknown;
    +}
    +
    +export interface AdminBlogPostFull {
    +  id: string;
    +  slug: string;
    +  authorId: string | null;
    +  mainImageUrl: string | null;
    +  mainImagePublicId: string | null;
    +  tags: string[];
    +  resourceLink: string | null;
    +  isPublished: boolean;
    +  publishedAt: Date | null;
    +  scheduledPublishAt: Date | null;
    +  createdAt: Date;
    +  updatedAt: Date;
    +  translations: Record;
    +  categoryIds: string[];
    +}
    +
    +export async function getAdminBlogPostById(
    +  postId: string
    +): Promise {
    +  const [post] = await db
    +    .select({
    +      id: blogPosts.id,
    +      slug: blogPosts.slug,
    +      authorId: blogPosts.authorId,
    +      mainImageUrl: blogPosts.mainImageUrl,
    +      mainImagePublicId: blogPosts.mainImagePublicId,
    +      tags: blogPosts.tags,
    +      resourceLink: blogPosts.resourceLink,
    +      isPublished: blogPosts.isPublished,
    +      publishedAt: blogPosts.publishedAt,
    +      scheduledPublishAt: blogPosts.scheduledPublishAt,
    +      createdAt: blogPosts.createdAt,
    +      updatedAt: blogPosts.updatedAt,
    +    })
    +    .from(blogPosts)
    +    .where(eq(blogPosts.id, postId))
    +    .limit(1);
    +
    +  if (!post) return null;
    +
    +  const transRows = await db
    +    .select({
    +      locale: blogPostTranslations.locale,
    +      title: blogPostTranslations.title,
    +      body: blogPostTranslations.body,
    +    })
    +    .from(blogPostTranslations)
    +    .where(eq(blogPostTranslations.postId, postId));
    +
    +  const translations: Record = {};
    +  for (const t of transRows) {
    +    translations[t.locale] = { title: t.title, body: t.body };
    +  }
    +
    +  const catRows = await db
    +    .select({ categoryId: blogPostCategories.categoryId })
    +    .from(blogPostCategories)
    +    .where(eq(blogPostCategories.postId, postId));
    +
    +  const categoryIds = catRows.map(r => r.categoryId);
    +
    +  return { ...post, translations, categoryIds };
    +}
    +
    +// ── Create ──────────────────────────────────────────────────────
    +
    +export interface CreateBlogPostInput {
    +  slug: string;
    +  authorId: string | null;
    +  mainImageUrl: string | null;
    +  mainImagePublicId: string | null;
    +  tags: string[];
    +  resourceLink: string | null;
    +  translations: Record;
    +  categoryIds: string[];
    +}
    +
    +export async function createBlogPost(
    +  input: CreateBlogPostInput
    +): Promise {
    +  const [created] = await db
    +    .insert(blogPosts)
    +    .values({
    +      slug: input.slug,
    +      authorId: input.authorId,
    +      mainImageUrl: input.mainImageUrl,
    +      mainImagePublicId: input.mainImagePublicId,
    +      tags: input.tags,
    +      resourceLink: input.resourceLink,
    +      isPublished: false,
    +    })
    +    .returning({ id: blogPosts.id });
    +
    +  const postId = created.id;
    +
    +  try {
    +    for (const [locale, trans] of Object.entries(input.translations)) {
    +      await db
    +        .insert(blogPostTranslations)
    +        .values({
    +          postId,
    +          locale,
    +          title: trans.title,
    +          body: trans.body,
    +        })
    +        .onConflictDoUpdate({
    +          target: [blogPostTranslations.postId, blogPostTranslations.locale],
    +          set: { title: trans.title, body: trans.body },
    +        });
    +    }
    +
    +    if (input.categoryIds.length > 0) {
    +      await db.insert(blogPostCategories).values(
    +        input.categoryIds.map(categoryId => ({
    +          postId,
    +          categoryId,
    +        }))
    +      );
    +    }
    +  } catch (error) {
    +    await db.delete(blogPosts).where(eq(blogPosts.id, postId));
    +    throw error;
    +  }
    +
    +  return postId;
    +}
    +
    +// ── Update ──────────────────────────────────────────────────────
    +
    +export interface UpdateBlogPostInput {
    +  slug?: string;
    +  authorId?: string | null;
    +  mainImageUrl?: string | null;
    +  mainImagePublicId?: string | null;
    +  tags?: string[];
    +  resourceLink?: string | null;
    +  translations?: Record;
    +  categoryIds?: string[];
    +}
    +
    +export async function updateBlogPost(
    +  postId: string,
    +  input: UpdateBlogPostInput
    +): Promise {
    +  const baseUpdate: Record = {};
    +  if (input.slug !== undefined) baseUpdate.slug = input.slug;
    +  if (input.authorId !== undefined) baseUpdate.authorId = input.authorId;
    +  if (input.mainImageUrl !== undefined)
    +    baseUpdate.mainImageUrl = input.mainImageUrl;
    +  if (input.mainImagePublicId !== undefined)
    +    baseUpdate.mainImagePublicId = input.mainImagePublicId;
    +  if (input.tags !== undefined) baseUpdate.tags = input.tags;
    +  if (input.resourceLink !== undefined)
    +    baseUpdate.resourceLink = input.resourceLink;
    +
    +  if (Object.keys(baseUpdate).length > 0) {
    +    baseUpdate.updatedAt = new Date();
    +    await db
    +      .update(blogPosts)
    +      .set(baseUpdate)
    +      .where(eq(blogPosts.id, postId));
    +  }
    +
    +  if (input.translations) {
    +    for (const [locale, trans] of Object.entries(input.translations)) {
    +      await db
    +        .insert(blogPostTranslations)
    +        .values({
    +          postId,
    +          locale,
    +          title: trans.title,
    +          body: trans.body,
    +        })
    +        .onConflictDoUpdate({
    +          target: [blogPostTranslations.postId, blogPostTranslations.locale],
    +          set: { title: trans.title, body: trans.body },
    +        });
    +    }
    +  }
    +
    +  if (input.categoryIds !== undefined) {
    +    await db
    +      .delete(blogPostCategories)
    +      .where(eq(blogPostCategories.postId, postId));
    +
    +    if (input.categoryIds.length > 0) {
    +      await db.insert(blogPostCategories).values(
    +        input.categoryIds.map(categoryId => ({
    +          postId,
    +          categoryId,
    +        }))
    +      );
    +    }
    +  }
    +}
    +
    +// ── Delete ──────────────────────────────────────────────────────
    +
    +export async function deleteBlogPost(postId: string): Promise {
    +  await db.delete(blogPosts).where(eq(blogPosts.id, postId));
    +}
    +
    +// ── Publish toggle ──────────────────────────────────────────────
    +
    +interface PublishOptions {
    +  isPublished: boolean;
    +  scheduledPublishAt?: Date | null;
    +}
    +
    +export async function toggleBlogPostPublish(
    +  postId: string,
    +  opts: PublishOptions
    +): Promise {
    +  const now = new Date();
    +
    +  await db
    +    .update(blogPosts)
    +    .set({
    +      isPublished: opts.isPublished,
    +      publishedAt: opts.isPublished ? now : null,
    +      scheduledPublishAt: opts.scheduledPublishAt ?? null,
    +      updatedAt: now,
    +    })
    +    .where(eq(blogPosts.id, postId));
    +}
    +
    +// ── Dropdown data ───────────────────────────────────────────────
    +
    +export interface AdminBlogAuthorOption {
    +  id: string;
    +  name: string;
    +}
    +
    +export async function getAdminBlogAuthors(): Promise {
    +  const rows = await db
    +    .select({
    +      id: blogAuthors.id,
    +      name: blogAuthorTranslations.name,
    +    })
    +    .from(blogAuthors)
    +    .leftJoin(
    +      blogAuthorTranslations,
    +      sql`${blogAuthorTranslations.authorId} = ${blogAuthors.id} AND ${blogAuthorTranslations.locale} = ${ADMIN_LOCALE}`
    +    )
    +    .orderBy(blogAuthors.displayOrder);
    +
    +  return rows.map(r => ({
    +    id: r.id,
    +    name: r.name ?? '(unnamed)',
    +  }));
    +}
    +
    +export interface AdminBlogCategoryOption {
    +  id: string;
    +  slug: string;
    +  title: string;
    +}
    +
    +export async function getAdminBlogCategories(): Promise<
    +  AdminBlogCategoryOption[]
    +> {
    +  const rows = await db
    +    .select({
    +      id: blogCategories.id,
    +      slug: blogCategories.slug,
    +      title: blogCategoryTranslations.title,
    +    })
    +    .from(blogCategories)
    +    .leftJoin(
    +      blogCategoryTranslations,
    +      sql`${blogCategoryTranslations.categoryId} = ${blogCategories.id} AND ${blogCategoryTranslations.locale} = ${ADMIN_LOCALE}`
    +    )
    +    .orderBy(blogCategories.displayOrder);
    +
    +  return rows.map(r => ({
    +    id: r.id,
    +    slug: r.slug,
    +    title: r.title ?? '(untitled)',
    +  }));
    +}
    +
    +// ── Create category (inline from post form) ─────────────────────
    +
    +export interface CreateBlogCategoryInput {
    +  slug: string;
    +  translations: Record;
    +}
    +
    +export async function createBlogCategory(
    +  input: CreateBlogCategoryInput
    +): Promise<{ id: string; slug: string; title: string }> {
    +  const [maxRow] = await db
    +    .select({ max: sql`COALESCE(MAX(${blogCategories.displayOrder}), -1)` })
    +    .from(blogCategories);
    +
    +  const [created] = await db
    +    .insert(blogCategories)
    +    .values({
    +      slug: input.slug,
    +      displayOrder: (maxRow?.max ?? -1) + 1,
    +    })
    +    .returning({ id: blogCategories.id });
    +
    +  const categoryId = created.id;
    +
    +  try {
    +    for (const [locale, trans] of Object.entries(input.translations)) {
    +      await db.insert(blogCategoryTranslations).values({
    +        categoryId,
    +        locale,
    +        title: trans.title,
    +      });
    +    }
    +  } catch (error) {
    +    await db.delete(blogCategories).where(eq(blogCategories.id, categoryId));
    +    throw error;
    +  }
    +
    +  return {
    +    id: categoryId,
    +    slug: input.slug,
    +    title: input.translations[ADMIN_LOCALE]?.title ?? input.slug,
    +  };
    +}
    +
    +// ── Create author (inline from post form) ────────────────────────
    +
    +export interface CreateBlogAuthorInput {
    +  slug: string;
    +  translations: Record;
    +}
    +
    +export async function createBlogAuthor(
    +  input: CreateBlogAuthorInput
    +): Promise<{ id: string; name: string }> {
    +  const [maxRow] = await db
    +    .select({ max: sql`COALESCE(MAX(${blogAuthors.displayOrder}), -1)` })
    +    .from(blogAuthors);
    +
    +  const [created] = await db
    +    .insert(blogAuthors)
    +    .values({
    +      slug: input.slug,
    +      displayOrder: (maxRow?.max ?? -1) + 1,
    +    })
    +    .returning({ id: blogAuthors.id });
    +
    +  const authorId = created.id;
    +
    +  try {
    +    for (const [locale, trans] of Object.entries(input.translations)) {
    +      await db.insert(blogAuthorTranslations).values({
    +        authorId,
    +        locale,
    +        name: trans.name,
    +      });
    +    }
    +  } catch (error) {
    +    await db.delete(blogAuthors).where(eq(blogAuthors.id, authorId));
    +    throw error;
    +  }
    +
    +  return {
    +    id: authorId,
    +    name: input.translations[ADMIN_LOCALE]?.name ?? input.slug,
    +  };
    +}
    diff --git a/frontend/lib/validation/admin-blog.ts b/frontend/lib/validation/admin-blog.ts
    new file mode 100644
    index 00000000..a6362b2c
    --- /dev/null
    +++ b/frontend/lib/validation/admin-blog.ts
    @@ -0,0 +1,61 @@
    +import { z } from 'zod';
    +
    +const blogTranslationSchema = z.object({
    +  title: z.string().min(1, 'Title is required'),
    +  body: z.unknown().default(null),
    +});
    +
    +export const createBlogPostSchema = z
    +  .object({
    +    slug: z.string().min(1, 'Slug is required'),
    +    authorId: z.string().uuid().nullable(),
    +    mainImageUrl: z.string().nullable(),
    +    mainImagePublicId: z.string().nullable(),
    +    tags: z.array(z.string()),
    +    resourceLink: z.string().nullable(),
    +    translations: z.object({
    +      en: blogTranslationSchema,
    +      uk: blogTranslationSchema,
    +      pl: blogTranslationSchema,
    +    }),
    +    categoryIds: z.array(z.string().uuid()),
    +    publishMode: z.enum(['draft', 'publish', 'schedule']),
    +    scheduledPublishAt: z.string().nullable(),
    +  })
    +  .refine(
    +    data =>
    +      data.publishMode !== 'schedule' || (data.scheduledPublishAt && data.scheduledPublishAt.length > 0),
    +    { message: 'Scheduled date is required', path: ['scheduledPublishAt'] }
    +  );
    +
    +export type CreateBlogPostPayload = z.infer;
    +
    +// ── Inline category creation ─────────────────────────────────────
    +
    +const blogCategoryTranslationSchema = z.object({
    +  title: z.string().min(1, 'Title is required'),
    +});
    +
    +export const createBlogCategorySchema = z.object({
    +  slug: z.string().min(1, 'Slug is required'),
    +  translations: z.object({
    +    en: blogCategoryTranslationSchema,
    +    uk: blogCategoryTranslationSchema,
    +    pl: blogCategoryTranslationSchema,
    +  }),
    +});
    +
    +// ── Inline author creation ───────────────────────────────────────
    +
    +const blogAuthorTranslationSchema = z.object({
    +  name: z.string().min(1, 'Name is required'),
    +});
    +
    +export const createBlogAuthorSchema = z.object({
    +  slug: z.string().min(1, 'Slug is required'),
    +  translations: z.object({
    +    en: blogAuthorTranslationSchema,
    +    uk: blogAuthorTranslationSchema,
    +    pl: blogAuthorTranslationSchema,
    +  }),
    +});
    diff --git a/frontend/package-lock.json b/frontend/package-lock.json
    index 5e6523e4..90cf1f78 100644
    --- a/frontend/package-lock.json
    +++ b/frontend/package-lock.json
    @@ -22,6 +22,10 @@
             "@swc/helpers": "^0.5.18",
             "@tiptap/core": "^3.19.0",
             "@tiptap/extension-code-block-lowlight": "^3.20.0",
    +        "@tiptap/extension-image": "^3.20.2",
    +        "@tiptap/extension-link": "^3.20.2",
    +        "@tiptap/extension-task-item": "^3.20.4",
    +        "@tiptap/extension-task-list": "^3.20.4",
             "@tiptap/react": "^3.20.0",
             "@tiptap/starter-kit": "^3.20.0",
             "@upstash/redis": "^1.36.1",
    @@ -5466,16 +5470,16 @@
           }
         },
         "node_modules/@tiptap/core": {
    -      "version": "3.20.0",
    -      "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz",
    -      "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==",
    +      "version": "3.20.4",
    +      "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz",
    +      "integrity": "sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==",
           "license": "MIT",
           "funding": {
             "type": "github",
             "url": "https://github.com/sponsors/ueberdosis"
           },
           "peerDependencies": {
    -        "@tiptap/pm": "^3.20.0"
    +        "@tiptap/pm": "^3.20.4"
           }
         },
         "node_modules/@tiptap/extension-blockquote": {
    @@ -5674,6 +5678,19 @@
             "@tiptap/pm": "^3.20.0"
           }
         },
    +    "node_modules/@tiptap/extension-image": {
    +      "version": "3.20.2",
    +      "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.20.2.tgz",
    +      "integrity": "sha512-STo7T3NQ1TcF93NXRQDhb5YkepBRpYHY54yfBUmHl5cygYZzOMaGlM0nh8NeX54mh3wJ6+nxpApuM3Jbmg0I+w==",
    +      "license": "MIT",
    +      "funding": {
    +        "type": "github",
    +        "url": "https://github.com/sponsors/ueberdosis"
    +      },
    +      "peerDependencies": {
    +        "@tiptap/core": "^3.20.2"
    +      }
    +    },
         "node_modules/@tiptap/extension-italic": {
           "version": "3.20.0",
           "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz",
    @@ -5688,9 +5705,9 @@
           }
         },
         "node_modules/@tiptap/extension-link": {
    -      "version": "3.20.0",
    -      "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz",
    -      "integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==",
    +      "version": "3.20.2",
    +      "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.2.tgz",
    +      "integrity": "sha512-vnC72CFMUiCJuAt7Hi4T/hKvbY4DqBjqo9G6dkBfNJHXHmqGiGKvkgzm1m7P/R1EX1XYk8nifeCpW6q2uliFRQ==",
           "license": "MIT",
           "dependencies": {
             "linkifyjs": "^4.3.2"
    @@ -5700,22 +5717,22 @@
             "url": "https://github.com/sponsors/ueberdosis"
           },
           "peerDependencies": {
    -        "@tiptap/core": "^3.20.0",
    -        "@tiptap/pm": "^3.20.0"
    +        "@tiptap/core": "^3.20.2",
    +        "@tiptap/pm": "^3.20.2"
           }
         },
         "node_modules/@tiptap/extension-list": {
    -      "version": "3.20.0",
    -      "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz",
    -      "integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==",
    +      "version": "3.20.4",
    +      "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.4.tgz",
    +      "integrity": "sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==",
           "license": "MIT",
           "funding": {
             "type": "github",
             "url": "https://github.com/sponsors/ueberdosis"
           },
           "peerDependencies": {
    -        "@tiptap/core": "^3.20.0",
    -        "@tiptap/pm": "^3.20.0"
    +        "@tiptap/core": "^3.20.4",
    +        "@tiptap/pm": "^3.20.4"
           }
         },
         "node_modules/@tiptap/extension-list-item": {
    @@ -5783,6 +5800,32 @@
             "@tiptap/core": "^3.20.0"
           }
         },
    +    "node_modules/@tiptap/extension-task-item": {
    +      "version": "3.20.4",
    +      "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.20.4.tgz",
    +      "integrity": "sha512-mEWyAtZ61USZnKyLDxi2DtnSREfW0yUFXDOFWstNg1i6hva197BuAy6VRQMQxTOq+cFAgAt1MEZKanW0Obsa+g==",
    +      "license": "MIT",
    +      "funding": {
    +        "type": "github",
    +        "url": "https://github.com/sponsors/ueberdosis"
    +      },
    +      "peerDependencies": {
    +        "@tiptap/extension-list": "^3.20.4"
    +      }
    +    },
    +    "node_modules/@tiptap/extension-task-list": {
    +      "version": "3.20.4",
    +      "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-3.20.4.tgz",
    +      "integrity": "sha512-QvLrpffkxkr7TTgMmk6fnPAE34HYrUosHiuZJpRK008MuJDOoANblS221M4lLuRE73w3KI7hd/fi2CliBcCC4A==",
    +      "license": "MIT",
    +      "funding": {
    +        "type": "github",
    +        "url": "https://github.com/sponsors/ueberdosis"
    +      },
    +      "peerDependencies": {
    +        "@tiptap/extension-list": "^3.20.4"
    +      }
    +    },
         "node_modules/@tiptap/extension-text": {
           "version": "3.20.0",
           "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz",
    @@ -5824,9 +5867,9 @@
           }
         },
         "node_modules/@tiptap/pm": {
    -      "version": "3.20.0",
    -      "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz",
    -      "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==",
    +      "version": "3.20.4",
    +      "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.4.tgz",
    +      "integrity": "sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==",
           "license": "MIT",
           "dependencies": {
             "prosemirror-changeset": "^2.3.0",
    diff --git a/frontend/package.json b/frontend/package.json
    index 67c0bd20..3e839390 100644
    --- a/frontend/package.json
    +++ b/frontend/package.json
    @@ -37,6 +37,10 @@
         "@swc/helpers": "^0.5.18",
         "@tiptap/core": "^3.19.0",
         "@tiptap/extension-code-block-lowlight": "^3.20.0",
    +    "@tiptap/extension-image": "^3.20.2",
    +    "@tiptap/extension-link": "^3.20.2",
    +    "@tiptap/extension-task-item": "^3.20.4",
    +    "@tiptap/extension-task-list": "^3.20.4",
         "@tiptap/react": "^3.20.0",
         "@tiptap/starter-kit": "^3.20.0",
         "@upstash/redis": "^1.36.1",
    
    From 411f69ab46b3dc87623fd5a4be1f056ca1329f1b Mon Sep 17 00:00:00 2001
    From: Lesia Soloviova 
    Date: Sat, 21 Mar 2026 14:41:53 +0000
    Subject: [PATCH 08/12] feat(blog-admin): fix publish logic, polish UI, and
     restructure blog API routes
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    - Add placeholder pages for Authors/Categories (Issue #388)
    - Move blog API routes: blog-author → blog/author, blog-search → blog/search
    - Fix scheduled posts being published immediately (isPublished logic)
    - Add isScheduling defense-in-depth guard in toggleBlogPostPublish
    - Always update updatedAt on save, not only when base fields change
    - Add toast.error for failed uploads/requests instead of silent failures
    - Add dirty tracking in edit mode (disable Update until changes detected)
    - Fix task list rendering in BlogPostRenderer (checkmarks, no bullets)
    - Fix TiptapEditor bullet points clipping editor border (pl-2)
    - Center Actions column in BlogPostListTable
    - Move preview page queries to admin-blog.ts helpers
    - Align @tiptap/core to ^3.20.0 to match extension versions
    ---
     .../[locale]/admin/blog/[id]/preview/page.tsx | 43 ++++---------
     .../app/[locale]/admin/blog/authors/page.tsx  | 14 +++++
     .../[locale]/admin/blog/categories/page.tsx   | 14 +++++
     frontend/app/api/admin/blog/[id]/route.ts     |  4 +-
     frontend/app/api/admin/blog/route.ts          |  2 +-
     .../api/{blog-author => blog/author}/route.ts |  0
     .../api/{blog-search => blog/search}/route.ts |  0
     .../components/admin/blog/BlogImageUpload.tsx |  5 +-
     .../components/admin/blog/BlogPostForm.tsx    | 18 ++++--
     .../admin/blog/BlogPostListTable.tsx          |  9 ++-
     .../admin/blog/BlogTiptapEditor.tsx           | 11 +++-
     .../admin/blog/InlineBlogAuthorForm.tsx       |  7 ++-
     .../admin/blog/InlineBlogCategoryForm.tsx     |  5 +-
     frontend/components/blog/BlogFilters.tsx      |  2 +-
     frontend/components/blog/BlogHeaderSearch.tsx |  2 +-
     frontend/db/queries/blog/admin-blog.ts        | 60 ++++++++++++++++---
     .../lib/tests/blog/blog-author-route.test.ts  |  6 +-
     .../lib/tests/blog/blog-search-route.test.ts  |  4 +-
     frontend/package-lock.json                    |  2 +-
     frontend/package.json                         |  2 +-
     20 files changed, 141 insertions(+), 69 deletions(-)
     create mode 100644 frontend/app/[locale]/admin/blog/authors/page.tsx
     create mode 100644 frontend/app/[locale]/admin/blog/categories/page.tsx
     rename frontend/app/api/{blog-author => blog/author}/route.ts (100%)
     rename frontend/app/api/{blog-search => blog/search}/route.ts (100%)
    
    diff --git a/frontend/app/[locale]/admin/blog/[id]/preview/page.tsx b/frontend/app/[locale]/admin/blog/[id]/preview/page.tsx
    index df4861c0..afc94510 100644
    --- a/frontend/app/[locale]/admin/blog/[id]/preview/page.tsx
    +++ b/frontend/app/[locale]/admin/blog/[id]/preview/page.tsx
    @@ -1,11 +1,13 @@
    -import { and,eq } from 'drizzle-orm';
     import { Metadata } from 'next';
     import Image from 'next/image';
     import { notFound } from 'next/navigation';
     
     import BlogPostRenderer from '@/components/blog/BlogPostRenderer';
    -import { db } from '@/db';
    -import { getAdminBlogPostById } from '@/db/queries/blog/admin-blog';
    +import {
    +  getAdminBlogPostById,
    +  getBlogAuthorName,
    +  getBlogPostCategoryName,
    +} from '@/db/queries/blog/admin-blog';
     import {
       blogAuthorTranslations,
       blogCategoryTranslations,
    @@ -43,36 +45,11 @@ export default async function AdminBlogPreviewPage({
       const title = translation?.title ?? post.translations.en?.title ?? post.slug;
       const body = translation?.body ?? post.translations.en?.body ?? null;
     
    -  // Fetch author name for this locale
    -  let authorName: string | null = null;
    -  if (post.authorId) {
    -    const [authorRow] = await db
    -      .select({ name: blogAuthorTranslations.name })
    -      .from(blogAuthorTranslations)
    -      .where(
    -        and(
    -          eq(blogAuthorTranslations.authorId, post.authorId),
    -          eq(blogAuthorTranslations.locale, lang)
    -        )
    -      )
    -      .limit(1);
    -    authorName = authorRow?.name ?? null;
    -  }
    -
    -  // Fetch category names for this locale
    -  const categoryRows = await db
    -    .select({ title: blogCategoryTranslations.title })
    -    .from(blogPostCategories)
    -    .innerJoin(
    -      blogCategoryTranslations,
    -      and(
    -        eq(blogCategoryTranslations.categoryId, blogPostCategories.categoryId),
    -        eq(blogCategoryTranslations.locale, lang)
    -      )
    -    )
    -    .where(eq(blogPostCategories.postId, id));
    -
    -  const categoryName = categoryRows[0]?.title ?? null;
    +   const authorName = post.authorId
    +    ? await getBlogAuthorName(post.authorId, lang)
    +    : null;
    +
    +  const categoryName = await getBlogPostCategoryName(id, lang);
     
       return (
         
    diff --git a/frontend/app/[locale]/admin/blog/authors/page.tsx b/frontend/app/[locale]/admin/blog/authors/page.tsx new file mode 100644 index 00000000..91c887f7 --- /dev/null +++ b/frontend/app/[locale]/admin/blog/authors/page.tsx @@ -0,0 +1,14 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { title: 'Authors | DevLovers' }; + +export default function AdminBlogAuthorsPage() { + return ( +
    +

    Authors

    +

    + Author management coming... +

    +
    + ); +} diff --git a/frontend/app/[locale]/admin/blog/categories/page.tsx b/frontend/app/[locale]/admin/blog/categories/page.tsx new file mode 100644 index 00000000..13815030 --- /dev/null +++ b/frontend/app/[locale]/admin/blog/categories/page.tsx @@ -0,0 +1,14 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { title: 'Categories | DevLovers' }; + +export default function AdminBlogCategoriesPage() { + return ( +
    +

    Categories

    +

    + Category management coming... +

    +
    + ); +} diff --git a/frontend/app/api/admin/blog/[id]/route.ts b/frontend/app/api/admin/blog/[id]/route.ts index 9f359717..626cfd44 100644 --- a/frontend/app/api/admin/blog/[id]/route.ts +++ b/frontend/app/api/admin/blog/[id]/route.ts @@ -91,7 +91,7 @@ export async function PUT( }); await toggleBlogPostPublish(id, { - isPublished: data.publishMode !== 'draft', + isPublished: data.publishMode === 'publish', scheduledPublishAt: data.publishMode === 'schedule' && data.scheduledPublishAt ? new Date(data.scheduledPublishAt) @@ -159,6 +159,8 @@ export async function DELETE( await deleteBlogPost(id); + revalidatePath('/[locale]/blog', 'page'); + return noStoreJson({ success: true }); } catch (error) { if (error instanceof AdminApiDisabledError) diff --git a/frontend/app/api/admin/blog/route.ts b/frontend/app/api/admin/blog/route.ts index 2c65287d..4273f111 100644 --- a/frontend/app/api/admin/blog/route.ts +++ b/frontend/app/api/admin/blog/route.ts @@ -94,7 +94,7 @@ export async function POST(request: NextRequest): Promise { // Apply publish state if not draft if (data.publishMode !== 'draft') { await toggleBlogPostPublish(postId, { - isPublished: true, + isPublished: data.publishMode === 'publish', scheduledPublishAt: data.publishMode === 'schedule' && data.scheduledPublishAt ? new Date(data.scheduledPublishAt) diff --git a/frontend/app/api/blog-author/route.ts b/frontend/app/api/blog/author/route.ts similarity index 100% rename from frontend/app/api/blog-author/route.ts rename to frontend/app/api/blog/author/route.ts diff --git a/frontend/app/api/blog-search/route.ts b/frontend/app/api/blog/search/route.ts similarity index 100% rename from frontend/app/api/blog-search/route.ts rename to frontend/app/api/blog/search/route.ts diff --git a/frontend/components/admin/blog/BlogImageUpload.tsx b/frontend/components/admin/blog/BlogImageUpload.tsx index cc37c33d..b8efd113 100644 --- a/frontend/components/admin/blog/BlogImageUpload.tsx +++ b/frontend/components/admin/blog/BlogImageUpload.tsx @@ -39,13 +39,14 @@ export function BlogImageUpload({ body: formData, }); - const data = await res.json(); - if (!res.ok) { + const data = await res.json().catch(() => ({})); setError(data.error ?? 'Upload failed'); return; } + const data = await res.json(); + setPreviewUrl(data.url); onChange({ url: data.url, publicId: data.publicId }); } catch { diff --git a/frontend/components/admin/blog/BlogPostForm.tsx b/frontend/components/admin/blog/BlogPostForm.tsx index c05081c5..6c4aa405 100644 --- a/frontend/components/admin/blog/BlogPostForm.tsx +++ b/frontend/components/admin/blog/BlogPostForm.tsx @@ -42,6 +42,12 @@ interface BlogPostFormProps { initialData?: AdminBlogPostFull; } +function toLocalDateTimeValue(date: Date | string): string { + const d = date instanceof Date ? date : new Date(date); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + export function BlogPostForm({ authors: initialAuthors, categories: initialCategories, @@ -98,9 +104,10 @@ export function BlogPostForm({ }); const [scheduledDate, setScheduledDate] = useState(() => { if (!initialData?.scheduledPublishAt) return ''; - return new Date(initialData.scheduledPublishAt).toISOString().slice(0, 16); + return toLocalDateTimeValue(initialData.scheduledPublishAt); }); + const [error, setError] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -129,7 +136,7 @@ export function BlogPostForm({ ? 'schedule' : 'publish', scheduledDate: initialData.scheduledPublishAt - ? new Date(initialData.scheduledPublishAt).toISOString().slice(0, 16) + ? toLocalDateTimeValue(initialData.scheduledPublishAt) : '', }); }); @@ -290,7 +297,9 @@ export function BlogPostForm({ categoryIds: selectedCategoryIds, publishMode, scheduledPublishAt: - publishMode === 'schedule' ? scheduledDate : null, + publishMode === 'schedule' + ? new Date(scheduledDate).toISOString() + : null, }; const url = isEditMode ? `/api/admin/blog/${postId}` : '/api/admin/blog'; @@ -305,9 +314,8 @@ export function BlogPostForm({ body: JSON.stringify(body), }); - const data = await res.json(); - if (!res.ok) { + const data = await res.json().catch(() => ({})); setError(data.error ?? (isEditMode ? 'Failed to update post' : 'Failed to create post')); return; } diff --git a/frontend/components/admin/blog/BlogPostListTable.tsx b/frontend/components/admin/blog/BlogPostListTable.tsx index 7f3cdcee..0b196ea2 100644 --- a/frontend/components/admin/blog/BlogPostListTable.tsx +++ b/frontend/components/admin/blog/BlogPostListTable.tsx @@ -1,7 +1,7 @@ 'use client'; - import { useRouter } from 'next/navigation'; import { useState } from 'react'; +import { toast } from 'sonner'; import type { AdminBlogListItem } from '@/db/queries/blog/admin-blog'; import { Link } from '@/i18n/routing'; @@ -72,8 +72,10 @@ export function BlogPostListTable({ method: 'DELETE', headers: { 'x-csrf-token': csrfTokenDelete }, }); - if (res.ok) { + if (res.ok) { router.refresh(); + } else { + toast.error('Failed to delete post'); } } finally { setDeletingId(null); @@ -92,7 +94,10 @@ export function BlogPostListTable({ }); if (res.ok) { router.refresh(); + } else { + toast.error('Failed to toggle publish status'); } + } finally { setTogglingId(null); } diff --git a/frontend/components/admin/blog/BlogTiptapEditor.tsx b/frontend/components/admin/blog/BlogTiptapEditor.tsx index c4d96ee3..4969f658 100644 --- a/frontend/components/admin/blog/BlogTiptapEditor.tsx +++ b/frontend/components/admin/blog/BlogTiptapEditor.tsx @@ -1,5 +1,4 @@ 'use client'; - import 'highlight.js/styles/github-dark.css'; import type { JSONContent } from '@tiptap/core'; @@ -12,6 +11,7 @@ import { EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { common, createLowlight } from 'lowlight'; import { useRef, useState } from 'react'; +import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -117,14 +117,19 @@ export function BlogTiptapEditor({ body: formData, }); - const data = await res.json(); - if (!res.ok) return; + if (!res.ok) { + const data = await res.json().catch(() => ({})); + toast.error(data.error ?? 'Image upload failed'); + return; + } + const data = await res.json(); editor.chain().focus().setImage({ src: data.url }).run(); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ''; } + } if (!editor) return null; diff --git a/frontend/components/admin/blog/InlineBlogAuthorForm.tsx b/frontend/components/admin/blog/InlineBlogAuthorForm.tsx index 84f1b9e3..f5a46c75 100644 --- a/frontend/components/admin/blog/InlineBlogAuthorForm.tsx +++ b/frontend/components/admin/blog/InlineBlogAuthorForm.tsx @@ -58,14 +58,15 @@ export function InlineBlogAuthorForm({ }), }); - const data = await res.json(); - - if (!res.ok) { + if (!res.ok) { + const data = await res.json().catch(() => ({})); setError(data.error ?? 'Failed to create author'); return; } + const data = await res.json(); onCreated(data.author); + } catch { setError('Network error'); } finally { diff --git a/frontend/components/admin/blog/InlineBlogCategoryForm.tsx b/frontend/components/admin/blog/InlineBlogCategoryForm.tsx index 5a0ec513..5a7faa60 100644 --- a/frontend/components/admin/blog/InlineBlogCategoryForm.tsx +++ b/frontend/components/admin/blog/InlineBlogCategoryForm.tsx @@ -58,14 +58,15 @@ export function InlineBlogCategoryForm({ }), }); - const data = await res.json(); - if (!res.ok) { + const data = await res.json().catch(() => ({})); setError(data.error ?? 'Failed to create category'); return; } + const data = await res.json(); onCreated(data.category); + } catch { setError('Network error'); } finally { diff --git a/frontend/components/blog/BlogFilters.tsx b/frontend/components/blog/BlogFilters.tsx index bd8787e7..735c0609 100644 --- a/frontend/components/blog/BlogFilters.tsx +++ b/frontend/components/blog/BlogFilters.tsx @@ -346,7 +346,7 @@ export default function BlogFilters({ let active = true; fetch( - `/api/blog-author?name=${encodeURIComponent(name)}&locale=${encodeURIComponent( + `/api/blog/author?name=${encodeURIComponent(name)}&locale=${encodeURIComponent( locale )}`, { cache: 'no-store' } diff --git a/frontend/components/blog/BlogHeaderSearch.tsx b/frontend/components/blog/BlogHeaderSearch.tsx index f03ee4e0..ea74d978 100644 --- a/frontend/components/blog/BlogHeaderSearch.tsx +++ b/frontend/components/blog/BlogHeaderSearch.tsx @@ -28,7 +28,7 @@ function extractSnippet(body: PostSearchItem['body'], query: string) { return `${prefix}${text.slice(start, end)}${suffix}`; } -const SEARCH_ENDPOINT = '/api/blog-search'; +const SEARCH_ENDPOINT = '/api/blog/search'; function normalizeSearchText(value: string) { return value.toLowerCase().replace(/\s+/g, ' ').trim(); diff --git a/frontend/db/queries/blog/admin-blog.ts b/frontend/db/queries/blog/admin-blog.ts index 7cfd7334..c562d116 100644 --- a/frontend/db/queries/blog/admin-blog.ts +++ b/frontend/db/queries/blog/admin-blog.ts @@ -1,4 +1,4 @@ -import { eq, sql } from 'drizzle-orm'; +import { and,eq, sql } from 'drizzle-orm'; import { db } from '../../index'; import { @@ -230,14 +230,12 @@ export async function updateBlogPost( if (input.tags !== undefined) baseUpdate.tags = input.tags; if (input.resourceLink !== undefined) baseUpdate.resourceLink = input.resourceLink; - - if (Object.keys(baseUpdate).length > 0) { + baseUpdate.updatedAt = new Date(); await db .update(blogPosts) .set(baseUpdate) .where(eq(blogPosts.id, postId)); - } if (input.translations) { for (const [locale, trans] of Object.entries(input.translations)) { @@ -290,13 +288,16 @@ export async function toggleBlogPostPublish( opts: PublishOptions ): Promise { const now = new Date(); - + const isScheduling = + opts.isPublished && + opts.scheduledPublishAt != null && + opts.scheduledPublishAt > now; await db .update(blogPosts) .set({ - isPublished: opts.isPublished, - publishedAt: opts.isPublished ? now : null, - scheduledPublishAt: opts.scheduledPublishAt ?? null, + isPublished: opts.isPublished && !isScheduling, + publishedAt: opts.isPublished && !isScheduling ? now : null, + scheduledPublishAt: isScheduling ? opts.scheduledPublishAt : null, updatedAt: now, }) .where(eq(blogPosts.id, postId)); @@ -443,3 +444,46 @@ export async function createBlogAuthor( name: input.translations[ADMIN_LOCALE]?.name ?? input.slug, }; } + +// ── Preview helpers ───────────────────────────────────────────── + +export async function getBlogAuthorName( + authorId: string, + locale: string +): Promise { + const [row] = await db + .select({ name: blogAuthorTranslations.name }) + .from(blogAuthorTranslations) + .where( + and( + eq(blogAuthorTranslations.authorId, authorId), + eq(blogAuthorTranslations.locale, locale) + ) + ) + .limit(1); + return row?.name ?? null; +} + +export async function getBlogPostCategoryName( + postId: string, + locale: string +): Promise { + const [row] = await db + .select({ title: blogCategoryTranslations.title }) + .from(blogPostCategories) + .innerJoin( + blogCategoryTranslations, + and( + eq(blogCategoryTranslations.categoryId, blogPostCategories.categoryId), + eq(blogCategoryTranslations.locale, locale) + ) + ) + .innerJoin( + blogCategories, + eq(blogCategories.id, blogPostCategories.categoryId) + ) + .where(eq(blogPostCategories.postId, postId)) + .orderBy(blogCategories.displayOrder) + .limit(1); + return row?.title ?? null; +} diff --git a/frontend/lib/tests/blog/blog-author-route.test.ts b/frontend/lib/tests/blog/blog-author-route.test.ts index c3e1262f..b72a7f4c 100644 --- a/frontend/lib/tests/blog/blog-author-route.test.ts +++ b/frontend/lib/tests/blog/blog-author-route.test.ts @@ -10,7 +10,7 @@ vi.mock('@/client', () => ({ }, })); -import { GET } from '@/app/api/blog-author/route'; +import { GET } from '@/app/api/blog/author/route'; afterEach(() => { fetchMock.mockReset(); @@ -19,7 +19,7 @@ afterEach(() => { describe('GET /api/blog-author', () => { it('returns 400 when name is missing', async () => { const response = await GET( - new Request('http://localhost/api/blog-author?locale=uk') + new Request('http://localhost/api/blog/author?locale=uk') ); expect(response.status).toBe(400); @@ -30,7 +30,7 @@ describe('GET /api/blog-author', () => { const response = await GET( new Request( - 'http://localhost/api/blog-author?name=%D0%90%D0%BD%D0%BD%D0%B0&locale=uk' + 'http://localhost/api/blog/author?name=%D0%90%D0%BD%D0%BD%D0%B0&locale=uk' ) ); const data = await response.json(); diff --git a/frontend/lib/tests/blog/blog-search-route.test.ts b/frontend/lib/tests/blog/blog-search-route.test.ts index f53efb91..cb64ee61 100644 --- a/frontend/lib/tests/blog/blog-search-route.test.ts +++ b/frontend/lib/tests/blog/blog-search-route.test.ts @@ -10,7 +10,7 @@ vi.mock('@/client', () => ({ }, })); -import { GET } from '@/app/api/blog-search/route'; +import { GET } from '@/app/api/blog/search/route'; afterEach(() => { fetchMock.mockReset(); @@ -23,7 +23,7 @@ describe('GET /api/blog-search', () => { ]); const response = await GET( - new Request('http://localhost/api/blog-search?locale=uk') + new Request('http://localhost/api/blog/search?locale=uk') ); const data = await response.json(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 90cf1f78..d72b46fb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,7 @@ "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.0", "@swc/helpers": "^0.5.18", - "@tiptap/core": "^3.19.0", + "@tiptap/core": "^3.20.0", "@tiptap/extension-code-block-lowlight": "^3.20.0", "@tiptap/extension-image": "^3.20.2", "@tiptap/extension-link": "^3.20.2", diff --git a/frontend/package.json b/frontend/package.json index 3e839390..bfb83bee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,7 +35,7 @@ "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.0", "@swc/helpers": "^0.5.18", - "@tiptap/core": "^3.19.0", + "@tiptap/core": "^3.20.0", "@tiptap/extension-code-block-lowlight": "^3.20.0", "@tiptap/extension-image": "^3.20.2", "@tiptap/extension-link": "^3.20.2", From f8d7601789896f1c87e315f3c2f9ad53e9d6a336 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 21 Mar 2026 17:55:45 +0000 Subject: [PATCH 09/12] feat(blog-admin): add authors & categories management Authors management: - Authors list page with photo, name, job title, post count - Create/edit forms with locale tabs (name, bio, jobTitle, company, city) - Profile photo upload via Cloudinary (reuses BlogImageUpload) - Social media editor (platform dropdown + URL, dynamic rows) - Delete guard: blocked if author has posts assigned - API routes: PUT/DELETE /api/admin/blog/authors/[id] Categories management: - Single-page inline CRUD (BlogCategoryManager) - Inline create/edit with 3-locale titles and descriptions - Move up/down reordering (swap displayOrder values) - Delete guard: blocked if category has posts assigned - API routes: PUT/DELETE /api/admin/blog/categories/[id], POST reorder Query layer (admin-blog.ts): - 9 new functions: getAdminBlogAuthorsFull, getAdminBlogAuthorById, updateBlogAuthor, deleteBlogAuthor, getAdminBlogCategoriesFull, getAdminBlogCategoryById, updateBlogCategory, deleteBlogCategory, swapBlogCategoryOrder - Extended CreateBlogAuthorInput/CreateBlogCategoryInput with optional fields (backward compatible with inline forms) Validation (admin-blog.ts): - updateBlogAuthorSchema, updateBlogCategorySchema, swapCategoryOrderSchema - Extended create schemas with optional image, social, bio, description Closes #388 --- .../[locale]/admin/blog/authors/[id]/page.tsx | 45 ++ .../[locale]/admin/blog/authors/new/page.tsx | 34 ++ .../app/[locale]/admin/blog/authors/page.tsx | 37 +- .../admin/blog/categories/[id]/page.tsx | 0 .../admin/blog/categories/new/page.tsx | 0 .../[locale]/admin/blog/categories/page.tsx | 28 +- .../app/api/admin/blog/authors/[id]/route.ts | 132 +++++ .../api/admin/blog/categories/[id]/route.ts | 132 +++++ .../admin/blog/categories/reorder/route.ts | 87 ++++ .../components/admin/blog/BlogAuthorForm.tsx | 416 ++++++++++++++++ .../admin/blog/BlogAuthorListTable.tsx | 187 +++++++ .../admin/blog/BlogCategoryManager.tsx | 458 ++++++++++++++++++ frontend/db/queries/blog/admin-blog.ts | 380 ++++++++++++++- frontend/lib/validation/admin-blog.ts | 49 ++ 14 files changed, 1971 insertions(+), 14 deletions(-) create mode 100644 frontend/app/[locale]/admin/blog/authors/[id]/page.tsx create mode 100644 frontend/app/[locale]/admin/blog/authors/new/page.tsx create mode 100644 frontend/app/[locale]/admin/blog/categories/[id]/page.tsx create mode 100644 frontend/app/[locale]/admin/blog/categories/new/page.tsx create mode 100644 frontend/app/api/admin/blog/authors/[id]/route.ts create mode 100644 frontend/app/api/admin/blog/categories/[id]/route.ts create mode 100644 frontend/app/api/admin/blog/categories/reorder/route.ts create mode 100644 frontend/components/admin/blog/BlogAuthorForm.tsx create mode 100644 frontend/components/admin/blog/BlogAuthorListTable.tsx create mode 100644 frontend/components/admin/blog/BlogCategoryManager.tsx diff --git a/frontend/app/[locale]/admin/blog/authors/[id]/page.tsx b/frontend/app/[locale]/admin/blog/authors/[id]/page.tsx new file mode 100644 index 00000000..602793ce --- /dev/null +++ b/frontend/app/[locale]/admin/blog/authors/[id]/page.tsx @@ -0,0 +1,45 @@ +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { BlogAuthorForm } from '@/components/admin/blog/BlogAuthorForm'; +import { getAdminBlogAuthorById } from '@/db/queries/blog/admin-blog'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'Edit Author | DevLovers', +}; + +export default async function AdminBlogAuthorEditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const author = await getAdminBlogAuthorById(id); + if (!author) notFound(); + + const csrfTokenAuthor = issueCsrfToken('admin:blog-author:update'); + const csrfTokenImage = issueCsrfToken('admin:blog:image'); + + return ( +
    +
    + + ← Back to authors + +
    + +

    Edit Author

    + + +
    + ); +} diff --git a/frontend/app/[locale]/admin/blog/authors/new/page.tsx b/frontend/app/[locale]/admin/blog/authors/new/page.tsx new file mode 100644 index 00000000..e132dff2 --- /dev/null +++ b/frontend/app/[locale]/admin/blog/authors/new/page.tsx @@ -0,0 +1,34 @@ +import { Metadata } from 'next'; + +import { BlogAuthorForm } from '@/components/admin/blog/BlogAuthorForm'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'New Author | DevLovers', +}; + +export default async function AdminBlogAuthorNewPage() { + const csrfTokenAuthor = issueCsrfToken('admin:blog-author:create'); + const csrfTokenImage = issueCsrfToken('admin:blog:image'); + + return ( +
    +
    + + ← Back to authors + +
    + +

    New Author

    + + +
    + ); +} diff --git a/frontend/app/[locale]/admin/blog/authors/page.tsx b/frontend/app/[locale]/admin/blog/authors/page.tsx index 91c887f7..442de87b 100644 --- a/frontend/app/[locale]/admin/blog/authors/page.tsx +++ b/frontend/app/[locale]/admin/blog/authors/page.tsx @@ -1,14 +1,39 @@ import { Metadata } from 'next'; -export const metadata: Metadata = { title: 'Authors | DevLovers' }; +import { BlogAuthorListTable } from '@/components/admin/blog/BlogAuthorListTable'; +import { getAdminBlogAuthorsFull } from '@/db/queries/blog/admin-blog'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'Authors | Admin | DevLovers', +}; + +export default async function AdminBlogAuthorsPage() { + const authors = await getAdminBlogAuthorsFull(); + const csrfTokenDelete = issueCsrfToken('admin:blog-author:delete'); -export default function AdminBlogAuthorsPage() { return (
    -

    Authors

    -

    - Author management coming... -

    +
    +
    +

    Authors

    +

    + Manage blog authors and their profiles +

    +
    + + + New Author + +
    + +
    + +
    ); } + diff --git a/frontend/app/[locale]/admin/blog/categories/[id]/page.tsx b/frontend/app/[locale]/admin/blog/categories/[id]/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/frontend/app/[locale]/admin/blog/categories/new/page.tsx b/frontend/app/[locale]/admin/blog/categories/new/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/frontend/app/[locale]/admin/blog/categories/page.tsx b/frontend/app/[locale]/admin/blog/categories/page.tsx index 13815030..f2adff3a 100644 --- a/frontend/app/[locale]/admin/blog/categories/page.tsx +++ b/frontend/app/[locale]/admin/blog/categories/page.tsx @@ -1,14 +1,30 @@ import { Metadata } from 'next'; -export const metadata: Metadata = { title: 'Categories | DevLovers' }; +import { BlogCategoryManager } from '@/components/admin/blog/BlogCategoryManager'; +import { getAdminBlogCategoriesFull } from '@/db/queries/blog/admin-blog'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'Categories | Admin | DevLovers', +}; + +export default async function AdminBlogCategoriesPage() { + const categories = await getAdminBlogCategoriesFull(); + const csrfTokenCreate = issueCsrfToken('admin:blog-category:create'); + const csrfTokenUpdate = issueCsrfToken('admin:blog-category:update'); + const csrfTokenDelete = issueCsrfToken('admin:blog-category:delete'); + const csrfTokenReorder = issueCsrfToken('admin:blog-category:reorder'); -export default function AdminBlogCategoriesPage() { return (
    -

    Categories

    -

    - Category management coming... -

    +
    ); } + diff --git a/frontend/app/api/admin/blog/authors/[id]/route.ts b/frontend/app/api/admin/blog/authors/[id]/route.ts new file mode 100644 index 00000000..800b3db0 --- /dev/null +++ b/frontend/app/api/admin/blog/authors/[id]/route.ts @@ -0,0 +1,132 @@ +import { revalidatePath } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +import { deleteBlogAuthor, updateBlogAuthor } from '@/db/queries/blog/admin-blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { updateBlogAuthorSchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-author:update'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = updateBlogAuthorSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Invalid payload', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, + { status: 400 } + ); + } + + await updateBlogAuthor(id, parsed.data); + revalidatePath('/admin/blog/authors'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_author_update_failed', error, {}); + return noStoreJson( + { error: 'Failed to update author', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-author:delete'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + await deleteBlogAuthor(id); + revalidatePath('/admin/blog/authors'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof Error && error.message === 'AUTHOR_HAS_POSTS') { + return noStoreJson( + { error: 'Author has posts assigned', code: 'HAS_POSTS' }, + { status: 409 } + ); + } + + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_author_delete_failed', error, {}); + return noStoreJson( + { error: 'Failed to delete author', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/admin/blog/categories/[id]/route.ts b/frontend/app/api/admin/blog/categories/[id]/route.ts new file mode 100644 index 00000000..2d59972d --- /dev/null +++ b/frontend/app/api/admin/blog/categories/[id]/route.ts @@ -0,0 +1,132 @@ +import { revalidatePath } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +import { deleteBlogCategory, updateBlogCategory } from '@/db/queries/blog/admin-blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { updateBlogCategorySchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-category:update'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = updateBlogCategorySchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Invalid payload', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, + { status: 400 } + ); + } + + await updateBlogCategory(id, parsed.data); + revalidatePath('/admin/blog/categories'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_category_update_failed', error, {}); + return noStoreJson( + { error: 'Failed to update category', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-category:delete'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + await deleteBlogCategory(id); + revalidatePath('/admin/blog/categories'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof Error && error.message === 'CATEGORY_HAS_POSTS') { + return noStoreJson( + { error: 'Category has posts assigned', code: 'HAS_POSTS' }, + { status: 409 } + ); + } + + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_category_delete_failed', error, {}); + return noStoreJson( + { error: 'Failed to delete category', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/admin/blog/categories/reorder/route.ts b/frontend/app/api/admin/blog/categories/reorder/route.ts new file mode 100644 index 00000000..d4a6e7d1 --- /dev/null +++ b/frontend/app/api/admin/blog/categories/reorder/route.ts @@ -0,0 +1,87 @@ +import { revalidatePath } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +import { swapBlogCategoryOrder } from '@/db/queries/blog/admin-blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { swapCategoryOrderSchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function POST(request: NextRequest): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-category:reorder'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = swapCategoryOrderSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Invalid payload', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, + { status: 400 } + ); + } + + await swapBlogCategoryOrder(parsed.data.id1, parsed.data.id2); + revalidatePath('/admin/blog/categories'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof Error && error.message === 'CATEGORIES_NOT_FOUND') { + return noStoreJson( + { error: 'One or both categories not found', code: 'NOT_FOUND' }, + { status: 404 } + ); + } + + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_category_reorder_failed', error, {}); + return noStoreJson( + { error: 'Failed to reorder categories', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/components/admin/blog/BlogAuthorForm.tsx b/frontend/components/admin/blog/BlogAuthorForm.tsx new file mode 100644 index 00000000..cb297d8d --- /dev/null +++ b/frontend/components/admin/blog/BlogAuthorForm.tsx @@ -0,0 +1,416 @@ +'use client'; + +import { useRef, useState } from 'react'; + +import type { AdminBlogAuthorFull } from '@/db/queries/blog/admin-blog'; +import { useRouter } from '@/i18n/routing'; +import { slugify } from '@/lib/shop/slug'; + +import { type AdminLocale, LocaleTabs } from '../quiz/LocaleTabs'; +import { BlogImageUpload } from './BlogImageUpload'; + +const LOCALES: AdminLocale[] = ['en', 'uk', 'pl']; + +const PLATFORMS = [ + { value: 'github', label: 'GitHub' }, + { value: 'linkedin', label: 'LinkedIn' }, + { value: 'x', label: 'X' }, + { value: 'website', label: 'Website' }, + { value: 'youtube', label: 'YouTube' }, + { value: 'instagram', label: 'Instagram' }, + { value: 'facebook', label: 'Facebook' }, + { value: 'behance', label: 'Behance' }, + { value: 'dribbble', label: 'Dribbble' }, +] as const; + +interface AuthorTranslation { + name: string; + bio: string; + jobTitle: string; + company: string; + city: string; +} + +interface SocialEntry { + platform: string; + url: string; +} + +const emptyTranslation = (): AuthorTranslation => ({ + name: '', + bio: '', + jobTitle: '', + company: '', + city: '', +}); + +const emptyTranslations = (): Record => ({ + en: emptyTranslation(), + uk: emptyTranslation(), + pl: emptyTranslation(), +}); + +interface BlogAuthorFormProps { + initialData?: AdminBlogAuthorFull; + csrfTokenAuthor: string; + csrfTokenImage: string; +} + +export function BlogAuthorForm({ + initialData, + csrfTokenAuthor, + csrfTokenImage, +}: BlogAuthorFormProps) { + const router = useRouter(); + const isEditMode = !!initialData; + + const [activeLocale, setActiveLocale] = useState('en'); + const [translations, setTranslations] = useState>(() => { + if (!initialData) return emptyTranslations(); + const t = initialData.translations; + return { + en: { + name: t.en?.name ?? '', + bio: t.en?.bio ?? '', + jobTitle: t.en?.jobTitle ?? '', + company: t.en?.company ?? '', + city: t.en?.city ?? '', + }, + uk: { + name: t.uk?.name ?? '', + bio: t.uk?.bio ?? '', + jobTitle: t.uk?.jobTitle ?? '', + company: t.uk?.company ?? '', + city: t.uk?.city ?? '', + }, + pl: { + name: t.pl?.name ?? '', + bio: t.pl?.bio ?? '', + jobTitle: t.pl?.jobTitle ?? '', + company: t.pl?.company ?? '', + city: t.pl?.city ?? '', + }, + }; + }); + + const [slug, setSlug] = useState(initialData?.slug ?? ''); + const [slugTouched, setSlugTouched] = useState(!!initialData); + + const [image, setImage] = useState<{ url: string; publicId: string } | null>( + initialData?.imageUrl + ? { url: initialData.imageUrl, publicId: initialData.imagePublicId ?? '' } + : null + ); + + const [socialMedia, setSocialMedia] = useState( + initialData?.socialMedia ?? [] + ); + + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const initialSnapshot = useRef(() => { + if (!initialData) return null; + return JSON.stringify({ + slug: initialData.slug, + imageUrl: initialData.imageUrl ?? null, + socialMedia: initialData.socialMedia ?? [], + translations: Object.fromEntries( + LOCALES.map(l => [l, { + name: initialData.translations[l]?.name ?? '', + bio: initialData.translations[l]?.bio ?? '', + jobTitle: initialData.translations[l]?.jobTitle ?? '', + company: initialData.translations[l]?.company ?? '', + city: initialData.translations[l]?.city ?? '', + }]) + ), + }); + }); + + function isDirty(): boolean { + if (!isEditMode) return true; + const snap = initialSnapshot.current(); + if (!snap) return true; + const current = JSON.stringify({ + slug, + imageUrl: image?.url ?? null, + socialMedia, + translations: Object.fromEntries( + LOCALES.map(l => [l, translations[l]]) + ), + }); + return current !== snap; + } + + function handleNameChange(value: string) { + setTranslations(prev => ({ + ...prev, + [activeLocale]: { ...prev[activeLocale], name: value }, + })); + if (activeLocale === 'en' && !slugTouched) { + setSlug(slugify(value)); + } + } + + function handleFieldChange(field: keyof AuthorTranslation, value: string) { + setTranslations(prev => ({ + ...prev, + [activeLocale]: { ...prev[activeLocale], [field]: value }, + })); + } + + function addSocialEntry() { + setSocialMedia(prev => [...prev, { platform: PLATFORMS[0].value, url: '' }]); + } + + function removeSocialEntry(index: number) { + setSocialMedia(prev => prev.filter((_, i) => i !== index)); + } + + function updateSocialEntry(index: number, field: keyof SocialEntry, value: string) { + setSocialMedia(prev => + prev.map((entry, i) => (i === index ? { ...entry, [field]: value } : entry)) + ); + } + + function isFormValid(): boolean { + if (!slug.trim()) return false; + return LOCALES.every(l => translations[l].name.trim().length > 0); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSubmitting(true); + + const body = { + slug: slug.trim(), + imageUrl: image?.url ?? null, + imagePublicId: image?.publicId ?? null, + socialMedia: socialMedia.filter(s => s.url.trim()), + translations: Object.fromEntries( + LOCALES.map(l => [l, { + name: translations[l].name.trim(), + bio: translations[l].bio.trim() || undefined, + jobTitle: translations[l].jobTitle.trim() || undefined, + company: translations[l].company.trim() || undefined, + city: translations[l].city.trim() || undefined, + }]) + ), + }; + + try { + const url = isEditMode + ? `/api/admin/blog/authors/${initialData.id}` + : '/api/admin/blog/authors'; + const method = isEditMode ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfTokenAuthor, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error ?? 'Failed to save author'); + return; + } + + router.push('/admin/blog/authors'); + } catch { + setError('Network error'); + } finally { + setSubmitting(false); + } + } + + const inputClass = + 'border-border bg-background text-foreground w-full rounded-md border px-3 py-2 text-sm'; + const labelClass = 'text-foreground mb-1 block text-sm font-medium'; + + const current = translations[activeLocale]; + + return ( +
    + {error && ( +
    + {error} +
    + )} + + {/* Profile photo */} +
    + + +
    + + {/* Locale tabs + translation fields */} +
    +
    + + +
    + +
    +
    + + handleNameChange(e.target.value)} + className={inputClass} + placeholder="Author name" + /> +
    +
    + + handleFieldChange('jobTitle', e.target.value)} + className={inputClass} + placeholder="e.g. Senior Developer" + /> +
    +
    +
    + + handleFieldChange('company', e.target.value)} + className={inputClass} + /> +
    +
    + + handleFieldChange('city', e.target.value)} + className={inputClass} + /> +
    +
    +
    + +