Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<!-- END:nextjs-agent-rules -->

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`.
98 changes: 68 additions & 30 deletions docs/schema-overview.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,53 @@
# MCLD Platform — Full Schema Overview

<!-- Generated — edit lib/db/schema + config, then npm run docs:schema -->

```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
Expand All @@ -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` |
27 changes: 27 additions & 0 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
integer,
boolean,
jsonb,
unique,
} from "drizzle-orm/pg-core";

export const roleEnum = pgEnum("role", ["user", "admin", "coach"]);
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
),
],
);
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
190 changes: 190 additions & 0 deletions scripts/generate-schema-overview.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};

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<string, string> = {
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<string> {
const s = new Set<string>();
for (const tbl of ts) {
const child = getTableName(tbl);
const fks = (tbl as unknown as Record<symbol, unknown>)[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<symbol, unknown>)[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<string>,
quotes: Record<string, string>,
): string {
const name = getTableName(tbl);
const cols = (tbl as unknown as Record<symbol, unknown>)[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

<!-- Generated — edit lib/db/schema + config, then npm run docs:schema -->

\`\`\`mermaid
${body}
\`\`\`

## Enums

${enumMd}
`,
"utf8",
);

console.log(path.relative(process.cwd(), outFile));
Loading