From a3d3a140edde810b4e3854b586f4bd883bcdadab Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 23 May 2026 12:17:20 +0530 Subject: [PATCH] refactor(db/schema): align TypeScript schema with production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. --- src/db/schema/admin.ts | 57 ++++++++++++++++++++++++ src/db/schema/affiliate.ts | 70 ++++++++++++++++++++++++++++++ src/db/schema/ai.ts | 60 +++++++++++++++++++++++++ src/db/schema/audit.ts | 71 +++++++++++++++++++++++++----- src/db/schema/auth.ts | 13 +++++- src/db/schema/family.ts | 44 +++++++++++++------ src/db/schema/index.ts | 15 ++++++- src/db/schema/logs.ts | 64 ++++++++++++++++++++++++--- src/db/schema/media.ts | 35 ++++++++++----- src/db/schema/medical.ts | 89 ++++++++++++++++++++++++++++++++++++++ src/db/schema/support.ts | 51 ++++++++++++++++++++++ 11 files changed, 523 insertions(+), 46 deletions(-) create mode 100644 src/db/schema/admin.ts create mode 100644 src/db/schema/affiliate.ts create mode 100644 src/db/schema/ai.ts create mode 100644 src/db/schema/medical.ts create mode 100644 src/db/schema/support.ts diff --git a/src/db/schema/admin.ts b/src/db/schema/admin.ts new file mode 100644 index 0000000..32946df --- /dev/null +++ b/src/db/schema/admin.ts @@ -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; diff --git a/src/db/schema/affiliate.ts b/src/db/schema/affiliate.ts new file mode 100644 index 0000000..1d24b66 --- /dev/null +++ b/src/db/schema/affiliate.ts @@ -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./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; diff --git a/src/db/schema/ai.ts b/src/db/schema/ai.ts new file mode 100644 index 0000000..184fe67 --- /dev/null +++ b/src/db/schema/ai.ts @@ -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; diff --git a/src/db/schema/audit.ts b/src/db/schema/audit.ts index 5927bb2..f6fc935 100644 --- a/src/db/schema/audit.ts +++ b/src/db/schema/audit.ts @@ -1,13 +1,60 @@ -import { pgTable } from "drizzle-orm/pg-core"; -import { text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { + pgTable, + text, + timestamp, + uuid, + varchar, + jsonb, + index, +} from "drizzle-orm/pg-core"; -export const auditLog = pgTable("audit_log", { - id: uuid("id").defaultRandom().primaryKey(), - userId: uuid("user_id"), - familyId: uuid("family_id"), - action: text("action").notNull(), - metadata: text("metadata"), // JSON string - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - createdAt: timestamp("created_at").defaultNow().notNull(), -}); \ No newline at end of file +// --------------------------------------------------------------------------- +// 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(), + familyId: uuid("family_id"), + userId: uuid("user_id"), + action: varchar("action", { length: 50 }).notNull(), + resourceType: varchar("resource_type", { length: 50 }), + resourceId: uuid("resource_id"), + ipAddress: varchar("ip_address", { length: 45 }), + userAgent: text("user_agent"), + metadata: jsonb("metadata").default({}), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("audit_family_idx").on(table.familyId, table.createdAt), + index("idx_audit_log_action").on(table.action), + index("idx_audit_log_created").on(table.createdAt), + index("idx_audit_log_family").on(table.familyId), + index("idx_audit_log_user").on(table.userId), + ] +); + +// Append-only correction trail for medical dose logs. +export const logCorrections = pgTable( + "log_corrections", + { + id: uuid("id").primaryKey().defaultRandom(), + familyId: uuid("family_id").notNull(), + doseId: uuid("dose_id").notNull(), + originalValue: jsonb("original_value").notNull(), + correctedValue: jsonb("corrected_value").notNull(), + reason: text("reason"), + correctedBy: uuid("corrected_by"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index("log_corrections_dose_idx").on(table.doseId)] +); + +export type AuditLog = typeof auditLog.$inferSelect; +export type LogCorrection = typeof logCorrections.$inferSelect; diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts index befa22e..b2a4307 100644 --- a/src/db/schema/auth.ts +++ b/src/db/schema/auth.ts @@ -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 +// 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"), @@ -9,6 +16,8 @@ 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) @@ -46,4 +55,4 @@ export const verificationTokens = pgTable("verification_tokens", { export type User = typeof users.$inferSelect; export type Account = typeof accounts.$inferSelect; export type Session = typeof sessions.$inferSelect; -export type VerificationToken = typeof verificationTokens.$inferSelect; \ No newline at end of file +export type VerificationToken = typeof verificationTokens.$inferSelect; diff --git a/src/db/schema/family.ts b/src/db/schema/family.ts index 468c56d..7a276ed 100644 --- a/src/db/schema/family.ts +++ b/src/db/schema/family.ts @@ -4,11 +4,25 @@ import { timestamp, uuid, pgEnum, + varchar, + integer, + date, uniqueIndex, index, - jsonb, } 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 export const memberRoleEnum = pgEnum("member_role", ["admin", "caregiver", "viewer"]); @@ -25,7 +39,11 @@ export const childStageEnum = pgEnum("child_stage", [ // Families table export const families = pgTable("families", { 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"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), @@ -39,24 +57,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_user_unique").on(table.familyId, table.userId)] + (table) => [uniqueIndex("family_members_family_id_user_id_key").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: timestamp("birth_date").notNull(), - sex: childSexEnum("sex").notNull(), - currentStage: childStageEnum("current_stage"), - stageOverrides: jsonb("stage_overrides").$type>().default({}), - profilePhotoUrl: text("profile_photo_url"), + birthDate: date("birth_date").notNull(), + sex: childSexEnum("sex"), // nullable in prod + currentStage: childStageEnum("stage").notNull().default("newborn"), + imageUrl: text("image_url"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, @@ -70,11 +88,9 @@ export const familyInvites = pgTable( id: uuid("id").primaryKey().defaultRandom(), familyId: uuid("family_id").notNull(), email: text("email").notNull(), - role: memberRoleEnum("role").notNull(), - displayName: text("display_name").notNull(), + role: memberRoleEnum("role").notNull().default("viewer"), 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)] @@ -99,4 +115,4 @@ export function deriveStage(birthDate: Date): (typeof childStageEnum)["enumValue if (months < 24) return "toddler_early"; if (months < 36) return "toddler_late"; return "preschool"; -} \ No newline at end of file +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 0aa9e52..ddb7738 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -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 "./family"; export * from "./audit"; -export * from "./media"; \ No newline at end of file +export * from "./media"; +export * from "./logs"; +export * from "./medical"; +export * from "./admin"; +export * from "./support"; +export * from "./ai"; +export * from "./affiliate"; diff --git a/src/db/schema/logs.ts b/src/db/schema/logs.ts index a2905f8..024b86f 100644 --- a/src/db/schema/logs.ts +++ b/src/db/schema/logs.ts @@ -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"; 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"]); @@ -25,8 +53,8 @@ export const feeds = pgTable("feeds", { createdAt: timestamp("created_at").defaultNow().notNull(), }); -// Logs: diapers -export const diapers = pgTable("diapers", { +// Logs: diapers (DB table: diapers_logs) +export const diapersLogs = pgTable("diapers_logs", { id: uuid("id").primaryKey().defaultRandom(), childId: uuid("child_id").references(() => children.id).notNull(), type: diaperType("type").notNull(), @@ -40,7 +68,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").notNull(), + startedAt: timestamp("started_at"), // nullable in prod endedAt: timestamp("ended_at"), durationMinutes: integer("duration_minutes"), notes: text("notes"), @@ -74,7 +102,7 @@ export const growth = pgTable("growth", { createdAt: timestamp("created_at").defaultNow().notNull(), }); -// Logs: medications +// Logs: medications (the prescription record; doses are in medical.ts) export const medications = pgTable("medications", { id: uuid("id").primaryKey().defaultRandom(), childId: uuid("child_id").references(() => children.id).notNull(), @@ -88,10 +116,32 @@ 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 Diaper = typeof diapers.$inferSelect; +export type DiaperLog = typeof diapersLogs.$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; \ No newline at end of file +export type Medication = typeof medications.$inferSelect; +export type MilestoneAchievement = typeof milestoneAchievements.$inferSelect; diff --git a/src/db/schema/media.ts b/src/db/schema/media.ts index e7c4a7d..3bf3c43 100644 --- a/src/db/schema/media.ts +++ b/src/db/schema/media.ts @@ -5,9 +5,19 @@ 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"; @@ -19,7 +29,7 @@ export const memories = pgTable( childId: uuid("child_id"), title: text("title"), description: text("description"), - takenAt: timestamp("taken_at"), + takenAt: timestamp("taken_at", { withTimezone: true }), r2Key: text("r2_key").notNull(), r2ThumbnailKey: text("r2_thumbnail_key"), mimeType: text("mime_type"), @@ -27,17 +37,24 @@ export const memories = pgTable( width: integer("width"), height: integer("height"), visionCaption: text("vision_caption"), - // vision_tags is text[] in DB — handled with raw SQL - // vision_embedding is vector(1536) in DB — handled with raw SQL + visionTags: text("vision_tags").array(), + visionEmbedding: vector("vision_embedding", { dimensions: 1536 }), isPrivate: boolean("is_private").default(false).notNull(), - processingStatus: text("processing_status").$type().default("uploading").notNull(), + processingStatus: text("processing_status") + .$type() + .default("uploading") + .notNull(), uploadedBy: uuid("uploaded_by"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).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 }), ] ); @@ -52,11 +69,9 @@ export const attachments = pgTable( mimeType: text("mime_type"), sizeBytes: integer("size_bytes"), uploadedBy: uuid("uploaded_by"), - createdAt: timestamp("created_at").defaultNow().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }, - (table) => [ - index("attachments_family_idx").on(table.familyId), - ] + (table) => [index("attachments_family_idx").on(table.familyId)] ); export type Memory = typeof memories.$inferSelect; diff --git a/src/db/schema/medical.ts b/src/db/schema/medical.ts new file mode 100644 index 0000000..c666fef --- /dev/null +++ b/src/db/schema/medical.ts @@ -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; diff --git a/src/db/schema/support.ts b/src/db/schema/support.ts new file mode 100644 index 0000000..9aa9431 --- /dev/null +++ b/src/db/schema/support.ts @@ -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;