diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..1e66311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,5 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +When you change `lib/db/schema.ts`, adjust `scripts/schema-overview.config.json` if needed (relationship lines or column quotes), then run `npm run docs:schema`. diff --git a/docs/schema-overview.md b/docs/schema-overview.md index cc388eb..250375d 100644 --- a/docs/schema-overview.md +++ b/docs/schema-overview.md @@ -1,29 +1,53 @@ # MCLD Platform — Full Schema Overview + + ```mermaid erDiagram + coaching_sessions { + uuid id PK + uuid service_id FK + uuid coach_id FK + uuid user_id FK + timestamp scheduled_at + int duration_minutes + session_status status + text meeting_url + text notes + jsonb selected_time_slots "array of {start, end} objects" + timestamp created_at + timestamp updated_at + } + profile_service_discounts { + uuid id PK + uuid user_id FK + uuid service_id FK + text stripe_coupon_id + int max_uses + int use_count + timestamp expires_at + timestamp created_at + } profiles { uuid id PK text first_name text last_name role role + text stripe_customer_id timestamp created_at timestamp updated_at } - - services { + purchases { uuid id PK - text title - text description - service_type type - jsonb scheduled_at "null for coaching_session" - int duration_minutes - int price - boolean is_active + uuid user_id FK + text stripe_price_id + text stripe_session_id + text product_name + int amount + text currency timestamp created_at timestamp updated_at } - service_bookings { uuid id PK uuid user_id FK @@ -34,47 +58,61 @@ erDiagram timestamp created_at timestamp updated_at } - - webinars { + services { uuid id PK text title text description - webinar_tier tier + service_type type + jsonb scheduled_at "null for coaching_session" int duration_minutes - text youtube_url + int price + text stripe_product_id + text stripe_default_price_id boolean is_active timestamp created_at timestamp updated_at } - - coaching_sessions { + subscriptions { uuid id PK - uuid service_id FK - uuid coach_id FK uuid user_id FK - timestamp scheduled_at + text stripe_subscription_id + text status + text stripe_price_id + boolean cancel_at_period_end + text payment_method_brand + text payment_method_last4 + timestamp created_at + timestamp updated_at + } + webinars { + uuid id PK + text title + text description + webinar_tier tier int duration_minutes - session_status status - text meeting_url - text notes - jsonb selected_time_slots "array of {start, end} objects" + text youtube_url + boolean is_active timestamp created_at timestamp updated_at } - profiles ||--o{ service_bookings : "books" - services ||--o{ service_bookings : "booked via" - profiles ||--o{ coaching_sessions : "coaches" - profiles ||--o{ coaching_sessions : "attends" - services ||--o{ coaching_sessions : "fulfilled by" + profiles ||--o{ service_bookings : "user" + services ||--o{ service_bookings : "service" + profiles ||--o{ coaching_sessions : "coach" + profiles ||--o{ coaching_sessions : "user" + services ||--o{ coaching_sessions : "service" + profiles ||--o| subscriptions : "0..1" + profiles ||--o{ purchases : "user" + profiles ||--o{ profile_service_discounts : "user" + services ||--o{ profile_service_discounts : "service" ``` ## Enums | Enum | Values | |---|---| +| `booking_status` | `pending`, `confirmed`, `cancelled` | | `role` | `user`, `admin`, `coach` | | `service_type` | `coaching_session`, `booking` | -| `booking_status` | `pending`, `confirmed`, `cancelled` | -| `webinar_tier` | `free`, `premium` | | `session_status` | `pending`, `confirmed`, `cancelled`, `completed` | +| `webinar_tier` | `free`, `premium` | diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 07ff6d3..3cf6270 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -7,6 +7,7 @@ import { integer, boolean, jsonb, + unique, } from "drizzle-orm/pg-core"; export const roleEnum = pgEnum("role", ["user", "admin", "coach"]); @@ -45,6 +46,8 @@ export const services = pgTable("services", { scheduledAt: jsonb("scheduled_at"), durationMinutes: integer("duration_minutes").notNull(), price: integer("price").notNull().default(0), + stripeProductId: text("stripe_product_id"), + stripeDefaultPriceId: text("stripe_default_price_id"), isActive: boolean("is_active").notNull().default(true), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), @@ -127,3 +130,27 @@ export const purchases = pgTable("purchases", { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); + +export const profileServiceDiscounts = pgTable( + "profile_service_discounts", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .references(() => profiles.id, { onDelete: "cascade" }) + .notNull(), + serviceId: uuid("service_id") + .references(() => services.id, { onDelete: "cascade" }) + .notNull(), + stripeCouponId: text("stripe_coupon_id").notNull(), + maxUses: integer("max_uses"), + useCount: integer("use_count").notNull().default(0), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + unique("profile_service_discounts_user_service_unique").on( + table.userId, + table.serviceId, + ), + ], +); diff --git a/package.json b/package.json index 8144fd4..bd6e310 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "docs:schema": "tsx scripts/generate-schema-overview.ts" }, "dependencies": { "@supabase/ssr": "^0.9.0", diff --git a/scripts/generate-schema-overview.ts b/scripts/generate-schema-overview.ts new file mode 100644 index 0000000..02b4873 --- /dev/null +++ b/scripts/generate-schema-overview.ts @@ -0,0 +1,190 @@ +// Writes docs/schema-overview.md from lib/db/schema.ts. +// Optional JSON: scripts/schema-overview.config.json — relationshipLines, columnQuotes. +// npm run docs:schema | npm run docs:schema -- --config path/to.json + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as schema from "../lib/db/schema"; +import { getTableName, isTable, type Table } from "drizzle-orm"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const outFile = path.join(root, "docs/schema-overview.md"); +const defaultConfig = path.join(root, "scripts/schema-overview.config.json"); + +const symCols = Symbol.for("drizzle:Columns"); +const symFks = Symbol.for("drizzle:PgInlineForeignKeys"); + +type Config = { + relationshipLines?: string[]; + columnQuotes?: Record; +}; + +type Col = { + name: string; + primary: boolean; + columnType: string; + enum?: { enumName: string }; +}; + +function readConfig(p: string): Config { + if (!fs.existsSync(p)) { + console.warn(`no config at ${p}, using auto FK lines only`); + return {}; + } + return JSON.parse(fs.readFileSync(p, "utf8")) as Config; +} + +function tables(): Table[] { + const t: Table[] = []; + for (const x of Object.values(schema)) { + if (isTable(x)) t.push(x); + } + return t.sort((a, b) => getTableName(a).localeCompare(getTableName(b))); +} + +function mermaidType(c: Col): string { + if (c.columnType === "PgEnumColumn" && c.enum?.enumName) { + return `${c.enum.enumName} ${c.name}`; + } + const pg: Record = { + PgUUID: "uuid", + PgText: "text", + PgTimestamp: "timestamp", + PgInteger: "int", + PgBoolean: "boolean", + PgJsonb: "jsonb", + }; + const t = + pg[c.columnType] ?? c.columnType.replace(/^Pg/, "").toLowerCase(); + return `${t} ${c.name}`; +} + +function fkColumnKeys(ts: Table[]): Set { + const s = new Set(); + for (const tbl of ts) { + const child = getTableName(tbl); + const fks = (tbl as unknown as Record)[symFks] as + | { reference: () => { columns: { name: string }[] } }[] + | undefined; + if (!fks) continue; + for (const fk of fks) { + for (const c of fk.reference().columns) { + s.add(`${child}.${c.name}`); + } + } + } + return s; +} + +function edgesFromFks(ts: Table[]): string[] { + const out: string[] = []; + for (const tbl of ts) { + const child = getTableName(tbl); + const fks = (tbl as unknown as Record)[symFks] as + | { + reference: () => { + columns: { name: string; isUnique: boolean }[]; + foreignTable: Table; + }; + }[] + | undefined; + if (!fks) continue; + for (const fk of fks) { + const r = fk.reference(); + const col = r.columns[0]!; + const parent = getTableName(r.foreignTable); + const label = col.name.replace(/_id$/, "") || col.name; + const mid = col.isUnique ? "||--o|" : "||--o{"; + out.push(` ${parent} ${mid} ${child} : "${label}"`); + } + } + return out.sort((a, b) => a.localeCompare(b)); +} + +function entity( + tbl: Table, + fkCols: Set, + quotes: Record, +): string { + const name = getTableName(tbl); + const cols = (tbl as unknown as Record)[symCols] as Record< + string, + Col + >; + const lines = [` ${name} {`]; + for (const c of Object.values(cols)) { + const k = `${name}.${c.name}`; + let row = ` ${mermaidType(c)}`; + if (c.primary) row += " PK"; + else if (fkCols.has(k)) row += " FK"; + const q = quotes[k]; + if (q) row += ` "${q}"`; + lines.push(row); + } + lines.push(" }"); + return lines.join("\n"); +} + +function pgEnums(): { name: string; values: string[] }[] { + const rows: { name: string; values: string[] }[] = []; + for (const x of Object.values(schema)) { + if ( + typeof x === "function" && + "enumName" in x && + Array.isArray((x as { enumValues?: string[] }).enumValues) + ) { + const e = x as { enumName: string; enumValues: string[] }; + rows.push({ name: e.enumName, values: [...e.enumValues] }); + } + } + return rows.sort((a, b) => a.name.localeCompare(b.name)); +} + +let configPath = defaultConfig; +for (let i = 2; i < process.argv.length; i++) { + if (process.argv[i] === "--config" && process.argv[i + 1]) { + configPath = path.resolve(root, process.argv[++i]!); + } +} + +const cfg = readConfig(configPath); +const ts = tables(); +const fkCols = fkColumnKeys(ts); +const quotes = cfg.columnQuotes ?? {}; + +const blocks = ts.map((t) => entity(t, fkCols, quotes)); +const rel = + cfg.relationshipLines?.length + ? cfg.relationshipLines.map((l) => ` ${l.trim()}`) + : edgesFromFks(ts); + +const enumMd = + "| Enum | Values |\n|---|---|\n" + + pgEnums() + .map( + (e) => + `| \`${e.name}\` | ${e.values.map((v) => `\`${v}\``).join(", ")} |`, + ) + .join("\n"); + +const body = ["erDiagram", ...blocks, "", ...rel].join("\n"); + +fs.writeFileSync( + outFile, + `# MCLD Platform — Full Schema Overview + + + +\`\`\`mermaid +${body} +\`\`\` + +## Enums + +${enumMd} +`, + "utf8", +); + +console.log(path.relative(process.cwd(), outFile)); diff --git a/scripts/schema-overview.config.json b/scripts/schema-overview.config.json new file mode 100644 index 0000000..12db51a --- /dev/null +++ b/scripts/schema-overview.config.json @@ -0,0 +1,17 @@ +{ + "relationshipLines": [ + "profiles ||--o{ service_bookings : \"user\"", + "services ||--o{ service_bookings : \"service\"", + "profiles ||--o{ coaching_sessions : \"coach\"", + "profiles ||--o{ coaching_sessions : \"user\"", + "services ||--o{ coaching_sessions : \"service\"", + "profiles ||--o| subscriptions : \"0..1\"", + "profiles ||--o{ purchases : \"user\"", + "profiles ||--o{ profile_service_discounts : \"user\"", + "services ||--o{ profile_service_discounts : \"service\"" + ], + "columnQuotes": { + "services.scheduled_at": "null for coaching_session", + "coaching_sessions.selected_time_slots": "array of {start, end} objects" + } +}