Compare commits
4 commits
5fe24b8c59
...
9b9b551463
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b9b551463 | |||
| edd239fa69 | |||
| a3d3a140ed | |||
| e7d68c2fc6 |
29 changed files with 4050 additions and 229 deletions
|
|
@ -6,5 +6,11 @@ README.md
|
||||||
.docker
|
.docker
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
docker/
|
docker/
|
||||||
drizzle/
|
*.log
|
||||||
*.log
|
dist
|
||||||
|
|
||||||
|
# NOTE: drizzle/ is intentionally NOT ignored — the migration SQL must be
|
||||||
|
# in the build context so the Dockerfile can COPY it into the runner image.
|
||||||
|
# Only the non-shipping sub-folders are excluded:
|
||||||
|
drizzle/_archived_pre_baseline_2026-05-19
|
||||||
|
drizzle/_introspected
|
||||||
|
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -7,6 +7,9 @@
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
|
# migrator build output (generated by scripts/build-migrator.mjs)
|
||||||
|
/dist/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
|
@ -31,4 +34,10 @@ yarn-error.log*
|
||||||
# docker
|
# docker
|
||||||
.docker/
|
.docker/
|
||||||
data/
|
data/
|
||||||
drizzle/
|
|
||||||
|
# NOTE: drizzle/ is intentionally NOT ignored.
|
||||||
|
# It holds migration SQL + meta snapshots — these are SOURCE CODE and
|
||||||
|
# MUST be committed so the deploy pipeline can apply them on the server.
|
||||||
|
# Only transient introspection scratch output is ignored:
|
||||||
|
drizzle/_introspected/
|
||||||
|
drizzle/_archived_pre_baseline_2026-05-19/
|
||||||
13
Dockerfile
13
Dockerfile
|
|
@ -14,6 +14,10 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN corepack enable pnpm
|
RUN corepack enable pnpm
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
# Bundle the standalone migration runner. This produces dist/migrate.mjs
|
||||||
|
# with drizzle-orm + postgres inlined, so the runner stage needs no extra
|
||||||
|
# node_modules. Runs here because the builder has full dependencies.
|
||||||
|
RUN pnpm run db:build-migrator
|
||||||
|
|
||||||
# Stage 3: Production runner
|
# Stage 3: Production runner
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|
@ -24,8 +28,15 @@ RUN adduser --system --uid 1001 nextjs
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static .next/static
|
COPY --from=builder /app/.next/static .next/static
|
||||||
|
# Migration runner + migration SQL. The migrator runs on container start
|
||||||
|
# (see CMD) BEFORE the Next.js server boots — see drizzle/README.md.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/dist/migrate.mjs ./migrate.mjs
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
CMD ["node", "server.js"]
|
# Apply pending migrations, THEN start the server. If migration fails the
|
||||||
|
# process exits non-zero and the container crashes — a loud, safe failure
|
||||||
|
# that prevents the app from serving against a half-migrated schema.
|
||||||
|
CMD ["sh", "-c", "node migrate.mjs && node server.js"]
|
||||||
|
|
|
||||||
430
drizzle/0000_baseline_prod_2026_05_19.sql
Normal file
430
drizzle/0000_baseline_prod_2026_05_19.sql
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
CREATE TYPE "public"."child_sex" AS ENUM('male', 'female', 'other');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."child_stage" AS ENUM('newborn', 'infant', 'solids_start', 'toddler_early', 'toddler_late', 'preschool');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."member_role" AS ENUM('admin', 'caregiver', 'viewer');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."diaper_type" AS ENUM('wet', 'dirty', 'both', 'dry');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."feed_method" AS ENUM('bottle', 'breast_left', 'breast_right', 'breast_both', 'cup', 'spoon', 'finger', 'self');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."feed_type" AS ENUM('breast_milk', 'formula', 'solid', 'water', 'other');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."sleep_type" AS ENUM('nap', 'night');--> statement-breakpoint
|
||||||
|
CREATE TABLE "admin_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"admin_id" uuid NOT NULL,
|
||||||
|
"session_token" text NOT NULL,
|
||||||
|
"expires" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
CONSTRAINT "admin_sessions_session_token_unique" UNIQUE("session_token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "admins" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"username" varchar(50) NOT NULL,
|
||||||
|
"password_hash" varchar(255) NOT NULL,
|
||||||
|
"role" varchar(20) DEFAULT 'admin',
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"last_login" timestamp with time zone,
|
||||||
|
CONSTRAINT "admins_username_unique" UNIQUE("username"),
|
||||||
|
CONSTRAINT "admins_role_check" CHECK ((role)::text = ANY (ARRAY['super_admin','admin','support']))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "password_resets" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"used_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "password_resets_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "member_profiles" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"family_id" uuid NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"display_name" text NOT NULL,
|
||||||
|
"bio" text,
|
||||||
|
"avatar_url" text,
|
||||||
|
"is_public" 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 "member_profiles_user_id_unique" UNIQUE("user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "product_clicks" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"product_id" uuid NOT NULL,
|
||||||
|
"clicked_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"referrer" text,
|
||||||
|
"ip_hash" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "recommended_products" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"profile_id" uuid NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"image_url" text,
|
||||||
|
"category" text DEFAULT 'general' NOT NULL,
|
||||||
|
"display_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ai_usage" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"family_id" uuid,
|
||||||
|
"user_id" uuid,
|
||||||
|
"intent" text,
|
||||||
|
"model_used" text,
|
||||||
|
"prompt_tokens" integer,
|
||||||
|
"completion_tokens" integer,
|
||||||
|
"total_tokens" integer,
|
||||||
|
"cost_estimate_paise" numeric(10, 4),
|
||||||
|
"duration_ms" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "chat_messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"session_id" uuid NOT NULL,
|
||||||
|
"role" varchar(20) NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "chat_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"title" varchar(255) DEFAULT 'New conversation' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "audit_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"family_id" uuid,
|
||||||
|
"user_id" uuid,
|
||||||
|
"action" varchar(50) NOT NULL,
|
||||||
|
"resource_type" varchar(50),
|
||||||
|
"resource_id" uuid,
|
||||||
|
"ip_address" varchar(45),
|
||||||
|
"user_agent" text,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "log_corrections" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"family_id" uuid NOT NULL,
|
||||||
|
"dose_id" uuid NOT NULL,
|
||||||
|
"original_value" jsonb NOT NULL,
|
||||||
|
"corrected_value" jsonb NOT NULL,
|
||||||
|
"reason" text,
|
||||||
|
"corrected_by" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "accounts" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"provider_account_id" text NOT NULL,
|
||||||
|
"refresh_token" text,
|
||||||
|
"access_token" text,
|
||||||
|
"expires_at" timestamp,
|
||||||
|
"token_type" text,
|
||||||
|
"scope" text,
|
||||||
|
"id_token" text,
|
||||||
|
"session_state" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"session_token" text NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"expires" timestamp NOT NULL,
|
||||||
|
CONSTRAINT "sessions_session_token_unique" UNIQUE("session_token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"email_verified" timestamp,
|
||||||
|
"image" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"password_hash" varchar(255),
|
||||||
|
"password_updated_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "verification_tokens" (
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"expires" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "children" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"family_id" uuid NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"birth_date" date NOT NULL,
|
||||||
|
"sex" "child_sex",
|
||||||
|
"stage" "child_stage" DEFAULT 'newborn' NOT NULL,
|
||||||
|
"image_url" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "families" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"tier" varchar(20) DEFAULT 'free',
|
||||||
|
"max_children" integer DEFAULT 1,
|
||||||
|
"max_members" integer DEFAULT 2,
|
||||||
|
"pediatrician_phone" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "family_invites" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"family_id" uuid NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"role" "member_role" DEFAULT 'viewer' NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "family_invites_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "family_members" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"family_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"role" "member_role" DEFAULT 'caregiver' NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "attachments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"family_id" uuid NOT NULL,
|
||||||
|
"log_entry_id" uuid,
|
||||||
|
"r2_key" text NOT NULL,
|
||||||
|
"r2_thumbnail_key" text,
|
||||||
|
"mime_type" text,
|
||||||
|
"size_bytes" integer,
|
||||||
|
"uploaded_by" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "memories" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"family_id" uuid NOT NULL,
|
||||||
|
"child_id" uuid,
|
||||||
|
"title" text,
|
||||||
|
"description" text,
|
||||||
|
"taken_at" timestamp with time zone,
|
||||||
|
"r2_key" text NOT NULL,
|
||||||
|
"r2_thumbnail_key" text,
|
||||||
|
"mime_type" text,
|
||||||
|
"size_bytes" integer,
|
||||||
|
"width" integer,
|
||||||
|
"height" integer,
|
||||||
|
"vision_caption" text,
|
||||||
|
"vision_tags" text[],
|
||||||
|
"vision_embedding" vector(1536),
|
||||||
|
"is_private" boolean DEFAULT false NOT NULL,
|
||||||
|
"processing_status" text DEFAULT 'uploading' NOT NULL,
|
||||||
|
"uploaded_by" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "diapers_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"type" "diaper_type" NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"logged_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "feeds" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"type" "feed_type" NOT NULL,
|
||||||
|
"method" "feed_method",
|
||||||
|
"amount_ml" real,
|
||||||
|
"notes" text,
|
||||||
|
"logged_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "growth" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"measured_at" timestamp NOT NULL,
|
||||||
|
"weight_kg" real,
|
||||||
|
"height_cm" real,
|
||||||
|
"head_circumference_cm" real,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "medications" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"dosage" text,
|
||||||
|
"frequency" text,
|
||||||
|
"start_date" date NOT NULL,
|
||||||
|
"end_date" date,
|
||||||
|
"active" boolean DEFAULT true NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "milestone_achievements" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"family_id" uuid NOT NULL,
|
||||||
|
"milestone_key" text NOT NULL,
|
||||||
|
"achieved_at" date NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sleeps" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"type" "sleep_type" NOT NULL,
|
||||||
|
"started_at" timestamp,
|
||||||
|
"ended_at" timestamp,
|
||||||
|
"duration_minutes" integer,
|
||||||
|
"notes" text,
|
||||||
|
"logged_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "vaccinations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"vaccine_name" text NOT NULL,
|
||||||
|
"scheduled_date" date NOT NULL,
|
||||||
|
"given_date" date,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"provider" text,
|
||||||
|
"lot_number" text,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "allergies" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"severity" varchar(50) DEFAULT 'mild',
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "doctor_visits" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"doctor_name" varchar(255) NOT NULL,
|
||||||
|
"reason" varchar(255),
|
||||||
|
"visit_date" date NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "illness_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"start_date" date NOT NULL,
|
||||||
|
"end_date" date,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "medication_doses" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"medicine_id" uuid NOT NULL,
|
||||||
|
"family_id" uuid NOT NULL,
|
||||||
|
"administered_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"administered_by" uuid,
|
||||||
|
"amount_given" text,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "medicines" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"child_id" uuid NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"dose" varchar(255),
|
||||||
|
"notes" text,
|
||||||
|
"reminder_time" varchar(10),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "support_responses" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"ticket_id" uuid,
|
||||||
|
"admin_id" uuid,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "support_tickets" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"family_id" uuid,
|
||||||
|
"user_id" uuid,
|
||||||
|
"email" varchar(255) NOT NULL,
|
||||||
|
"subject" varchar(255) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"status" varchar(20) DEFAULT 'open',
|
||||||
|
"priority" varchar(20) DEFAULT 'normal',
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now(),
|
||||||
|
CONSTRAINT "support_tickets_status_check" CHECK ((status)::text = ANY (ARRAY['open','in_progress','resolved','closed'])),
|
||||||
|
CONSTRAINT "support_tickets_priority_check" CHECK ((priority)::text = ANY (ARRAY['low','normal','high','urgent']))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "diapers_logs" ADD CONSTRAINT "diapers_logs_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "feeds" ADD CONSTRAINT "feeds_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "growth" ADD CONSTRAINT "growth_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "medications" ADD CONSTRAINT "medications_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sleeps" ADD CONSTRAINT "sleeps_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "vaccinations" ADD CONSTRAINT "vaccinations_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "member_profiles_slug_idx" ON "member_profiles" USING btree ("slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "product_clicks_product_idx" ON "product_clicks" USING btree ("product_id","clicked_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "recommended_products_profile_idx" ON "recommended_products" USING btree ("profile_id","display_order");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ai_usage_created_idx" ON "ai_usage" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ai_usage_family_idx" ON "ai_usage" USING btree ("family_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_family_idx" ON "audit_log" USING btree ("family_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_audit_log_action" ON "audit_log" USING btree ("action");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_audit_log_created" ON "audit_log" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_audit_log_family" ON "audit_log" USING btree ("family_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_audit_log_user" ON "audit_log" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "log_corrections_dose_idx" ON "log_corrections" USING btree ("dose_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "accounts_provider_idx" ON "accounts" USING btree ("provider","provider_account_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "verification_tokens_idx" ON "verification_tokens" USING btree ("identifier","token");--> statement-breakpoint
|
||||||
|
CREATE INDEX "children_family_idx" ON "children" USING btree ("family_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "invite_token_idx" ON "family_invites" USING btree ("token");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "family_members_family_id_user_id_key" ON "family_members" USING btree ("family_id","user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "attachments_family_idx" ON "attachments" USING btree ("family_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "memories_family_idx" ON "memories" USING btree ("family_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "memories_child_idx" ON "memories" USING btree ("child_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "memories_embedding_idx" ON "memories" USING ivfflat ("vision_embedding" vector_cosine_ops) WITH (lists=100);--> statement-breakpoint
|
||||||
|
CREATE INDEX "milestone_child_idx" ON "milestone_achievements" USING btree ("child_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "milestone_achievements_child_milestone_unique" ON "milestone_achievements" USING btree ("child_id","milestone_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "medication_doses_family_idx" ON "medication_doses" USING btree ("family_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "medication_doses_medicine_idx" ON "medication_doses" USING btree ("medicine_id");
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
-- Enable pgvector if not already
|
|
||||||
CREATE EXTENSION IF NOT EXISTS vector;
|
|
||||||
|
|
||||||
-- Memories table (photos with vision metadata)
|
|
||||||
CREATE TABLE IF NOT EXISTS memories (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
family_id UUID NOT NULL,
|
|
||||||
child_id UUID,
|
|
||||||
title TEXT,
|
|
||||||
description TEXT,
|
|
||||||
taken_at TIMESTAMPTZ,
|
|
||||||
r2_key TEXT NOT NULL,
|
|
||||||
r2_thumbnail_key TEXT,
|
|
||||||
mime_type TEXT,
|
|
||||||
size_bytes INTEGER,
|
|
||||||
width INTEGER,
|
|
||||||
height INTEGER,
|
|
||||||
vision_caption TEXT,
|
|
||||||
vision_tags TEXT[],
|
|
||||||
vision_embedding VECTOR(1536),
|
|
||||||
is_private BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
processing_status TEXT NOT NULL DEFAULT 'uploading'
|
|
||||||
CHECK (processing_status IN ('uploading', 'processing', 'ready', 'failed')),
|
|
||||||
uploaded_by UUID,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS memories_family_idx ON memories (family_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS memories_child_idx ON memories (child_id);
|
|
||||||
|
|
||||||
-- Attachments table (files linked to log entries, no vision)
|
|
||||||
CREATE TABLE IF NOT EXISTS attachments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
family_id UUID NOT NULL,
|
|
||||||
log_entry_id UUID,
|
|
||||||
r2_key TEXT NOT NULL,
|
|
||||||
r2_thumbnail_key TEXT,
|
|
||||||
mime_type TEXT,
|
|
||||||
size_bytes INTEGER,
|
|
||||||
uploaded_by UUID,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS attachments_family_idx ON attachments (family_id);
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
-- G3.2: Medication dose log
|
|
||||||
CREATE TABLE IF NOT EXISTS medication_doses (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
medicine_id UUID NOT NULL,
|
|
||||||
family_id UUID NOT NULL,
|
|
||||||
administered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
administered_by UUID,
|
|
||||||
amount_given TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS medication_doses_medicine_idx ON medication_doses (medicine_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS medication_doses_family_idx ON medication_doses (family_id);
|
|
||||||
|
|
||||||
-- G3.4: Log corrections (audit trail for edited doses)
|
|
||||||
CREATE TABLE IF NOT EXISTS log_corrections (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
family_id UUID NOT NULL,
|
|
||||||
dose_id UUID NOT NULL REFERENCES medication_doses(id) ON DELETE CASCADE,
|
|
||||||
original_value JSONB NOT NULL,
|
|
||||||
corrected_value JSONB NOT NULL,
|
|
||||||
reason TEXT,
|
|
||||||
corrected_by UUID,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS log_corrections_dose_idx ON log_corrections (dose_id);
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
CREATE TABLE IF NOT EXISTS ai_usage (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
family_id UUID,
|
|
||||||
user_id UUID,
|
|
||||||
intent TEXT, -- structured_query | memory_search | general_parenting | medical_redirect
|
|
||||||
model_used TEXT,
|
|
||||||
prompt_tokens INTEGER,
|
|
||||||
completion_tokens INTEGER,
|
|
||||||
total_tokens INTEGER,
|
|
||||||
cost_estimate_paise NUMERIC(10,4), -- rough estimate for monitoring
|
|
||||||
duration_ms INTEGER,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ai_usage_family_idx ON ai_usage (family_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS ai_usage_created_idx ON ai_usage (created_at DESC);
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
CREATE TABLE IF NOT EXISTS milestone_achievements (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
child_id UUID NOT NULL REFERENCES children(id) ON DELETE CASCADE,
|
|
||||||
family_id UUID NOT NULL,
|
|
||||||
milestone_key TEXT NOT NULL,
|
|
||||||
achieved_at DATE NOT NULL,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE(child_id, milestone_key)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS milestone_child_idx ON milestone_achievements (child_id);
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
CREATE TABLE IF NOT EXISTS member_profiles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE,
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
bio TEXT,
|
|
||||||
avatar_url TEXT,
|
|
||||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recommended_products (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES member_profiles(id) ON DELETE CASCADE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
image_url TEXT,
|
|
||||||
category TEXT NOT NULL DEFAULT 'general',
|
|
||||||
display_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS product_clicks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
product_id UUID NOT NULL REFERENCES recommended_products(id) ON DELETE CASCADE,
|
|
||||||
clicked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
referrer TEXT,
|
|
||||||
ip_hash TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS member_profiles_slug_idx ON member_profiles (slug);
|
|
||||||
CREATE INDEX IF NOT EXISTS recommended_products_profile_idx ON recommended_products (profile_id, display_order);
|
|
||||||
CREATE INDEX IF NOT EXISTS product_clicks_product_idx ON product_clicks (product_id, clicked_at DESC);
|
|
||||||
55
drizzle/README.md
Normal file
55
drizzle/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Tia — Database Migrations
|
||||||
|
|
||||||
|
This folder is **source code** and is committed to git. It is consumed by the
|
||||||
|
deploy pipeline (`pnpm db:migrate`, run on container start — see `Dockerfile`).
|
||||||
|
|
||||||
|
## Baseline reset — 2026-05-19
|
||||||
|
|
||||||
|
The project's first 16 migrations (`0000`–`0015`) plus a `manual/` folder were
|
||||||
|
hand-rolled SQL applied directly via the Dokploy database terminal. They were
|
||||||
|
**never** run through Drizzle's migrator, so:
|
||||||
|
|
||||||
|
- prod had no `__drizzle_migrations` tracking table;
|
||||||
|
- the `drizzle/` folder was gitignored, so migration SQL never reached the server;
|
||||||
|
- `schema.ts` had drifted well behind the real production schema.
|
||||||
|
|
||||||
|
To fix this we performed a **Path A baseline reset**:
|
||||||
|
|
||||||
|
1. `pg_dump` backup of prod taken and stored off-server.
|
||||||
|
2. `drizzle-kit pull` introspected the live prod schema (35 tables).
|
||||||
|
3. `src/db/schema/*.ts` was rewritten to match prod exactly.
|
||||||
|
4. Legacy migrations were archived to `_archived_pre_baseline_2026-05-19/`
|
||||||
|
(also retained in git history).
|
||||||
|
5. A single fresh baseline — `0000_baseline_prod_2026_05_19.sql` — was generated
|
||||||
|
and **verified column-for-column against the introspected prod schema**.
|
||||||
|
6. Prod's `drizzle.__drizzle_migrations` table was created and seeded with one
|
||||||
|
row marking `0000_baseline_prod_2026_05_19` as already applied, so the
|
||||||
|
migrator treats prod as up-to-date and runs nothing on the next deploy.
|
||||||
|
|
||||||
|
## Normal workflow from here
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Edit src/db/schema/*.ts
|
||||||
|
# 2. Generate a migration from the diff:
|
||||||
|
pnpm db:generate # writes drizzle/000N_<name>.sql
|
||||||
|
# 3. Review the generated SQL by eye.
|
||||||
|
# 4. Apply locally against the dev DB:
|
||||||
|
pnpm db:migrate
|
||||||
|
# 5. Commit schema + migration together, then push.
|
||||||
|
# Dokploy redeploys; the migrator applies it in prod on container start.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- **Never** edit a migration file after it has been pushed. Fix-forward with a
|
||||||
|
new migration instead.
|
||||||
|
- **Never** run schema-changing SQL directly against prod. It becomes drift.
|
||||||
|
- The `drizzle/` folder must stay **out** of `.gitignore`.
|
||||||
|
|
||||||
|
## RLS policies
|
||||||
|
|
||||||
|
Five log tables (`feeds`, `diapers_logs`, `sleeps`, `vaccinations`, `growth`)
|
||||||
|
plus `children` / `family_members` carry row-level-security policies in prod.
|
||||||
|
These are **not** modelled in the `pgTable` definitions and are managed
|
||||||
|
separately in the database. Drizzle migrations will not recreate them — keep
|
||||||
|
that in mind if you ever rebuild the DB from scratch.
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
-- Create audit_log table
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID,
|
|
||||||
family_id UUID,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
metadata JSONB,
|
|
||||||
ip_address TEXT,
|
|
||||||
user_agent TEXT,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create index for queries
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_family ON audit_log(family_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- Create password_resets table
|
|
||||||
CREATE TABLE IF NOT EXISTS password_resets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
token TEXT UNIQUE NOT NULL,
|
|
||||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
||||||
used_at TIMESTAMP WITH TIME ZONE
|
|
||||||
);
|
|
||||||
2898
drizzle/meta/0000_snapshot.json
Normal file
2898
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779518962214,
|
||||||
|
"tag": "0000_baseline_prod_2026_05_19",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,12 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:pull": "drizzle-kit pull",
|
||||||
|
"db:build-migrator": "node scripts/build-migrator.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/drizzle-adapter": "^1.11.2",
|
"@auth/drizzle-adapter": "^1.11.2",
|
||||||
|
|
@ -41,6 +46,7 @@
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/sharp": "^0.32.0",
|
"@types/sharp": "^0.32.0",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"esbuild": "^0.25.12",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|
|
||||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
|
|
@ -46,7 +46,7 @@ importers:
|
||||||
version: 5.0.0-beta.31(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.13)(react@19.2.4)
|
version: 5.0.0-beta.31(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.13)(react@19.2.4)
|
||||||
next-pwa:
|
next-pwa:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.6.0(@babel/core@7.29.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2)
|
version: 5.6.0(@babel/core@7.29.0)(esbuild@0.25.12)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2(esbuild@0.25.12))
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^7.0.13
|
specifier: ^7.0.13
|
||||||
version: 7.0.13
|
version: 7.0.13
|
||||||
|
|
@ -102,6 +102,9 @@ importers:
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.31.10
|
specifier: ^0.31.10
|
||||||
version: 0.31.10
|
version: 0.31.10
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.25.12
|
||||||
|
version: 0.25.12
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
|
|
@ -6302,14 +6305,14 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
|
|
||||||
babel-loader@8.4.1(@babel/core@7.29.0)(webpack@5.106.2):
|
babel-loader@8.4.1(@babel/core@7.29.0)(webpack@5.106.2(esbuild@0.25.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
find-cache-dir: 3.3.2
|
find-cache-dir: 3.3.2
|
||||||
loader-utils: 2.0.4
|
loader-utils: 2.0.4
|
||||||
make-dir: 3.1.0
|
make-dir: 3.1.0
|
||||||
schema-utils: 2.7.1
|
schema-utils: 2.7.1
|
||||||
webpack: 5.106.2
|
webpack: 5.106.2(esbuild@0.25.12)
|
||||||
|
|
||||||
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0):
|
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -6417,10 +6420,10 @@ snapshots:
|
||||||
|
|
||||||
chrome-trace-event@1.0.4: {}
|
chrome-trace-event@1.0.4: {}
|
||||||
|
|
||||||
clean-webpack-plugin@4.0.0(webpack@5.106.2):
|
clean-webpack-plugin@4.0.0(webpack@5.106.2(esbuild@0.25.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
del: 4.1.1
|
del: 4.1.1
|
||||||
webpack: 5.106.2
|
webpack: 5.106.2(esbuild@0.25.12)
|
||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
|
@ -7318,14 +7321,14 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
nodemailer: 7.0.13
|
nodemailer: 7.0.13
|
||||||
|
|
||||||
next-pwa@5.6.0(@babel/core@7.29.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2):
|
next-pwa@5.6.0(@babel/core@7.29.0)(esbuild@0.25.12)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2(esbuild@0.25.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
babel-loader: 8.4.1(@babel/core@7.29.0)(webpack@5.106.2)
|
babel-loader: 8.4.1(@babel/core@7.29.0)(webpack@5.106.2(esbuild@0.25.12))
|
||||||
clean-webpack-plugin: 4.0.0(webpack@5.106.2)
|
clean-webpack-plugin: 4.0.0(webpack@5.106.2(esbuild@0.25.12))
|
||||||
globby: 11.1.0
|
globby: 11.1.0
|
||||||
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
terser-webpack-plugin: 5.6.0(webpack@5.106.2)
|
terser-webpack-plugin: 5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12))
|
||||||
workbox-webpack-plugin: 6.6.0(webpack@5.106.2)
|
workbox-webpack-plugin: 6.6.0(webpack@5.106.2(esbuild@0.25.12))
|
||||||
workbox-window: 6.6.0
|
workbox-window: 6.6.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
|
|
@ -7885,13 +7888,15 @@ snapshots:
|
||||||
type-fest: 0.16.0
|
type-fest: 0.16.0
|
||||||
unique-string: 2.0.0
|
unique-string: 2.0.0
|
||||||
|
|
||||||
terser-webpack-plugin@5.6.0(webpack@5.106.2):
|
terser-webpack-plugin@5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
jest-worker: 27.5.1
|
jest-worker: 27.5.1
|
||||||
schema-utils: 4.3.3
|
schema-utils: 4.3.3
|
||||||
terser: 5.47.1
|
terser: 5.47.1
|
||||||
webpack: 5.106.2
|
webpack: 5.106.2(esbuild@0.25.12)
|
||||||
|
optionalDependencies:
|
||||||
|
esbuild: 0.25.12
|
||||||
|
|
||||||
terser@5.47.1:
|
terser@5.47.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -8049,7 +8054,7 @@ snapshots:
|
||||||
|
|
||||||
webpack-sources@3.4.1: {}
|
webpack-sources@3.4.1: {}
|
||||||
|
|
||||||
webpack@5.106.2:
|
webpack@5.106.2(esbuild@0.25.12):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint-scope': 3.7.7
|
'@types/eslint-scope': 3.7.7
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
|
|
@ -8072,7 +8077,7 @@ snapshots:
|
||||||
neo-async: 2.6.2
|
neo-async: 2.6.2
|
||||||
schema-utils: 4.3.3
|
schema-utils: 4.3.3
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
terser-webpack-plugin: 5.6.0(webpack@5.106.2)
|
terser-webpack-plugin: 5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12))
|
||||||
watchpack: 2.5.1
|
watchpack: 2.5.1
|
||||||
webpack-sources: 3.4.1
|
webpack-sources: 3.4.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
@ -8244,12 +8249,12 @@ snapshots:
|
||||||
|
|
||||||
workbox-sw@6.6.0: {}
|
workbox-sw@6.6.0: {}
|
||||||
|
|
||||||
workbox-webpack-plugin@6.6.0(webpack@5.106.2):
|
workbox-webpack-plugin@6.6.0(webpack@5.106.2(esbuild@0.25.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-json-stable-stringify: 2.1.0
|
fast-json-stable-stringify: 2.1.0
|
||||||
pretty-bytes: 5.6.0
|
pretty-bytes: 5.6.0
|
||||||
upath: 1.2.0
|
upath: 1.2.0
|
||||||
webpack: 5.106.2
|
webpack: 5.106.2(esbuild@0.25.12)
|
||||||
webpack-sources: 1.4.3
|
webpack-sources: 1.4.3
|
||||||
workbox-build: 6.6.0
|
workbox-build: 6.6.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
|
||||||
30
scripts/build-migrator.mjs
Normal file
30
scripts/build-migrator.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* Bundles src/db/migrate.ts into a single self-contained dist/migrate.mjs.
|
||||||
|
*
|
||||||
|
* Why: the Next.js standalone production image does NOT keep drizzle-orm or
|
||||||
|
* postgres as separate node_modules packages (Next traces them straight into
|
||||||
|
* server.js). The migration runner is a separate entrypoint, so it needs its
|
||||||
|
* own bundle with those deps inlined. esbuild does exactly that.
|
||||||
|
*
|
||||||
|
* esbuild is already available as a transitive dependency of drizzle-kit/tsx,
|
||||||
|
* so this adds no new package to the project.
|
||||||
|
*
|
||||||
|
* Run via: pnpm db:build-migrator (invoked automatically inside the build).
|
||||||
|
*/
|
||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: ["src/db/migrate.ts"],
|
||||||
|
bundle: true, // inline drizzle-orm + postgres into the output
|
||||||
|
platform: "node",
|
||||||
|
target: "node22",
|
||||||
|
format: "esm",
|
||||||
|
outfile: "dist/migrate.mjs",
|
||||||
|
// postgres ships optional native bits; keep it bundled but let node resolve
|
||||||
|
// built-ins normally. No externals — we want a fully standalone file.
|
||||||
|
banner: {
|
||||||
|
js: "// AUTO-GENERATED by scripts/build-migrator.mjs — do not edit.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[build-migrator] dist/migrate.mjs written.");
|
||||||
43
src/db/migrate.ts
Normal file
43
src/db/migrate.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* Standalone database migration runner.
|
||||||
|
*
|
||||||
|
* This runs as its OWN process on container startup, before the Next.js
|
||||||
|
* server boots (see Dockerfile CMD). It is intentionally separate from
|
||||||
|
* src/db/index.ts: that module configures a long-lived connection POOL for
|
||||||
|
* the running app; migrations want a single short-lived connection that is
|
||||||
|
* closed the moment they finish.
|
||||||
|
*
|
||||||
|
* Build note: this file is bundled by scripts/build-migrator.mjs into a
|
||||||
|
* self-contained dist/migrate.mjs (drizzle-orm + postgres inlined) so the
|
||||||
|
* production image needs no extra runtime dependencies.
|
||||||
|
*/
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("[migrate] DATABASE_URL is not set — aborting.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// max: 1 — a migration run is sequential; a single connection is correct
|
||||||
|
// and avoids holding pool slots. The connection is closed in finally{}.
|
||||||
|
const client = postgres(url, { max: 1 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[migrate] applying pending migrations...");
|
||||||
|
await migrate(drizzle(client), { migrationsFolder: "./drizzle" });
|
||||||
|
console.log("[migrate] done — schema is up to date.");
|
||||||
|
} catch (err) {
|
||||||
|
// Fail LOUD. If migration fails we must NOT let the app boot against a
|
||||||
|
// half-migrated schema — a crashed container is a visible, safe failure.
|
||||||
|
console.error("[migrate] FAILED:", err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
57
src/db/schema/admin.ts
Normal file
57
src/db/schema/admin.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
check,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin / auth-extra schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: admins, admin_sessions, password_resets
|
||||||
|
// (legacy migrations 0006_admin_auth, 0009_admin_sessions, 0004's password_resets).
|
||||||
|
//
|
||||||
|
// SECURITY NOTE: `admins` is the privileged back-office account table, fully
|
||||||
|
// separate from the `users` table. Password hashes here gate admin access —
|
||||||
|
// treat any change to this table as security-critical.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const admins = pgTable(
|
||||||
|
"admins",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
username: varchar("username", { length: 50 }).notNull().unique(),
|
||||||
|
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
||||||
|
role: varchar("role", { length: 20 }).default("admin"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
lastLogin: timestamp("last_login", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check(
|
||||||
|
"admins_role_check",
|
||||||
|
sql`(role)::text = ANY (ARRAY['super_admin','admin','support'])`
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const adminSessions = pgTable("admin_sessions", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
adminId: uuid("admin_id").notNull(),
|
||||||
|
sessionToken: text("session_token").notNull().unique(),
|
||||||
|
expires: timestamp("expires", { withTimezone: true }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const passwordResets = pgTable("password_resets", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id").notNull(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Admin = typeof admins.$inferSelect;
|
||||||
|
export type AdminSession = typeof adminSessions.$inferSelect;
|
||||||
|
export type PasswordReset = typeof passwordResets.$inferSelect;
|
||||||
70
src/db/schema/affiliate.ts
Normal file
70
src/db/schema/affiliate.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
boolean,
|
||||||
|
integer,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Affiliate schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: member_profiles, recommended_products, product_clicks
|
||||||
|
// (legacy migration 0015_affiliate).
|
||||||
|
//
|
||||||
|
// Backs the public affiliate product page at tia.<domain>/m/[slug].
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const memberProfiles = pgTable(
|
||||||
|
"member_profiles",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id").notNull().unique(),
|
||||||
|
familyId: uuid("family_id").notNull(),
|
||||||
|
slug: text("slug").notNull(),
|
||||||
|
displayName: text("display_name").notNull(),
|
||||||
|
bio: text("bio"),
|
||||||
|
avatarUrl: text("avatar_url"),
|
||||||
|
isPublic: boolean("is_public").default(false).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("member_profiles_slug_idx").on(table.slug)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const recommendedProducts = pgTable(
|
||||||
|
"recommended_products",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
profileId: uuid("profile_id").notNull(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
category: text("category").default("general").notNull(),
|
||||||
|
displayOrder: integer("display_order").default(0).notNull(),
|
||||||
|
isActive: boolean("is_active").default(true).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("recommended_products_profile_idx").on(table.profileId, table.displayOrder),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click tracking — ipHash is a hash, never a raw IP (privacy).
|
||||||
|
export const productClicks = pgTable(
|
||||||
|
"product_clicks",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
productId: uuid("product_id").notNull(),
|
||||||
|
clickedAt: timestamp("clicked_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
referrer: text("referrer"),
|
||||||
|
ipHash: text("ip_hash"),
|
||||||
|
},
|
||||||
|
(table) => [index("product_clicks_product_idx").on(table.productId, table.clickedAt)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MemberProfile = typeof memberProfiles.$inferSelect;
|
||||||
|
export type RecommendedProduct = typeof recommendedProducts.$inferSelect;
|
||||||
|
export type ProductClick = typeof productClicks.$inferSelect;
|
||||||
60
src/db/schema/ai.ts
Normal file
60
src/db/schema/ai.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
integer,
|
||||||
|
numeric,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: chat_sessions, chat_messages, ai_usage
|
||||||
|
// (legacy migrations 0004_chat_sessions, 0013_ai_usage).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const chatSessions = pgTable("chat_sessions", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
title: varchar("title", { length: 255 }).default("New conversation").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chatMessages = pgTable("chat_messages", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
sessionId: uuid("session_id").notNull(),
|
||||||
|
role: varchar("role", { length: 20 }).notNull(),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token + cost accounting for the LiteLLM-gateway AI calls.
|
||||||
|
// cost_estimate_paise stores cost in Indian paise (1 INR = 100 paise) so
|
||||||
|
// fractional model costs stay exact without float drift.
|
||||||
|
export const aiUsage = pgTable(
|
||||||
|
"ai_usage",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
familyId: uuid("family_id"),
|
||||||
|
userId: uuid("user_id"),
|
||||||
|
intent: text("intent"),
|
||||||
|
modelUsed: text("model_used"),
|
||||||
|
promptTokens: integer("prompt_tokens"),
|
||||||
|
completionTokens: integer("completion_tokens"),
|
||||||
|
totalTokens: integer("total_tokens"),
|
||||||
|
costEstimatePaise: numeric("cost_estimate_paise", { precision: 10, scale: 4 }),
|
||||||
|
durationMs: integer("duration_ms"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("ai_usage_created_idx").on(table.createdAt),
|
||||||
|
index("ai_usage_family_idx").on(table.familyId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ChatSession = typeof chatSessions.$inferSelect;
|
||||||
|
export type ChatMessage = typeof chatMessages.$inferSelect;
|
||||||
|
export type AiUsage = typeof aiUsage.$inferSelect;
|
||||||
|
|
@ -1,13 +1,60 @@
|
||||||
import { pgTable } from "drizzle-orm/pg-core";
|
import {
|
||||||
import { text, timestamp, uuid } from "drizzle-orm/pg-core";
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const auditLog = pgTable("audit_log", {
|
// ---------------------------------------------------------------------------
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
// Audit schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
userId: uuid("user_id"),
|
//
|
||||||
familyId: uuid("family_id"),
|
// Drift corrected at baseline:
|
||||||
action: text("action").notNull(),
|
// - audit_log: added resource_id / resource_type (legacy 0010_audit_log);
|
||||||
metadata: text("metadata"), // JSON string
|
// metadata is JSONB (was modelled as text); action is varchar(50)
|
||||||
ipAddress: text("ip_address"),
|
// - log_corrections: append-only-with-corrections record, was missing here
|
||||||
userAgent: text("user_agent"),
|
// ---------------------------------------------------------------------------
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
||||||
});
|
export const auditLog = pgTable(
|
||||||
|
"audit_log",
|
||||||
|
{
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
familyId: uuid("family_id"),
|
||||||
|
userId: uuid("user_id"),
|
||||||
|
action: varchar("action", { length: 50 }).notNull(),
|
||||||
|
resourceType: varchar("resource_type", { length: 50 }),
|
||||||
|
resourceId: uuid("resource_id"),
|
||||||
|
ipAddress: varchar("ip_address", { length: 45 }),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
|
metadata: jsonb("metadata").default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("audit_family_idx").on(table.familyId, table.createdAt),
|
||||||
|
index("idx_audit_log_action").on(table.action),
|
||||||
|
index("idx_audit_log_created").on(table.createdAt),
|
||||||
|
index("idx_audit_log_family").on(table.familyId),
|
||||||
|
index("idx_audit_log_user").on(table.userId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Append-only correction trail for medical dose logs.
|
||||||
|
export const logCorrections = pgTable(
|
||||||
|
"log_corrections",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
familyId: uuid("family_id").notNull(),
|
||||||
|
doseId: uuid("dose_id").notNull(),
|
||||||
|
originalValue: jsonb("original_value").notNull(),
|
||||||
|
correctedValue: jsonb("corrected_value").notNull(),
|
||||||
|
reason: text("reason"),
|
||||||
|
correctedBy: uuid("corrected_by"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("log_corrections_dose_idx").on(table.doseId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AuditLog = typeof auditLog.$inferSelect;
|
||||||
|
export type LogCorrection = typeof logCorrections.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
|
import { pgTable, text, timestamp, uuid, varchar, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Source of truth: drizzle-kit pull against tia_prod.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Users table
|
// Users table
|
||||||
|
// NOTE: password_hash / password_updated_at were added by the legacy
|
||||||
|
// 0008_user_passwords migration and were missing from this file until baseline.
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
|
|
@ -9,6 +16,8 @@ export const users = pgTable("users", {
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
passwordHash: varchar("password_hash", { length: 255 }),
|
||||||
|
passwordUpdatedAt: timestamp("password_updated_at", { withTimezone: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Accounts table (for OAuth providers)
|
// Accounts table (for OAuth providers)
|
||||||
|
|
@ -46,4 +55,4 @@ export const verificationTokens = pgTable("verification_tokens", {
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type Account = typeof accounts.$inferSelect;
|
export type Account = typeof accounts.$inferSelect;
|
||||||
export type Session = typeof sessions.$inferSelect;
|
export type Session = typeof sessions.$inferSelect;
|
||||||
export type VerificationToken = typeof verificationTokens.$inferSelect;
|
export type VerificationToken = typeof verificationTokens.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,25 @@ import {
|
||||||
timestamp,
|
timestamp,
|
||||||
uuid,
|
uuid,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
|
varchar,
|
||||||
|
integer,
|
||||||
|
date,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
index,
|
index,
|
||||||
jsonb,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Family schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Source of truth: drizzle-kit pull against tia_prod.
|
||||||
|
//
|
||||||
|
// Drift corrected at baseline:
|
||||||
|
// - families: added tier / maxChildren / maxMembers (legacy 0005)
|
||||||
|
// - children: col is `stage` not `current_stage`; `image_url` not
|
||||||
|
// `profile_photo_url`; dropped phantom `stage_overrides`;
|
||||||
|
// birth_date is DATE; sex is NULLABLE
|
||||||
|
// - family_members: dropped phantom `display_name`
|
||||||
|
// - family_invites: dropped phantom `display_name` / `accepted_at`
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Enums
|
// Enums
|
||||||
export const memberRoleEnum = pgEnum("member_role", ["admin", "caregiver", "viewer"]);
|
export const memberRoleEnum = pgEnum("member_role", ["admin", "caregiver", "viewer"]);
|
||||||
|
|
@ -25,7 +39,11 @@ export const childStageEnum = pgEnum("child_stage", [
|
||||||
// Families table
|
// Families table
|
||||||
export const families = pgTable("families", {
|
export const families = pgTable("families", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: text("name").notNull().default("The Gupta Family"),
|
name: text("name").notNull(),
|
||||||
|
// Tier system (legacy migration 0005_tier_system).
|
||||||
|
tier: varchar("tier", { length: 20 }).default("free"),
|
||||||
|
maxChildren: integer("max_children").default(1),
|
||||||
|
maxMembers: integer("max_members").default(2),
|
||||||
pediatricianPhone: text("pediatrician_phone"),
|
pediatricianPhone: text("pediatrician_phone"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
|
@ -39,24 +57,24 @@ export const familyMembers = pgTable(
|
||||||
familyId: uuid("family_id").notNull(),
|
familyId: uuid("family_id").notNull(),
|
||||||
userId: uuid("user_id").notNull(),
|
userId: uuid("user_id").notNull(),
|
||||||
role: memberRoleEnum("role").notNull().default("caregiver"),
|
role: memberRoleEnum("role").notNull().default("caregiver"),
|
||||||
displayName: text("display_name").notNull(),
|
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("family_user_unique").on(table.familyId, table.userId)]
|
(table) => [uniqueIndex("family_members_family_id_user_id_key").on(table.familyId, table.userId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Children table
|
// Children table
|
||||||
|
// NOTE (2b): the DB column is `stage`. We keep the JS-friendly key
|
||||||
|
// `currentStage` mapped onto it so application code reads naturally.
|
||||||
export const children = pgTable(
|
export const children = pgTable(
|
||||||
"children",
|
"children",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
familyId: uuid("family_id").notNull(),
|
familyId: uuid("family_id").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
birthDate: timestamp("birth_date").notNull(),
|
birthDate: date("birth_date").notNull(),
|
||||||
sex: childSexEnum("sex").notNull(),
|
sex: childSexEnum("sex"), // nullable in prod
|
||||||
currentStage: childStageEnum("current_stage"),
|
currentStage: childStageEnum("stage").notNull().default("newborn"),
|
||||||
stageOverrides: jsonb("stage_overrides").$type<Record<string, unknown>>().default({}),
|
imageUrl: text("image_url"),
|
||||||
profilePhotoUrl: text("profile_photo_url"),
|
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
|
|
@ -70,11 +88,9 @@ export const familyInvites = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
familyId: uuid("family_id").notNull(),
|
familyId: uuid("family_id").notNull(),
|
||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
role: memberRoleEnum("role").notNull(),
|
role: memberRoleEnum("role").notNull().default("viewer"),
|
||||||
displayName: text("display_name").notNull(),
|
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
acceptedAt: timestamp("accepted_at"),
|
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("invite_token_idx").on(table.token)]
|
(table) => [uniqueIndex("invite_token_idx").on(table.token)]
|
||||||
|
|
@ -99,4 +115,4 @@ export function deriveStage(birthDate: Date): (typeof childStageEnum)["enumValue
|
||||||
if (months < 24) return "toddler_early";
|
if (months < 24) return "toddler_early";
|
||||||
if (months < 36) return "toddler_late";
|
if (months < 36) return "toddler_late";
|
||||||
return "preschool";
|
return "preschool";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,17 @@
|
||||||
|
// Barrel export for the full Drizzle schema.
|
||||||
|
// Every schema file MUST be re-exported here so `import * as schema`
|
||||||
|
// in src/db/index.ts sees the complete table set.
|
||||||
|
//
|
||||||
|
// BUG FIXED AT BASELINE: ./logs was previously NOT exported here, so
|
||||||
|
// Drizzle never knew about feeds / sleeps / vaccinations / growth /
|
||||||
|
// medications / diapers. That is why those tables drifted unnoticed.
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./family";
|
export * from "./family";
|
||||||
export * from "./audit";
|
export * from "./audit";
|
||||||
export * from "./media";
|
export * from "./media";
|
||||||
|
export * from "./logs";
|
||||||
|
export * from "./medical";
|
||||||
|
export * from "./admin";
|
||||||
|
export * from "./support";
|
||||||
|
export * from "./ai";
|
||||||
|
export * from "./affiliate";
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,36 @@
|
||||||
import { pgTable, pgEnum, uuid, timestamp, text, integer, boolean, date, real } from "drizzle-orm/pg-core";
|
import {
|
||||||
|
pgTable,
|
||||||
|
pgEnum,
|
||||||
|
uuid,
|
||||||
|
timestamp,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
boolean,
|
||||||
|
date,
|
||||||
|
real,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
import { children as childrenTable } from "./family";
|
import { children as childrenTable } from "./family";
|
||||||
|
|
||||||
const children = childrenTable;
|
const children = childrenTable;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logs schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
//
|
||||||
|
// Drift corrected at baseline:
|
||||||
|
// - `diapers` table renamed to `diapersLogs` (DB table is `diapers_logs`).
|
||||||
|
// The app already writes to diapers_logs via raw SQL; the old `diapers`
|
||||||
|
// pgTable was dead/phantom.
|
||||||
|
// - sleeps.startedAt is NULLABLE in prod.
|
||||||
|
// - milestone_achievements added (legacy 0014_milestones).
|
||||||
|
//
|
||||||
|
// RLS NOTE: feeds / diapers_logs / sleeps / vaccinations / growth carry a
|
||||||
|
// `family_isolation` row-level-security policy in prod. RLS policies are not
|
||||||
|
// modelled in these pgTable definitions — they live in the DB and are managed
|
||||||
|
// separately. Do not assume Drizzle will recreate them.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Feed types
|
// Feed types
|
||||||
export const feedType = pgEnum("feed_type", ["breast_milk", "formula", "solid", "water", "other"]);
|
export const feedType = pgEnum("feed_type", ["breast_milk", "formula", "solid", "water", "other"]);
|
||||||
export const feedMethod = pgEnum("feed_method", ["bottle", "breast_left", "breast_right", "breast_both", "cup", "spoon", "finger", "self"]);
|
export const feedMethod = pgEnum("feed_method", ["bottle", "breast_left", "breast_right", "breast_both", "cup", "spoon", "finger", "self"]);
|
||||||
|
|
@ -25,8 +53,8 @@ export const feeds = pgTable("feeds", {
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logs: diapers
|
// Logs: diapers (DB table: diapers_logs)
|
||||||
export const diapers = pgTable("diapers", {
|
export const diapersLogs = pgTable("diapers_logs", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
childId: uuid("child_id").references(() => children.id).notNull(),
|
childId: uuid("child_id").references(() => children.id).notNull(),
|
||||||
type: diaperType("type").notNull(),
|
type: diaperType("type").notNull(),
|
||||||
|
|
@ -40,7 +68,7 @@ export const sleeps = pgTable("sleeps", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
childId: uuid("child_id").references(() => children.id).notNull(),
|
childId: uuid("child_id").references(() => children.id).notNull(),
|
||||||
type: sleepType("type").notNull(),
|
type: sleepType("type").notNull(),
|
||||||
startedAt: timestamp("started_at").notNull(),
|
startedAt: timestamp("started_at"), // nullable in prod
|
||||||
endedAt: timestamp("ended_at"),
|
endedAt: timestamp("ended_at"),
|
||||||
durationMinutes: integer("duration_minutes"),
|
durationMinutes: integer("duration_minutes"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
|
@ -74,7 +102,7 @@ export const growth = pgTable("growth", {
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logs: medications
|
// Logs: medications (the prescription record; doses are in medical.ts)
|
||||||
export const medications = pgTable("medications", {
|
export const medications = pgTable("medications", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
childId: uuid("child_id").references(() => children.id).notNull(),
|
childId: uuid("child_id").references(() => children.id).notNull(),
|
||||||
|
|
@ -88,10 +116,32 @@ export const medications = pgTable("medications", {
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Milestone achievements (legacy 0014_milestones)
|
||||||
|
export const milestoneAchievements = pgTable(
|
||||||
|
"milestone_achievements",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
familyId: uuid("family_id").notNull(),
|
||||||
|
milestoneKey: text("milestone_key").notNull(),
|
||||||
|
achievedAt: date("achieved_at").notNull(),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("milestone_child_idx").on(table.childId),
|
||||||
|
uniqueIndex("milestone_achievements_child_milestone_unique").on(
|
||||||
|
table.childId,
|
||||||
|
table.milestoneKey
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// Type exports
|
// Type exports
|
||||||
export type Feed = typeof feeds.$inferSelect;
|
export type Feed = typeof feeds.$inferSelect;
|
||||||
export type Diaper = typeof diapers.$inferSelect;
|
export type DiaperLog = typeof diapersLogs.$inferSelect;
|
||||||
export type Sleep = typeof sleeps.$inferSelect;
|
export type Sleep = typeof sleeps.$inferSelect;
|
||||||
export type Vaccination = typeof vaccinations.$inferSelect;
|
export type Vaccination = typeof vaccinations.$inferSelect;
|
||||||
export type Growth = typeof growth.$inferSelect;
|
export type Growth = typeof growth.$inferSelect;
|
||||||
export type Medication = typeof medications.$inferSelect;
|
export type Medication = typeof medications.$inferSelect;
|
||||||
|
export type MilestoneAchievement = typeof milestoneAchievements.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,19 @@ import {
|
||||||
uuid,
|
uuid,
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
|
vector,
|
||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Media schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
//
|
||||||
|
// Drift corrected at baseline:
|
||||||
|
// - memories: vision_tags (text[]) and vision_embedding (vector(1536)) are
|
||||||
|
// now modelled with Drizzle's native column types instead of being
|
||||||
|
// "raw SQL only". The ivfflat index on the embedding is declared too.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Processing states for background jobs
|
// Processing states for background jobs
|
||||||
export type ProcessingStatus = "uploading" | "processing" | "ready" | "failed";
|
export type ProcessingStatus = "uploading" | "processing" | "ready" | "failed";
|
||||||
|
|
||||||
|
|
@ -19,7 +29,7 @@ export const memories = pgTable(
|
||||||
childId: uuid("child_id"),
|
childId: uuid("child_id"),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
takenAt: timestamp("taken_at"),
|
takenAt: timestamp("taken_at", { withTimezone: true }),
|
||||||
r2Key: text("r2_key").notNull(),
|
r2Key: text("r2_key").notNull(),
|
||||||
r2ThumbnailKey: text("r2_thumbnail_key"),
|
r2ThumbnailKey: text("r2_thumbnail_key"),
|
||||||
mimeType: text("mime_type"),
|
mimeType: text("mime_type"),
|
||||||
|
|
@ -27,17 +37,24 @@ export const memories = pgTable(
|
||||||
width: integer("width"),
|
width: integer("width"),
|
||||||
height: integer("height"),
|
height: integer("height"),
|
||||||
visionCaption: text("vision_caption"),
|
visionCaption: text("vision_caption"),
|
||||||
// vision_tags is text[] in DB — handled with raw SQL
|
visionTags: text("vision_tags").array(),
|
||||||
// vision_embedding is vector(1536) in DB — handled with raw SQL
|
visionEmbedding: vector("vision_embedding", { dimensions: 1536 }),
|
||||||
isPrivate: boolean("is_private").default(false).notNull(),
|
isPrivate: boolean("is_private").default(false).notNull(),
|
||||||
processingStatus: text("processing_status").$type<ProcessingStatus>().default("uploading").notNull(),
|
processingStatus: text("processing_status")
|
||||||
|
.$type<ProcessingStatus>()
|
||||||
|
.default("uploading")
|
||||||
|
.notNull(),
|
||||||
uploadedBy: uuid("uploaded_by"),
|
uploadedBy: uuid("uploaded_by"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("memories_family_idx").on(table.familyId),
|
index("memories_family_idx").on(table.familyId),
|
||||||
index("memories_child_idx").on(table.childId),
|
index("memories_child_idx").on(table.childId),
|
||||||
|
// ivfflat index for cosine similarity search over CLIP/vision embeddings.
|
||||||
|
index("memories_embedding_idx")
|
||||||
|
.using("ivfflat", table.visionEmbedding.op("vector_cosine_ops"))
|
||||||
|
.with({ lists: 100 }),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -52,11 +69,9 @@ export const attachments = pgTable(
|
||||||
mimeType: text("mime_type"),
|
mimeType: text("mime_type"),
|
||||||
sizeBytes: integer("size_bytes"),
|
sizeBytes: integer("size_bytes"),
|
||||||
uploadedBy: uuid("uploaded_by"),
|
uploadedBy: uuid("uploaded_by"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [index("attachments_family_idx").on(table.familyId)]
|
||||||
index("attachments_family_idx").on(table.familyId),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export type Memory = typeof memories.$inferSelect;
|
export type Memory = typeof memories.$inferSelect;
|
||||||
|
|
|
||||||
89
src/db/schema/medical.ts
Normal file
89
src/db/schema/medical.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
date,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Medical schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: medicines, medication_doses, allergies, illness_logs, doctor_visits
|
||||||
|
// (legacy migrations 0003_medical_data, 0012_medical_doses).
|
||||||
|
//
|
||||||
|
// Note: `medicines` here is the per-child medicine list with reminder times.
|
||||||
|
// `medications` (in logs.ts) is a separate prescription-record table that
|
||||||
|
// predates it. Both exist in prod; keep them distinct.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const medicines = pgTable("medicines", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
dose: varchar("dose", { length: 255 }),
|
||||||
|
notes: text("notes"),
|
||||||
|
reminderTime: varchar("reminder_time", { length: 10 }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append-only dose administration log. Corrections go via log_corrections.
|
||||||
|
export const medicationDoses = pgTable(
|
||||||
|
"medication_doses",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
medicineId: uuid("medicine_id").notNull(),
|
||||||
|
familyId: uuid("family_id").notNull(),
|
||||||
|
administeredAt: timestamp("administered_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
administeredBy: uuid("administered_by"),
|
||||||
|
amountGiven: text("amount_given"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("medication_doses_family_idx").on(table.familyId),
|
||||||
|
index("medication_doses_medicine_idx").on(table.medicineId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const allergies = pgTable("allergies", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
severity: varchar("severity", { length: 50 }).default("mild"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const illnessLogs = pgTable("illness_logs", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
startDate: date("start_date").notNull(),
|
||||||
|
endDate: date("end_date"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const doctorVisits = pgTable("doctor_visits", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
doctorName: varchar("doctor_name", { length: 255 }).notNull(),
|
||||||
|
reason: varchar("reason", { length: 255 }),
|
||||||
|
visitDate: date("visit_date").notNull(),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Medicine = typeof medicines.$inferSelect;
|
||||||
|
export type MedicationDose = typeof medicationDoses.$inferSelect;
|
||||||
|
export type Allergy = typeof allergies.$inferSelect;
|
||||||
|
export type IllnessLog = typeof illnessLogs.$inferSelect;
|
||||||
|
export type DoctorVisit = typeof doctorVisits.$inferSelect;
|
||||||
51
src/db/schema/support.ts
Normal file
51
src/db/schema/support.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
check,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Support schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: support_tickets, support_responses (legacy 0007_support_tickets).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const supportTickets = pgTable(
|
||||||
|
"support_tickets",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
familyId: uuid("family_id"),
|
||||||
|
userId: uuid("user_id"),
|
||||||
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
|
subject: varchar("subject", { length: 255 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
status: varchar("status", { length: 20 }).default("open"),
|
||||||
|
priority: varchar("priority", { length: 20 }).default("normal"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check(
|
||||||
|
"support_tickets_status_check",
|
||||||
|
sql`(status)::text = ANY (ARRAY['open','in_progress','resolved','closed'])`
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"support_tickets_priority_check",
|
||||||
|
sql`(priority)::text = ANY (ARRAY['low','normal','high','urgent'])`
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const supportResponses = pgTable("support_responses", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
ticketId: uuid("ticket_id"),
|
||||||
|
adminId: uuid("admin_id"),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SupportTicket = typeof supportTickets.$inferSelect;
|
||||||
|
export type SupportResponse = typeof supportResponses.$inferSelect;
|
||||||
Loading…
Add table
Reference in a new issue