refactor(db/schema): align TypeScript schema with production
Path A baseline reconciliation. drizzle-kit pull against tia_prod showed
prod had drifted well past schema.ts because legacy hand-rolled migrations
0003-0015 wrote to the DB but were never reflected back into TypeScript.
Shared-table drift fixed:
- users: + password_hash, + password_updated_at
- families: + tier, + max_children, + max_members
- children: col is 'stage' (kept JS key currentStage -> stage);
'image_url' not 'profile_photo_url'; birth_date is DATE;
sex nullable; dropped phantom stage_overrides
- family_members: dropped phantom display_name
- family_invites: dropped phantom display_name, accepted_at
- audit_log: + resource_id, + resource_type; metadata -> jsonb; +5 indexes
- memories: + vision_tags (text[]), + vision_embedding (vector 1536)
- logs.ts: 'diapers' phantom table renamed to diapersLogs ('diapers_logs')
19 missing tables added across new files:
- admin.ts: admins, admin_sessions, password_resets
- support.ts: support_tickets, support_responses
- ai.ts: chat_sessions, chat_messages, ai_usage
- medical.ts: medicines, medication_doses, allergies, illness_logs, doctor_visits
- affiliate.ts: member_profiles, recommended_products, product_clicks
- logs.ts: + milestone_achievements
- audit.ts: + log_corrections
BUG FIX: schema/index.ts never re-exported ./logs — Drizzle was blind to
feeds/sleeps/vaccinations/growth/medications. Now exported.
Verified: tsc --noEmit has zero non-test errors. Dropped phantom columns
confirmed to have zero references in src/.
This commit is contained in:
parent
e7d68c2fc6
commit
a3d3a140ed
11 changed files with 523 additions and 46 deletions
57
src/db/schema/admin.ts
Normal file
57
src/db/schema/admin.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
check,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin / auth-extra schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: admins, admin_sessions, password_resets
|
||||||
|
// (legacy migrations 0006_admin_auth, 0009_admin_sessions, 0004's password_resets).
|
||||||
|
//
|
||||||
|
// SECURITY NOTE: `admins` is the privileged back-office account table, fully
|
||||||
|
// separate from the `users` table. Password hashes here gate admin access —
|
||||||
|
// treat any change to this table as security-critical.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const admins = pgTable(
|
||||||
|
"admins",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
username: varchar("username", { length: 50 }).notNull().unique(),
|
||||||
|
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
||||||
|
role: varchar("role", { length: 20 }).default("admin"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
lastLogin: timestamp("last_login", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check(
|
||||||
|
"admins_role_check",
|
||||||
|
sql`(role)::text = ANY (ARRAY['super_admin','admin','support'])`
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const adminSessions = pgTable("admin_sessions", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
adminId: uuid("admin_id").notNull(),
|
||||||
|
sessionToken: text("session_token").notNull().unique(),
|
||||||
|
expires: timestamp("expires", { withTimezone: true }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const passwordResets = pgTable("password_resets", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id").notNull(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Admin = typeof admins.$inferSelect;
|
||||||
|
export type AdminSession = typeof adminSessions.$inferSelect;
|
||||||
|
export type PasswordReset = typeof passwordResets.$inferSelect;
|
||||||
70
src/db/schema/affiliate.ts
Normal file
70
src/db/schema/affiliate.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
boolean,
|
||||||
|
integer,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Affiliate schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: member_profiles, recommended_products, product_clicks
|
||||||
|
// (legacy migration 0015_affiliate).
|
||||||
|
//
|
||||||
|
// Backs the public affiliate product page at tia.<domain>/m/[slug].
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const memberProfiles = pgTable(
|
||||||
|
"member_profiles",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id").notNull().unique(),
|
||||||
|
familyId: uuid("family_id").notNull(),
|
||||||
|
slug: text("slug").notNull(),
|
||||||
|
displayName: text("display_name").notNull(),
|
||||||
|
bio: text("bio"),
|
||||||
|
avatarUrl: text("avatar_url"),
|
||||||
|
isPublic: boolean("is_public").default(false).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("member_profiles_slug_idx").on(table.slug)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const recommendedProducts = pgTable(
|
||||||
|
"recommended_products",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
profileId: uuid("profile_id").notNull(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
category: text("category").default("general").notNull(),
|
||||||
|
displayOrder: integer("display_order").default(0).notNull(),
|
||||||
|
isActive: boolean("is_active").default(true).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("recommended_products_profile_idx").on(table.profileId, table.displayOrder),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click tracking — ipHash is a hash, never a raw IP (privacy).
|
||||||
|
export const productClicks = pgTable(
|
||||||
|
"product_clicks",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
productId: uuid("product_id").notNull(),
|
||||||
|
clickedAt: timestamp("clicked_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
referrer: text("referrer"),
|
||||||
|
ipHash: text("ip_hash"),
|
||||||
|
},
|
||||||
|
(table) => [index("product_clicks_product_idx").on(table.productId, table.clickedAt)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MemberProfile = typeof memberProfiles.$inferSelect;
|
||||||
|
export type RecommendedProduct = typeof recommendedProducts.$inferSelect;
|
||||||
|
export type ProductClick = typeof productClicks.$inferSelect;
|
||||||
60
src/db/schema/ai.ts
Normal file
60
src/db/schema/ai.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
integer,
|
||||||
|
numeric,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: chat_sessions, chat_messages, ai_usage
|
||||||
|
// (legacy migrations 0004_chat_sessions, 0013_ai_usage).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const chatSessions = pgTable("chat_sessions", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
title: varchar("title", { length: 255 }).default("New conversation").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chatMessages = pgTable("chat_messages", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
sessionId: uuid("session_id").notNull(),
|
||||||
|
role: varchar("role", { length: 20 }).notNull(),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token + cost accounting for the LiteLLM-gateway AI calls.
|
||||||
|
// cost_estimate_paise stores cost in Indian paise (1 INR = 100 paise) so
|
||||||
|
// fractional model costs stay exact without float drift.
|
||||||
|
export const aiUsage = pgTable(
|
||||||
|
"ai_usage",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
familyId: uuid("family_id"),
|
||||||
|
userId: uuid("user_id"),
|
||||||
|
intent: text("intent"),
|
||||||
|
modelUsed: text("model_used"),
|
||||||
|
promptTokens: integer("prompt_tokens"),
|
||||||
|
completionTokens: integer("completion_tokens"),
|
||||||
|
totalTokens: integer("total_tokens"),
|
||||||
|
costEstimatePaise: numeric("cost_estimate_paise", { precision: 10, scale: 4 }),
|
||||||
|
durationMs: integer("duration_ms"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("ai_usage_created_idx").on(table.createdAt),
|
||||||
|
index("ai_usage_family_idx").on(table.familyId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ChatSession = typeof chatSessions.$inferSelect;
|
||||||
|
export type ChatMessage = typeof chatMessages.$inferSelect;
|
||||||
|
export type AiUsage = typeof aiUsage.$inferSelect;
|
||||||
|
|
@ -1,13 +1,60 @@
|
||||||
import { pgTable } from "drizzle-orm/pg-core";
|
import {
|
||||||
import { text, timestamp, uuid } from "drizzle-orm/pg-core";
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const auditLog = pgTable("audit_log", {
|
// ---------------------------------------------------------------------------
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
// Audit schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
userId: uuid("user_id"),
|
//
|
||||||
familyId: uuid("family_id"),
|
// Drift corrected at baseline:
|
||||||
action: text("action").notNull(),
|
// - audit_log: added resource_id / resource_type (legacy 0010_audit_log);
|
||||||
metadata: text("metadata"), // JSON string
|
// metadata is JSONB (was modelled as text); action is varchar(50)
|
||||||
ipAddress: text("ip_address"),
|
// - log_corrections: append-only-with-corrections record, was missing here
|
||||||
userAgent: text("user_agent"),
|
// ---------------------------------------------------------------------------
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
||||||
});
|
export const auditLog = pgTable(
|
||||||
|
"audit_log",
|
||||||
|
{
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
familyId: uuid("family_id"),
|
||||||
|
userId: uuid("user_id"),
|
||||||
|
action: varchar("action", { length: 50 }).notNull(),
|
||||||
|
resourceType: varchar("resource_type", { length: 50 }),
|
||||||
|
resourceId: uuid("resource_id"),
|
||||||
|
ipAddress: varchar("ip_address", { length: 45 }),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
|
metadata: jsonb("metadata").default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("audit_family_idx").on(table.familyId, table.createdAt),
|
||||||
|
index("idx_audit_log_action").on(table.action),
|
||||||
|
index("idx_audit_log_created").on(table.createdAt),
|
||||||
|
index("idx_audit_log_family").on(table.familyId),
|
||||||
|
index("idx_audit_log_user").on(table.userId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Append-only correction trail for medical dose logs.
|
||||||
|
export const logCorrections = pgTable(
|
||||||
|
"log_corrections",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
familyId: uuid("family_id").notNull(),
|
||||||
|
doseId: uuid("dose_id").notNull(),
|
||||||
|
originalValue: jsonb("original_value").notNull(),
|
||||||
|
correctedValue: jsonb("corrected_value").notNull(),
|
||||||
|
reason: text("reason"),
|
||||||
|
correctedBy: uuid("corrected_by"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("log_corrections_dose_idx").on(table.doseId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AuditLog = typeof auditLog.$inferSelect;
|
||||||
|
export type LogCorrection = typeof logCorrections.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
|
import { pgTable, text, timestamp, uuid, varchar, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Source of truth: drizzle-kit pull against tia_prod.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Users table
|
// Users table
|
||||||
|
// NOTE: password_hash / password_updated_at were added by the legacy
|
||||||
|
// 0008_user_passwords migration and were missing from this file until baseline.
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
|
|
@ -9,6 +16,8 @@ export const users = pgTable("users", {
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
passwordHash: varchar("password_hash", { length: 255 }),
|
||||||
|
passwordUpdatedAt: timestamp("password_updated_at", { withTimezone: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Accounts table (for OAuth providers)
|
// Accounts table (for OAuth providers)
|
||||||
|
|
@ -46,4 +55,4 @@ export const verificationTokens = pgTable("verification_tokens", {
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type Account = typeof accounts.$inferSelect;
|
export type Account = typeof accounts.$inferSelect;
|
||||||
export type Session = typeof sessions.$inferSelect;
|
export type Session = typeof sessions.$inferSelect;
|
||||||
export type VerificationToken = typeof verificationTokens.$inferSelect;
|
export type VerificationToken = typeof verificationTokens.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,25 @@ import {
|
||||||
timestamp,
|
timestamp,
|
||||||
uuid,
|
uuid,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
|
varchar,
|
||||||
|
integer,
|
||||||
|
date,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
index,
|
index,
|
||||||
jsonb,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Family schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Source of truth: drizzle-kit pull against tia_prod.
|
||||||
|
//
|
||||||
|
// Drift corrected at baseline:
|
||||||
|
// - families: added tier / maxChildren / maxMembers (legacy 0005)
|
||||||
|
// - children: col is `stage` not `current_stage`; `image_url` not
|
||||||
|
// `profile_photo_url`; dropped phantom `stage_overrides`;
|
||||||
|
// birth_date is DATE; sex is NULLABLE
|
||||||
|
// - family_members: dropped phantom `display_name`
|
||||||
|
// - family_invites: dropped phantom `display_name` / `accepted_at`
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Enums
|
// Enums
|
||||||
export const memberRoleEnum = pgEnum("member_role", ["admin", "caregiver", "viewer"]);
|
export const memberRoleEnum = pgEnum("member_role", ["admin", "caregiver", "viewer"]);
|
||||||
|
|
@ -25,7 +39,11 @@ export const childStageEnum = pgEnum("child_stage", [
|
||||||
// Families table
|
// Families table
|
||||||
export const families = pgTable("families", {
|
export const families = pgTable("families", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: text("name").notNull().default("The Gupta Family"),
|
name: text("name").notNull(),
|
||||||
|
// Tier system (legacy migration 0005_tier_system).
|
||||||
|
tier: varchar("tier", { length: 20 }).default("free"),
|
||||||
|
maxChildren: integer("max_children").default(1),
|
||||||
|
maxMembers: integer("max_members").default(2),
|
||||||
pediatricianPhone: text("pediatrician_phone"),
|
pediatricianPhone: text("pediatrician_phone"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
|
@ -39,24 +57,24 @@ export const familyMembers = pgTable(
|
||||||
familyId: uuid("family_id").notNull(),
|
familyId: uuid("family_id").notNull(),
|
||||||
userId: uuid("user_id").notNull(),
|
userId: uuid("user_id").notNull(),
|
||||||
role: memberRoleEnum("role").notNull().default("caregiver"),
|
role: memberRoleEnum("role").notNull().default("caregiver"),
|
||||||
displayName: text("display_name").notNull(),
|
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("family_user_unique").on(table.familyId, table.userId)]
|
(table) => [uniqueIndex("family_members_family_id_user_id_key").on(table.familyId, table.userId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Children table
|
// Children table
|
||||||
|
// NOTE (2b): the DB column is `stage`. We keep the JS-friendly key
|
||||||
|
// `currentStage` mapped onto it so application code reads naturally.
|
||||||
export const children = pgTable(
|
export const children = pgTable(
|
||||||
"children",
|
"children",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
familyId: uuid("family_id").notNull(),
|
familyId: uuid("family_id").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
birthDate: timestamp("birth_date").notNull(),
|
birthDate: date("birth_date").notNull(),
|
||||||
sex: childSexEnum("sex").notNull(),
|
sex: childSexEnum("sex"), // nullable in prod
|
||||||
currentStage: childStageEnum("current_stage"),
|
currentStage: childStageEnum("stage").notNull().default("newborn"),
|
||||||
stageOverrides: jsonb("stage_overrides").$type<Record<string, unknown>>().default({}),
|
imageUrl: text("image_url"),
|
||||||
profilePhotoUrl: text("profile_photo_url"),
|
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
|
|
@ -70,11 +88,9 @@ export const familyInvites = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
familyId: uuid("family_id").notNull(),
|
familyId: uuid("family_id").notNull(),
|
||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
role: memberRoleEnum("role").notNull(),
|
role: memberRoleEnum("role").notNull().default("viewer"),
|
||||||
displayName: text("display_name").notNull(),
|
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
acceptedAt: timestamp("accepted_at"),
|
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("invite_token_idx").on(table.token)]
|
(table) => [uniqueIndex("invite_token_idx").on(table.token)]
|
||||||
|
|
@ -99,4 +115,4 @@ export function deriveStage(birthDate: Date): (typeof childStageEnum)["enumValue
|
||||||
if (months < 24) return "toddler_early";
|
if (months < 24) return "toddler_early";
|
||||||
if (months < 36) return "toddler_late";
|
if (months < 36) return "toddler_late";
|
||||||
return "preschool";
|
return "preschool";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,17 @@
|
||||||
|
// Barrel export for the full Drizzle schema.
|
||||||
|
// Every schema file MUST be re-exported here so `import * as schema`
|
||||||
|
// in src/db/index.ts sees the complete table set.
|
||||||
|
//
|
||||||
|
// BUG FIXED AT BASELINE: ./logs was previously NOT exported here, so
|
||||||
|
// Drizzle never knew about feeds / sleeps / vaccinations / growth /
|
||||||
|
// medications / diapers. That is why those tables drifted unnoticed.
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./family";
|
export * from "./family";
|
||||||
export * from "./audit";
|
export * from "./audit";
|
||||||
export * from "./media";
|
export * from "./media";
|
||||||
|
export * from "./logs";
|
||||||
|
export * from "./medical";
|
||||||
|
export * from "./admin";
|
||||||
|
export * from "./support";
|
||||||
|
export * from "./ai";
|
||||||
|
export * from "./affiliate";
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,36 @@
|
||||||
import { pgTable, pgEnum, uuid, timestamp, text, integer, boolean, date, real } from "drizzle-orm/pg-core";
|
import {
|
||||||
|
pgTable,
|
||||||
|
pgEnum,
|
||||||
|
uuid,
|
||||||
|
timestamp,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
boolean,
|
||||||
|
date,
|
||||||
|
real,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
import { children as childrenTable } from "./family";
|
import { children as childrenTable } from "./family";
|
||||||
|
|
||||||
const children = childrenTable;
|
const children = childrenTable;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logs schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
//
|
||||||
|
// Drift corrected at baseline:
|
||||||
|
// - `diapers` table renamed to `diapersLogs` (DB table is `diapers_logs`).
|
||||||
|
// The app already writes to diapers_logs via raw SQL; the old `diapers`
|
||||||
|
// pgTable was dead/phantom.
|
||||||
|
// - sleeps.startedAt is NULLABLE in prod.
|
||||||
|
// - milestone_achievements added (legacy 0014_milestones).
|
||||||
|
//
|
||||||
|
// RLS NOTE: feeds / diapers_logs / sleeps / vaccinations / growth carry a
|
||||||
|
// `family_isolation` row-level-security policy in prod. RLS policies are not
|
||||||
|
// modelled in these pgTable definitions — they live in the DB and are managed
|
||||||
|
// separately. Do not assume Drizzle will recreate them.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Feed types
|
// Feed types
|
||||||
export const feedType = pgEnum("feed_type", ["breast_milk", "formula", "solid", "water", "other"]);
|
export const feedType = pgEnum("feed_type", ["breast_milk", "formula", "solid", "water", "other"]);
|
||||||
export const feedMethod = pgEnum("feed_method", ["bottle", "breast_left", "breast_right", "breast_both", "cup", "spoon", "finger", "self"]);
|
export const feedMethod = pgEnum("feed_method", ["bottle", "breast_left", "breast_right", "breast_both", "cup", "spoon", "finger", "self"]);
|
||||||
|
|
@ -25,8 +53,8 @@ export const feeds = pgTable("feeds", {
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logs: diapers
|
// Logs: diapers (DB table: diapers_logs)
|
||||||
export const diapers = pgTable("diapers", {
|
export const diapersLogs = pgTable("diapers_logs", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
childId: uuid("child_id").references(() => children.id).notNull(),
|
childId: uuid("child_id").references(() => children.id).notNull(),
|
||||||
type: diaperType("type").notNull(),
|
type: diaperType("type").notNull(),
|
||||||
|
|
@ -40,7 +68,7 @@ export const sleeps = pgTable("sleeps", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
childId: uuid("child_id").references(() => children.id).notNull(),
|
childId: uuid("child_id").references(() => children.id).notNull(),
|
||||||
type: sleepType("type").notNull(),
|
type: sleepType("type").notNull(),
|
||||||
startedAt: timestamp("started_at").notNull(),
|
startedAt: timestamp("started_at"), // nullable in prod
|
||||||
endedAt: timestamp("ended_at"),
|
endedAt: timestamp("ended_at"),
|
||||||
durationMinutes: integer("duration_minutes"),
|
durationMinutes: integer("duration_minutes"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
|
@ -74,7 +102,7 @@ export const growth = pgTable("growth", {
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logs: medications
|
// Logs: medications (the prescription record; doses are in medical.ts)
|
||||||
export const medications = pgTable("medications", {
|
export const medications = pgTable("medications", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
childId: uuid("child_id").references(() => children.id).notNull(),
|
childId: uuid("child_id").references(() => children.id).notNull(),
|
||||||
|
|
@ -88,10 +116,32 @@ export const medications = pgTable("medications", {
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Milestone achievements (legacy 0014_milestones)
|
||||||
|
export const milestoneAchievements = pgTable(
|
||||||
|
"milestone_achievements",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
familyId: uuid("family_id").notNull(),
|
||||||
|
milestoneKey: text("milestone_key").notNull(),
|
||||||
|
achievedAt: date("achieved_at").notNull(),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("milestone_child_idx").on(table.childId),
|
||||||
|
uniqueIndex("milestone_achievements_child_milestone_unique").on(
|
||||||
|
table.childId,
|
||||||
|
table.milestoneKey
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// Type exports
|
// Type exports
|
||||||
export type Feed = typeof feeds.$inferSelect;
|
export type Feed = typeof feeds.$inferSelect;
|
||||||
export type Diaper = typeof diapers.$inferSelect;
|
export type DiaperLog = typeof diapersLogs.$inferSelect;
|
||||||
export type Sleep = typeof sleeps.$inferSelect;
|
export type Sleep = typeof sleeps.$inferSelect;
|
||||||
export type Vaccination = typeof vaccinations.$inferSelect;
|
export type Vaccination = typeof vaccinations.$inferSelect;
|
||||||
export type Growth = typeof growth.$inferSelect;
|
export type Growth = typeof growth.$inferSelect;
|
||||||
export type Medication = typeof medications.$inferSelect;
|
export type Medication = typeof medications.$inferSelect;
|
||||||
|
export type MilestoneAchievement = typeof milestoneAchievements.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,19 @@ import {
|
||||||
uuid,
|
uuid,
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
|
vector,
|
||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Media schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
//
|
||||||
|
// Drift corrected at baseline:
|
||||||
|
// - memories: vision_tags (text[]) and vision_embedding (vector(1536)) are
|
||||||
|
// now modelled with Drizzle's native column types instead of being
|
||||||
|
// "raw SQL only". The ivfflat index on the embedding is declared too.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Processing states for background jobs
|
// Processing states for background jobs
|
||||||
export type ProcessingStatus = "uploading" | "processing" | "ready" | "failed";
|
export type ProcessingStatus = "uploading" | "processing" | "ready" | "failed";
|
||||||
|
|
||||||
|
|
@ -19,7 +29,7 @@ export const memories = pgTable(
|
||||||
childId: uuid("child_id"),
|
childId: uuid("child_id"),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
takenAt: timestamp("taken_at"),
|
takenAt: timestamp("taken_at", { withTimezone: true }),
|
||||||
r2Key: text("r2_key").notNull(),
|
r2Key: text("r2_key").notNull(),
|
||||||
r2ThumbnailKey: text("r2_thumbnail_key"),
|
r2ThumbnailKey: text("r2_thumbnail_key"),
|
||||||
mimeType: text("mime_type"),
|
mimeType: text("mime_type"),
|
||||||
|
|
@ -27,17 +37,24 @@ export const memories = pgTable(
|
||||||
width: integer("width"),
|
width: integer("width"),
|
||||||
height: integer("height"),
|
height: integer("height"),
|
||||||
visionCaption: text("vision_caption"),
|
visionCaption: text("vision_caption"),
|
||||||
// vision_tags is text[] in DB — handled with raw SQL
|
visionTags: text("vision_tags").array(),
|
||||||
// vision_embedding is vector(1536) in DB — handled with raw SQL
|
visionEmbedding: vector("vision_embedding", { dimensions: 1536 }),
|
||||||
isPrivate: boolean("is_private").default(false).notNull(),
|
isPrivate: boolean("is_private").default(false).notNull(),
|
||||||
processingStatus: text("processing_status").$type<ProcessingStatus>().default("uploading").notNull(),
|
processingStatus: text("processing_status")
|
||||||
|
.$type<ProcessingStatus>()
|
||||||
|
.default("uploading")
|
||||||
|
.notNull(),
|
||||||
uploadedBy: uuid("uploaded_by"),
|
uploadedBy: uuid("uploaded_by"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("memories_family_idx").on(table.familyId),
|
index("memories_family_idx").on(table.familyId),
|
||||||
index("memories_child_idx").on(table.childId),
|
index("memories_child_idx").on(table.childId),
|
||||||
|
// ivfflat index for cosine similarity search over CLIP/vision embeddings.
|
||||||
|
index("memories_embedding_idx")
|
||||||
|
.using("ivfflat", table.visionEmbedding.op("vector_cosine_ops"))
|
||||||
|
.with({ lists: 100 }),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -52,11 +69,9 @@ export const attachments = pgTable(
|
||||||
mimeType: text("mime_type"),
|
mimeType: text("mime_type"),
|
||||||
sizeBytes: integer("size_bytes"),
|
sizeBytes: integer("size_bytes"),
|
||||||
uploadedBy: uuid("uploaded_by"),
|
uploadedBy: uuid("uploaded_by"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [index("attachments_family_idx").on(table.familyId)]
|
||||||
index("attachments_family_idx").on(table.familyId),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export type Memory = typeof memories.$inferSelect;
|
export type Memory = typeof memories.$inferSelect;
|
||||||
|
|
|
||||||
89
src/db/schema/medical.ts
Normal file
89
src/db/schema/medical.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
date,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Medical schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: medicines, medication_doses, allergies, illness_logs, doctor_visits
|
||||||
|
// (legacy migrations 0003_medical_data, 0012_medical_doses).
|
||||||
|
//
|
||||||
|
// Note: `medicines` here is the per-child medicine list with reminder times.
|
||||||
|
// `medications` (in logs.ts) is a separate prescription-record table that
|
||||||
|
// predates it. Both exist in prod; keep them distinct.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const medicines = pgTable("medicines", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
dose: varchar("dose", { length: 255 }),
|
||||||
|
notes: text("notes"),
|
||||||
|
reminderTime: varchar("reminder_time", { length: 10 }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append-only dose administration log. Corrections go via log_corrections.
|
||||||
|
export const medicationDoses = pgTable(
|
||||||
|
"medication_doses",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
medicineId: uuid("medicine_id").notNull(),
|
||||||
|
familyId: uuid("family_id").notNull(),
|
||||||
|
administeredAt: timestamp("administered_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
administeredBy: uuid("administered_by"),
|
||||||
|
amountGiven: text("amount_given"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("medication_doses_family_idx").on(table.familyId),
|
||||||
|
index("medication_doses_medicine_idx").on(table.medicineId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const allergies = pgTable("allergies", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
severity: varchar("severity", { length: 50 }).default("mild"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const illnessLogs = pgTable("illness_logs", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
startDate: date("start_date").notNull(),
|
||||||
|
endDate: date("end_date"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const doctorVisits = pgTable("doctor_visits", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
childId: uuid("child_id").notNull(),
|
||||||
|
doctorName: varchar("doctor_name", { length: 255 }).notNull(),
|
||||||
|
reason: varchar("reason", { length: 255 }),
|
||||||
|
visitDate: date("visit_date").notNull(),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Medicine = typeof medicines.$inferSelect;
|
||||||
|
export type MedicationDose = typeof medicationDoses.$inferSelect;
|
||||||
|
export type Allergy = typeof allergies.$inferSelect;
|
||||||
|
export type IllnessLog = typeof illnessLogs.$inferSelect;
|
||||||
|
export type DoctorVisit = typeof doctorVisits.$inferSelect;
|
||||||
51
src/db/schema/support.ts
Normal file
51
src/db/schema/support.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
check,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Support schema — aligned to PRODUCTION as of 2026-05-19 baseline.
|
||||||
|
// Tables: support_tickets, support_responses (legacy 0007_support_tickets).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const supportTickets = pgTable(
|
||||||
|
"support_tickets",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
familyId: uuid("family_id"),
|
||||||
|
userId: uuid("user_id"),
|
||||||
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
|
subject: varchar("subject", { length: 255 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
status: varchar("status", { length: 20 }).default("open"),
|
||||||
|
priority: varchar("priority", { length: 20 }).default("normal"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check(
|
||||||
|
"support_tickets_status_check",
|
||||||
|
sql`(status)::text = ANY (ARRAY['open','in_progress','resolved','closed'])`
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"support_tickets_priority_check",
|
||||||
|
sql`(priority)::text = ANY (ARRAY['low','normal','high','urgent'])`
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const supportResponses = pgTable("support_responses", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
ticketId: uuid("ticket_id"),
|
||||||
|
adminId: uuid("admin_id"),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SupportTicket = typeof supportTickets.$inferSelect;
|
||||||
|
export type SupportResponse = typeof supportResponses.$inferSelect;
|
||||||
Loading…
Add table
Reference in a new issue