Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
95 changes: 76 additions & 19 deletions docs/schema-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,31 @@ erDiagram
text first_name
text last_name
role role
text stripe_customer_id
timestamp last_login_at
timestamp created_at
timestamp updated_at
}

forms {
uuid id PK
text name
timestamp created_at
timestamp updated_at
}

services {
uuid id PK
text title
text description
service_type type
jsonb scheduled_at "null for coaching_session"
date start_date "null for private_lessons"
date end_date "null for private_lessons"
jsonb slots "array of {dayOfWeek, time}; null for private_lessons"
int duration_minutes
int price
boolean is_active
text stripe_product_id
service_status status
uuid coach_id FK "null for programs"
uuid form_id FK "nullable"
boolean is_for_children
timestamp created_at
timestamp updated_at
}
Expand All @@ -28,9 +40,11 @@ erDiagram
uuid id PK
uuid user_id FK
uuid service_id FK
uuid child_id FK "nullable; null means adult registration"
booking_status status
text notes
boolean is_active
text stripe_order_id
timestamp created_at
timestamp updated_at
}
Expand All @@ -52,12 +66,41 @@ erDiagram
uuid service_id FK
uuid coach_id FK
uuid user_id FK
timestamp scheduled_at
int duration_minutes
uuid child_id FK "nullable; null means adult registration"
timestamp scheduled_at "set when slot is confirmed"
session_status status
text meeting_url
text notes
jsonb selected_time_slots "array of {start, end} objects"
jsonb coach_time_slots
text coach_token
text client_token
text stripe_order_id
timestamp created_at
timestamp updated_at
}

subscriptions {
uuid id PK
uuid user_id FK
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
}

purchases {
uuid id PK
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
}
Expand Down Expand Up @@ -87,19 +130,20 @@ erDiagram
timestamp updated_at
}

extra_questions {
form_questions {
uuid id PK
uuid service_id FK
extra_question_type type
uuid form_id FK
form_question_type type
text prompt
jsonb options
int sort_order
timestamp created_at
timestamp updated_at
}

extra_question_answers {
form_question_answers {
uuid id PK
uuid extra_question_id FK
uuid form_question_id FK
uuid child_id FK
text answer "text[]"
timestamp created_at
Expand All @@ -108,24 +152,37 @@ erDiagram

profiles ||--o{ service_bookings : "books"
services ||--o{ service_bookings : "booked via"
children |o--o{ service_bookings : "registered for"
profiles ||--o{ coaching_sessions : "coaches"
profiles ||--o{ coaching_sessions : "attends"
services ||--o{ coaching_sessions : "fulfilled by"
children |o--o{ coaching_sessions : "registered for"
profiles ||--o{ children : "parent of"
children ||--o{ emergency_contacts : "has"
services ||--o{ extra_questions : "defines"
extra_questions ||--o{ extra_question_answers : "answered via"
children ||--o{ extra_question_answers : "submits"
profiles ||--o| subscriptions : "has"
profiles ||--o{ purchases : "makes"
profiles |o--o{ services : "coaches"
forms ||--o{ form_questions : "contains"
services }o--o| forms : "uses"
form_questions ||--o{ form_question_answers : "answered via"
children ||--o{ form_question_answers : "submits"
```

## Indexes

| Table | Index | Type | Condition |
|---|---|---|---|
| `service_bookings` | `service_bookings_service_id_child_id_idx` | Unique (partial) | `WHERE child_id IS NOT NULL` — prevents the same child from registering for the same program twice |

## Enums

| Enum | Values |
|---|---|
| `role` | `user`, `admin`, `coach` |
| `service_type` | `coaching_session`, `booking` |
| `booking_status` | `pending`, `confirmed`, `cancelled` |
| `service_type` | `private_lessons`, `programs` |
| `service_status` | `active`, `disabled`, `archived`, `deleted` |
| `booking_status` | `awaiting_payment`, `pending`, `confirmed`, `cancelled` |
| `session_status` | `awaiting_payment`, `pending`, `confirmed`, `cancelled`, `completed` |
| `webinar_tier` | `free`, `premium` |
| `session_status` | `pending`, `confirmed`, `cancelled`, `completed` |
| `gender` | `male`, `female`, `prefer_not_to_say` |
| `extra_question_type` | `text`, `multiple_choices`, `checkboxes`, `user_agreement` |
| `form_question_type` | `text`, `multiple_choices`, `checkboxes`, `user_agreement` |
79 changes: 54 additions & 25 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import {
boolean,
jsonb,
date,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";

export type ProgramSlot = { dayOfWeek: number; time: string };

export type ExtraQuestionOption = {
export type FormQuestionOption = {
id: string;
title: string;
description?: string;
Expand Down Expand Up @@ -44,7 +46,7 @@ export const serviceStatusEnum = pgEnum("service_status", [
"disabled",
]);
export const genderEnum = pgEnum("gender", ["male", "female", "prefer_not_to_say"]);
export const extraQuestionTypeEnum = pgEnum("extra_question_type", [
export const formQuestionTypeEnum = pgEnum("form_question_type", [
"text",
"multiple_choices",
"checkboxes",
Expand All @@ -62,6 +64,13 @@ export const profiles = pgTable("profiles", {
lastLoginAt: timestamp('last_login_at').defaultNow().notNull()
});

export const forms = pgTable("forms", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(),
type: serviceTypeEnum("type").notNull(),
Expand All @@ -74,25 +83,38 @@ export const services = pgTable("services", {
coachId: uuid("coach_id").references(() => profiles.id, {
onDelete: "set null",
}),
formId: uuid("form_id").references(() => forms.id, { onDelete: "set null" }),
isForChildren: boolean("is_for_children").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

export const serviceBookings = pgTable("service_bookings", {
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(),
status: bookingStatusEnum("status").notNull().default("pending"),
notes: text("notes"),
isActive: boolean("is_active").notNull().default(true),
stripeOrderId: text("stripe_order_id").unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const serviceBookings = pgTable(
"service_bookings",
{
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(),
childId: uuid("child_id").references(() => children.id, {
onDelete: "cascade",
}),
status: bookingStatusEnum("status").notNull().default("pending"),
notes: text("notes"),
isActive: boolean("is_active").notNull().default(true),
stripeOrderId: text("stripe_order_id").unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(t) => [
uniqueIndex("service_bookings_service_id_child_id_idx")
.on(t.serviceId, t.childId)
.where(sql`${t.childId} is not null`),
],
);

export const webinars = pgTable("webinars", {
id: uuid("id").primaryKey().defaultRandom(),
Expand All @@ -117,11 +139,17 @@ export const coachingSessions = pgTable("coaching_sessions", {
userId: uuid("user_id")
.references(() => profiles.id, { onDelete: "cascade" })
.notNull(),
childId: uuid("child_id").references(() => children.id, {
onDelete: "cascade",
}),
scheduledAt: timestamp("scheduled_at"),
status: sessionStatusEnum("status").notNull().default("pending"),
meetingUrl: text("meeting_url"),
notes: text("notes"),
selectedTimeSlots: jsonb("selected_time_slots").notNull(),
coachTimeSlots: jsonb("coach_time_slots"),
coachToken: text("coach_token").unique(),
clientToken: text("client_token").unique(),
stripeOrderId: text("stripe_order_id").unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
Expand Down Expand Up @@ -186,22 +214,23 @@ export const emergencyContacts = pgTable("emergency_contacts", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

export const extraQuestions = pgTable("extra_questions", {
export const formQuestions = pgTable("form_questions", {
id: uuid("id").primaryKey().defaultRandom(),
serviceId: uuid("service_id")
.references(() => services.id, { onDelete: "cascade" })
formId: uuid("form_id")
.references(() => forms.id, { onDelete: "cascade" })
.notNull(),
type: extraQuestionTypeEnum("type").notNull(),
type: formQuestionTypeEnum("type").notNull(),
prompt: text("prompt").notNull(),
options: jsonb("options").$type<ExtraQuestionOption[]>(),
options: jsonb("options").$type<FormQuestionOption[]>(),
sortOrder: integer("sort_order").notNull(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sortOrder: integer("sort_order").notNull(),
sortOrder: integer("sort_order").notNull().default(0)

maybe safer to add a default? or a n+1?

createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

export const extraQuestionAnswers = pgTable("extra_question_answers", {
export const formQuestionAnswers = pgTable("form_question_answers", {
id: uuid("id").primaryKey().defaultRandom(),
extraQuestionId: uuid("extra_question_id")
.references(() => extraQuestions.id, { onDelete: "cascade" })
formQuestionId: uuid("form_question_id")
.references(() => formQuestions.id, { onDelete: "cascade" })
.notNull(),
childId: uuid("child_id")
.references(() => children.id, { onDelete: "cascade" })
Expand Down