Compare commits
No commits in common. "9b9b551463a62dddc7671b0e419a419192e7d846" and "5fe24b8c5986d8613d6d3e14c30d8ce7bee975c0" have entirely different histories.
9b9b551463
...
5fe24b8c59
29 changed files with 229 additions and 4050 deletions
|
|
@ -6,11 +6,5 @@ README.md
|
|||
.docker
|
||||
docker-compose.dev.yml
|
||||
docker/
|
||||
drizzle/
|
||||
*.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,9 +7,6 @@
|
|||
/.next/
|
||||
/out/
|
||||
|
||||
# migrator build output (generated by scripts/build-migrator.mjs)
|
||||
/dist/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
|
|
@ -34,10 +31,4 @@ yarn-error.log*
|
|||
# docker
|
||||
.docker/
|
||||
data/
|
||||
|
||||
# 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/
|
||||
drizzle/
|
||||
13
Dockerfile
13
Dockerfile
|
|
@ -14,10 +14,6 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||
COPY . .
|
||||
RUN corepack enable pnpm
|
||||
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
|
||||
FROM node:22-alpine AS runner
|
||||
|
|
@ -28,15 +24,8 @@ RUN adduser --system --uid 1001 nextjs
|
|||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
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
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
# 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"]
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -1,430 +0,0 @@
|
|||
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");
|
||||
45
drizzle/0011_memories.sql
Normal file
45
drizzle/0011_memories.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
-- 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);
|
||||
28
drizzle/0012_medical_doses.sql
Normal file
28
drizzle/0012_medical_doses.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
-- 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);
|
||||
16
drizzle/0013_ai_usage.sql
Normal file
16
drizzle/0013_ai_usage.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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);
|
||||
11
drizzle/0014_milestones.sql
Normal file
11
drizzle/0014_milestones.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
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);
|
||||
37
drizzle/0015_affiliate.sql
Normal file
37
drizzle/0015_affiliate.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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);
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
# 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.
|
||||
17
drizzle/manual/03-audit-log.sql
Normal file
17
drizzle/manual/03-audit-log.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- 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);
|
||||
8
drizzle/manual/04-password-resets.sql
Normal file
8
drizzle/manual/04-password-resets.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- 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
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1779518962214,
|
||||
"tag": "0000_baseline_prod_2026_05_19",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -5,12 +5,7 @@
|
|||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"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"
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.11.2",
|
||||
|
|
@ -46,7 +41,6 @@
|
|||
"@types/react-dom": "^19",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"esbuild": "^0.25.12",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"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)
|
||||
next-pwa:
|
||||
specifier: ^5.6.0
|
||||
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))
|
||||
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)
|
||||
nodemailer:
|
||||
specifier: ^7.0.13
|
||||
version: 7.0.13
|
||||
|
|
@ -102,9 +102,6 @@ importers:
|
|||
drizzle-kit:
|
||||
specifier: ^0.31.10
|
||||
version: 0.31.10
|
||||
esbuild:
|
||||
specifier: ^0.25.12
|
||||
version: 0.25.12
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.3.0
|
||||
|
|
@ -6305,14 +6302,14 @@ snapshots:
|
|||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
babel-loader@8.4.1(@babel/core@7.29.0)(webpack@5.106.2(esbuild@0.25.12)):
|
||||
babel-loader@8.4.1(@babel/core@7.29.0)(webpack@5.106.2):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
find-cache-dir: 3.3.2
|
||||
loader-utils: 2.0.4
|
||||
make-dir: 3.1.0
|
||||
schema-utils: 2.7.1
|
||||
webpack: 5.106.2(esbuild@0.25.12)
|
||||
webpack: 5.106.2
|
||||
|
||||
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0):
|
||||
dependencies:
|
||||
|
|
@ -6420,10 +6417,10 @@ snapshots:
|
|||
|
||||
chrome-trace-event@1.0.4: {}
|
||||
|
||||
clean-webpack-plugin@4.0.0(webpack@5.106.2(esbuild@0.25.12)):
|
||||
clean-webpack-plugin@4.0.0(webpack@5.106.2):
|
||||
dependencies:
|
||||
del: 4.1.1
|
||||
webpack: 5.106.2(esbuild@0.25.12)
|
||||
webpack: 5.106.2
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
|
|
@ -7321,14 +7318,14 @@ snapshots:
|
|||
optionalDependencies:
|
||||
nodemailer: 7.0.13
|
||||
|
||||
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)):
|
||||
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):
|
||||
dependencies:
|
||||
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(esbuild@0.25.12))
|
||||
babel-loader: 8.4.1(@babel/core@7.29.0)(webpack@5.106.2)
|
||||
clean-webpack-plugin: 4.0.0(webpack@5.106.2)
|
||||
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)
|
||||
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(esbuild@0.25.12))
|
||||
terser-webpack-plugin: 5.6.0(webpack@5.106.2)
|
||||
workbox-webpack-plugin: 6.6.0(webpack@5.106.2)
|
||||
workbox-window: 6.6.0
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
|
@ -7888,15 +7885,13 @@ snapshots:
|
|||
type-fest: 0.16.0
|
||||
unique-string: 2.0.0
|
||||
|
||||
terser-webpack-plugin@5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12)):
|
||||
terser-webpack-plugin@5.6.0(webpack@5.106.2):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.47.1
|
||||
webpack: 5.106.2(esbuild@0.25.12)
|
||||
optionalDependencies:
|
||||
esbuild: 0.25.12
|
||||
webpack: 5.106.2
|
||||
|
||||
terser@5.47.1:
|
||||
dependencies:
|
||||
|
|
@ -8054,7 +8049,7 @@ snapshots:
|
|||
|
||||
webpack-sources@3.4.1: {}
|
||||
|
||||
webpack@5.106.2(esbuild@0.25.12):
|
||||
webpack@5.106.2:
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.9
|
||||
|
|
@ -8077,7 +8072,7 @@ snapshots:
|
|||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.3
|
||||
terser-webpack-plugin: 5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12))
|
||||
terser-webpack-plugin: 5.6.0(webpack@5.106.2)
|
||||
watchpack: 2.5.1
|
||||
webpack-sources: 3.4.1
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -8249,12 +8244,12 @@ snapshots:
|
|||
|
||||
workbox-sw@6.6.0: {}
|
||||
|
||||
workbox-webpack-plugin@6.6.0(webpack@5.106.2(esbuild@0.25.12)):
|
||||
workbox-webpack-plugin@6.6.0(webpack@5.106.2):
|
||||
dependencies:
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
pretty-bytes: 5.6.0
|
||||
upath: 1.2.0
|
||||
webpack: 5.106.2(esbuild@0.25.12)
|
||||
webpack: 5.106.2
|
||||
webpack-sources: 1.4.3
|
||||
workbox-build: 6.6.0
|
||||
transitivePeerDependencies:
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
/**
|
||||
* 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.");
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/**
|
||||
* 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();
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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,60 +1,13 @@
|
|||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { pgTable } from "drizzle-orm/pg-core";
|
||||
import { text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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",
|
||||
{
|
||||
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 }),
|
||||
familyId: uuid("family_id"),
|
||||
action: text("action").notNull(),
|
||||
metadata: text("metadata"), // JSON string
|
||||
ipAddress: text("ip_address"),
|
||||
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;
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -1,13 +1,6 @@
|
|||
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.
|
||||
// ---------------------------------------------------------------------------
|
||||
import { pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
|
||||
// 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", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name"),
|
||||
|
|
@ -16,8 +9,6 @@ export const users = pgTable("users", {
|
|||
image: text("image"),
|
||||
createdAt: timestamp("created_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)
|
||||
|
|
|
|||
|
|
@ -4,25 +4,11 @@ import {
|
|||
timestamp,
|
||||
uuid,
|
||||
pgEnum,
|
||||
varchar,
|
||||
integer,
|
||||
date,
|
||||
uniqueIndex,
|
||||
index,
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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`
|
||||
// ---------------------------------------------------------------------------
|
||||
import { relations } from "drizzle-orm";
|
||||
|
||||
// Enums
|
||||
export const memberRoleEnum = pgEnum("member_role", ["admin", "caregiver", "viewer"]);
|
||||
|
|
@ -39,11 +25,7 @@ export const childStageEnum = pgEnum("child_stage", [
|
|||
// Families table
|
||||
export const families = pgTable("families", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
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),
|
||||
name: text("name").notNull().default("The Gupta Family"),
|
||||
pediatricianPhone: text("pediatrician_phone"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
|
|
@ -57,24 +39,24 @@ export const familyMembers = pgTable(
|
|||
familyId: uuid("family_id").notNull(),
|
||||
userId: uuid("user_id").notNull(),
|
||||
role: memberRoleEnum("role").notNull().default("caregiver"),
|
||||
displayName: text("display_name").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("family_members_family_id_user_id_key").on(table.familyId, table.userId)]
|
||||
(table) => [uniqueIndex("family_user_unique").on(table.familyId, table.userId)]
|
||||
);
|
||||
|
||||
// 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(
|
||||
"children",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
familyId: uuid("family_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
birthDate: date("birth_date").notNull(),
|
||||
sex: childSexEnum("sex"), // nullable in prod
|
||||
currentStage: childStageEnum("stage").notNull().default("newborn"),
|
||||
imageUrl: text("image_url"),
|
||||
birthDate: timestamp("birth_date").notNull(),
|
||||
sex: childSexEnum("sex").notNull(),
|
||||
currentStage: childStageEnum("current_stage"),
|
||||
stageOverrides: jsonb("stage_overrides").$type<Record<string, unknown>>().default({}),
|
||||
profilePhotoUrl: text("profile_photo_url"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
},
|
||||
|
|
@ -88,9 +70,11 @@ export const familyInvites = pgTable(
|
|||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
familyId: uuid("family_id").notNull(),
|
||||
email: text("email").notNull(),
|
||||
role: memberRoleEnum("role").notNull().default("viewer"),
|
||||
role: memberRoleEnum("role").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
acceptedAt: timestamp("accepted_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("invite_token_idx").on(table.token)]
|
||||
|
|
|
|||
|
|
@ -1,17 +1,4 @@
|
|||
// 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 "./family";
|
||||
export * from "./audit";
|
||||
export * from "./media";
|
||||
export * from "./logs";
|
||||
export * from "./medical";
|
||||
export * from "./admin";
|
||||
export * from "./support";
|
||||
export * from "./ai";
|
||||
export * from "./affiliate";
|
||||
|
|
|
|||
|
|
@ -1,36 +1,8 @@
|
|||
import {
|
||||
pgTable,
|
||||
pgEnum,
|
||||
uuid,
|
||||
timestamp,
|
||||
text,
|
||||
integer,
|
||||
boolean,
|
||||
date,
|
||||
real,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { pgTable, pgEnum, uuid, timestamp, text, integer, boolean, date, real } from "drizzle-orm/pg-core";
|
||||
import { children as childrenTable } from "./family";
|
||||
|
||||
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
|
||||
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"]);
|
||||
|
|
@ -53,8 +25,8 @@ export const feeds = pgTable("feeds", {
|
|||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Logs: diapers (DB table: diapers_logs)
|
||||
export const diapersLogs = pgTable("diapers_logs", {
|
||||
// Logs: diapers
|
||||
export const diapers = pgTable("diapers", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
childId: uuid("child_id").references(() => children.id).notNull(),
|
||||
type: diaperType("type").notNull(),
|
||||
|
|
@ -68,7 +40,7 @@ export const sleeps = pgTable("sleeps", {
|
|||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
childId: uuid("child_id").references(() => children.id).notNull(),
|
||||
type: sleepType("type").notNull(),
|
||||
startedAt: timestamp("started_at"), // nullable in prod
|
||||
startedAt: timestamp("started_at").notNull(),
|
||||
endedAt: timestamp("ended_at"),
|
||||
durationMinutes: integer("duration_minutes"),
|
||||
notes: text("notes"),
|
||||
|
|
@ -102,7 +74,7 @@ export const growth = pgTable("growth", {
|
|||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Logs: medications (the prescription record; doses are in medical.ts)
|
||||
// Logs: medications
|
||||
export const medications = pgTable("medications", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
childId: uuid("child_id").references(() => children.id).notNull(),
|
||||
|
|
@ -116,32 +88,10 @@ export const medications = pgTable("medications", {
|
|||
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
|
||||
export type Feed = typeof feeds.$inferSelect;
|
||||
export type DiaperLog = typeof diapersLogs.$inferSelect;
|
||||
export type Diaper = typeof diapers.$inferSelect;
|
||||
export type Sleep = typeof sleeps.$inferSelect;
|
||||
export type Vaccination = typeof vaccinations.$inferSelect;
|
||||
export type Growth = typeof growth.$inferSelect;
|
||||
export type Medication = typeof medications.$inferSelect;
|
||||
export type MilestoneAchievement = typeof milestoneAchievements.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -5,19 +5,9 @@ import {
|
|||
uuid,
|
||||
boolean,
|
||||
integer,
|
||||
vector,
|
||||
index,
|
||||
} 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
|
||||
export type ProcessingStatus = "uploading" | "processing" | "ready" | "failed";
|
||||
|
||||
|
|
@ -29,7 +19,7 @@ export const memories = pgTable(
|
|||
childId: uuid("child_id"),
|
||||
title: text("title"),
|
||||
description: text("description"),
|
||||
takenAt: timestamp("taken_at", { withTimezone: true }),
|
||||
takenAt: timestamp("taken_at"),
|
||||
r2Key: text("r2_key").notNull(),
|
||||
r2ThumbnailKey: text("r2_thumbnail_key"),
|
||||
mimeType: text("mime_type"),
|
||||
|
|
@ -37,24 +27,17 @@ export const memories = pgTable(
|
|||
width: integer("width"),
|
||||
height: integer("height"),
|
||||
visionCaption: text("vision_caption"),
|
||||
visionTags: text("vision_tags").array(),
|
||||
visionEmbedding: vector("vision_embedding", { dimensions: 1536 }),
|
||||
// vision_tags is text[] in DB — handled with raw SQL
|
||||
// vision_embedding is vector(1536) in DB — handled with raw SQL
|
||||
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"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("memories_family_idx").on(table.familyId),
|
||||
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 }),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -69,9 +52,11 @@ export const attachments = pgTable(
|
|||
mimeType: text("mime_type"),
|
||||
sizeBytes: integer("size_bytes"),
|
||||
uploadedBy: uuid("uploaded_by"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("attachments_family_idx").on(table.familyId)]
|
||||
(table) => [
|
||||
index("attachments_family_idx").on(table.familyId),
|
||||
]
|
||||
);
|
||||
|
||||
export type Memory = typeof memories.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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