Compare commits

..

4 commits

Author SHA1 Message Date
9b9b551463 feat(db): wire migration runner into the deploy pipeline
Makes schema changes deploy automatically: edit schema -> db:generate ->
commit -> push -> Dokploy redeploys -> migrations apply on container start.
No more Dokploy database terminal.

Components:
- src/db/migrate.ts: standalone migrator (single short-lived connection,
  fails loud on error so a bad migration crashes the container instead of
  letting the app serve a half-migrated schema)
- scripts/build-migrator.mjs: esbuild bundles migrate.ts -> dist/migrate.mjs
  with drizzle-orm + postgres inlined. Needed because Next.js standalone
  output keeps neither as a separate node_modules package.
- Dockerfile: builder runs db:build-migrator; runner copies migrate.mjs +
  drizzle/; CMD is 'node migrate.mjs && node server.js'
- package.json: db:generate / db:migrate / db:studio / db:pull /
  db:build-migrator scripts; esbuild promoted to an explicit devDependency
- pnpm-lock.yaml resynced

BUG FIX: .dockerignore had 'drizzle/' — migration SQL was excluded from the
build context, so even a correct Dockerfile COPY would have found nothing.
This was the second half (with the .gitignore bug in commit 1) of why the
migration pipeline never worked. Now only _archived/_introspected are
excluded.

Verified: full docker build succeeds; runner image contains migrate.mjs +
drizzle baseline; migrator tested end-to-end against a scratch DB (35
tables created, __drizzle_migrations populated, idempotent on rerun).
2026-05-23 13:40:30 +05:30
edd239fa69 chore(db): regenerate baseline migration from corrected schema
drizzle-kit generate against the now-prod-aligned schema produces a
single baseline migration covering all 35 tables.

VERIFIED: 0000_baseline_prod_2026_05_19.sql was compared column-for-column
and type-for-type against the drizzle-kit pull introspection of tia_prod.
Table sets identical, all columns and types match. The baseline is a
faithful representation of production.

This baseline will be marked as already-applied in prod's
__drizzle_migrations table (done out-of-band, not in git), so the migrator
runs nothing on the next deploy. It exists purely as the reference point
for future schema diffs.

Adds drizzle/README.md documenting the baseline reset and the migration
workflow going forward.
2026-05-23 12:25:20 +05:30
a3d3a140ed refactor(db/schema): align TypeScript schema with production
Path A baseline reconciliation. drizzle-kit pull against tia_prod showed
prod had drifted well past schema.ts because legacy hand-rolled migrations
0003-0015 wrote to the DB but were never reflected back into TypeScript.

Shared-table drift fixed:
- users:          + password_hash, + password_updated_at
- families:       + tier, + max_children, + max_members
- children:       col is 'stage' (kept JS key currentStage -> stage);
                  'image_url' not 'profile_photo_url'; birth_date is DATE;
                  sex nullable; dropped phantom stage_overrides
- family_members: dropped phantom display_name
- family_invites: dropped phantom display_name, accepted_at
- audit_log:      + resource_id, + resource_type; metadata -> jsonb; +5 indexes
- memories:       + vision_tags (text[]), + vision_embedding (vector 1536)
- logs.ts:        'diapers' phantom table renamed to diapersLogs ('diapers_logs')

19 missing tables added across new files:
- admin.ts:     admins, admin_sessions, password_resets
- support.ts:   support_tickets, support_responses
- ai.ts:        chat_sessions, chat_messages, ai_usage
- medical.ts:   medicines, medication_doses, allergies, illness_logs, doctor_visits
- affiliate.ts: member_profiles, recommended_products, product_clicks
- logs.ts:      + milestone_achievements
- audit.ts:     + log_corrections

BUG FIX: schema/index.ts never re-exported ./logs — Drizzle was blind to
feeds/sleeps/vaccinations/growth/medications. Now exported.

Verified: tsc --noEmit has zero non-test errors. Dropped phantom columns
confirmed to have zero references in src/.
2026-05-23 12:17:20 +05:30
e7d68c2fc6 chore(db): archive legacy migrations, stop gitignoring drizzle/
The drizzle/ folder was in .gitignore (line 34) — likely confused with
the build 'out/' dir. Effect: migration SQL never reached the server on
deploy, so the migration pipeline could never have worked. Only 7 of 18
files were ever force-tracked; 0000-0010 + most of manual/ were untracked.

- Remove drizzle/ from .gitignore; document why it must be tracked
- Archive legacy hand-rolled migrations 0000-0015 + manual/ to
  _archived_pre_baseline_2026-05-19/ (kept on disk; history retains old copies)
- Archive stale meta/ (knew of only 3 of 16 migrations)
- Baseline regeneration follows in subsequent commits
2026-05-23 12:05:50 +05:30
29 changed files with 4050 additions and 229 deletions

View file

@ -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
View file

@ -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/

View file

@ -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"]

View 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");

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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
View 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.

View file

@ -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);

View file

@ -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
);

File diff suppressed because it is too large Load diff

View 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
}
]
}

View file

@ -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
View file

@ -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:

View 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
View 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
View 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;

View 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
View 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;

View file

@ -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", { // ---------------------------------------------------------------------------
// Audit schema — aligned to PRODUCTION as of 2026-05-19 baseline.
//
// Drift corrected at baseline:
// - audit_log: added resource_id / resource_type (legacy 0010_audit_log);
// metadata is JSONB (was modelled as text); action is varchar(50)
// - log_corrections: append-only-with-corrections record, was missing here
// ---------------------------------------------------------------------------
export const auditLog = pgTable(
"audit_log",
{
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id"),
familyId: uuid("family_id"), familyId: uuid("family_id"),
action: text("action").notNull(), userId: uuid("user_id"),
metadata: text("metadata"), // JSON string action: varchar("action", { length: 50 }).notNull(),
ipAddress: text("ip_address"), resourceType: varchar("resource_type", { length: 50 }),
resourceId: uuid("resource_id"),
ipAddress: varchar("ip_address", { length: 45 }),
userAgent: text("user_agent"), userAgent: text("user_agent"),
createdAt: timestamp("created_at").defaultNow().notNull(), 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;

View file

@ -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)

View file

@ -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)]

View file

@ -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";

View file

@ -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;

View file

@ -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
View 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
View 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;