Add lessons domain Phase 1: instructor and lesson type entities

Foundation tables for the lessons module with full CRUD, pagination,
search, and sorting. Includes migration, Drizzle schema, Zod validation,
services, routes, and 23 integration tests.
This commit is contained in:
Ryan Moon
2026-03-30 09:17:32 -05:00
parent 145eb0efce
commit 5dbe837c08
10 changed files with 603 additions and 1 deletions

View File

@@ -97,7 +97,7 @@ async function setupDatabase() {
{ slug: 'pos', name: 'Point of Sale', description: 'Sales transactions, cash drawer, and receipts', enabled: true }, { slug: 'pos', name: 'Point of Sale', description: 'Sales transactions, cash drawer, and receipts', enabled: true },
{ slug: 'repairs', name: 'Repairs', description: 'Repair ticket management, batches, and service templates', enabled: true }, { slug: 'repairs', name: 'Repairs', description: 'Repair ticket management, batches, and service templates', enabled: true },
{ slug: 'rentals', name: 'Rentals', description: 'Rental agreements and billing', enabled: false }, { slug: 'rentals', name: 'Rentals', description: 'Rental agreements and billing', enabled: false },
{ slug: 'lessons', name: 'Lessons', description: 'Scheduling, staff management, and billing', enabled: false }, { slug: 'lessons', name: 'Lessons', description: 'Scheduling, staff management, and billing', enabled: true },
{ slug: 'files', name: 'Files', description: 'Shared file storage with folder organization', enabled: true }, { slug: 'files', name: 'Files', description: 'Shared file storage with folder organization', enabled: true },
{ slug: 'vault', name: 'Vault', description: 'Encrypted password and secret manager', enabled: true }, { slug: 'vault', name: 'Vault', description: 'Encrypted password and secret manager', enabled: true },
{ slug: 'email', name: 'Email', description: 'Email campaigns, templates, and sending', enabled: false }, { slug: 'email', name: 'Email', description: 'Email campaigns, templates, and sending', enabled: false },

View File

@@ -0,0 +1,219 @@
import { suite } from '../lib/context.js'
suite('Lessons', { tags: ['lessons'] }, (t) => {
// ─── Instructors: CRUD ───
t.test('creates an instructor', { tags: ['instructors', 'create'] }, async () => {
const res = await t.api.post('/v1/instructors', {
displayName: 'Sarah Mitchell',
bio: 'Piano and voice instructor with 10 years experience',
instruments: ['Piano', 'Voice'],
})
t.assert.status(res, 201)
t.assert.equal(res.data.displayName, 'Sarah Mitchell')
t.assert.ok(res.data.id)
t.assert.equal(res.data.isActive, true)
t.assert.equal(res.data.instruments.length, 2)
t.assert.equal(res.data.instruments[0], 'Piano')
})
t.test('creates an instructor with minimal fields', { tags: ['instructors', 'create'] }, async () => {
const res = await t.api.post('/v1/instructors', {
displayName: 'John Doe',
})
t.assert.status(res, 201)
t.assert.equal(res.data.displayName, 'John Doe')
t.assert.equal(res.data.bio, null)
t.assert.equal(res.data.instruments, null)
})
t.test('rejects instructor creation without display name', { tags: ['instructors', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/instructors', {})
t.assert.status(res, 400)
})
t.test('gets instructor by id', { tags: ['instructors', 'read'] }, async () => {
const created = await t.api.post('/v1/instructors', { displayName: 'Get By ID Instructor' })
const res = await t.api.get(`/v1/instructors/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.displayName, 'Get By ID Instructor')
})
t.test('returns 404 for missing instructor', { tags: ['instructors', 'read'] }, async () => {
const res = await t.api.get('/v1/instructors/a0000000-0000-0000-0000-999999999999')
t.assert.status(res, 404)
})
t.test('updates an instructor', { tags: ['instructors', 'update'] }, async () => {
const created = await t.api.post('/v1/instructors', { displayName: 'Before Update', bio: 'Old bio' })
const res = await t.api.patch(`/v1/instructors/${created.data.id}`, {
displayName: 'After Update',
bio: 'New bio',
instruments: ['Guitar', 'Bass'],
})
t.assert.status(res, 200)
t.assert.equal(res.data.displayName, 'After Update')
t.assert.equal(res.data.bio, 'New bio')
t.assert.equal(res.data.instruments.length, 2)
})
t.test('soft-deletes an instructor', { tags: ['instructors', 'delete'] }, async () => {
const created = await t.api.post('/v1/instructors', { displayName: 'To Delete' })
const res = await t.api.del(`/v1/instructors/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Instructors: List, Search, Sort ───
t.test('lists instructors with pagination', { tags: ['instructors', 'read', 'pagination'] }, async () => {
await t.api.post('/v1/instructors', { displayName: 'List Test A' })
await t.api.post('/v1/instructors', { displayName: 'List Test B' })
const res = await t.api.get('/v1/instructors', { limit: 100 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination.total >= 2)
})
t.test('searches instructors by display name', { tags: ['instructors', 'search'] }, async () => {
await t.api.post('/v1/instructors', { displayName: 'Searchable Piano Teacher' })
const res = await t.api.get('/v1/instructors', { q: 'Piano Teacher' })
t.assert.status(res, 200)
t.assert.ok(res.data.data.some((i: any) => i.displayName === 'Searchable Piano Teacher'))
})
t.test('sorts instructors by display name descending', { tags: ['instructors', 'sort'] }, async () => {
await t.api.post('/v1/instructors', { displayName: 'AAA First Instructor' })
await t.api.post('/v1/instructors', { displayName: 'ZZZ Last Instructor' })
const res = await t.api.get('/v1/instructors', { sort: 'display_name', order: 'desc', limit: 100 })
t.assert.status(res, 200)
const names = res.data.data.map((i: any) => i.displayName)
const zIdx = names.findIndex((n: string) => n.includes('ZZZ'))
const aIdx = names.findIndex((n: string) => n.includes('AAA'))
t.assert.ok(zIdx < aIdx, 'ZZZ should come before AAA in desc order')
})
t.test('deleted instructor does not appear in list', { tags: ['instructors', 'delete', 'list'] }, async () => {
const created = await t.api.post('/v1/instructors', { displayName: 'Ghost Instructor XYZ' })
await t.api.del(`/v1/instructors/${created.data.id}`)
const res = await t.api.get('/v1/instructors', { q: 'Ghost Instructor XYZ', limit: 100 })
t.assert.equal(res.data.data.length, 0)
})
// ─── Lesson Types: CRUD ───
t.test('creates a lesson type', { tags: ['lesson-types', 'create'] }, async () => {
const res = await t.api.post('/v1/lesson-types', {
name: '30-min Private Piano',
instrument: 'Piano',
durationMinutes: 30,
lessonFormat: 'private',
baseRateMonthly: 120,
})
t.assert.status(res, 201)
t.assert.equal(res.data.name, '30-min Private Piano')
t.assert.equal(res.data.instrument, 'Piano')
t.assert.equal(res.data.durationMinutes, 30)
t.assert.equal(res.data.lessonFormat, 'private')
t.assert.equal(res.data.baseRateMonthly, '120')
t.assert.ok(res.data.id)
})
t.test('creates a group lesson type', { tags: ['lesson-types', 'create'] }, async () => {
const res = await t.api.post('/v1/lesson-types', {
name: '60-min Group Guitar',
instrument: 'Guitar',
durationMinutes: 60,
lessonFormat: 'group',
baseRateMonthly: 80,
})
t.assert.status(res, 201)
t.assert.equal(res.data.lessonFormat, 'group')
})
t.test('rejects lesson type without required fields', { tags: ['lesson-types', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/lesson-types', {})
t.assert.status(res, 400)
})
t.test('rejects lesson type without duration', { tags: ['lesson-types', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/lesson-types', { name: 'No Duration' })
t.assert.status(res, 400)
})
t.test('gets lesson type by id', { tags: ['lesson-types', 'read'] }, async () => {
const created = await t.api.post('/v1/lesson-types', { name: 'Get By ID Type', durationMinutes: 45 })
const res = await t.api.get(`/v1/lesson-types/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'Get By ID Type')
})
t.test('returns 404 for missing lesson type', { tags: ['lesson-types', 'read'] }, async () => {
const res = await t.api.get('/v1/lesson-types/a0000000-0000-0000-0000-999999999999')
t.assert.status(res, 404)
})
t.test('updates a lesson type', { tags: ['lesson-types', 'update'] }, async () => {
const created = await t.api.post('/v1/lesson-types', { name: 'Before Update Type', durationMinutes: 30 })
const res = await t.api.patch(`/v1/lesson-types/${created.data.id}`, {
name: 'After Update Type',
durationMinutes: 45,
baseRateMonthly: 150,
})
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'After Update Type')
t.assert.equal(res.data.durationMinutes, 45)
t.assert.equal(res.data.baseRateMonthly, '150')
})
t.test('soft-deletes a lesson type', { tags: ['lesson-types', 'delete'] }, async () => {
const created = await t.api.post('/v1/lesson-types', { name: 'To Delete Type', durationMinutes: 30 })
const res = await t.api.del(`/v1/lesson-types/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Lesson Types: List, Search, Sort ───
t.test('lists lesson types with pagination', { tags: ['lesson-types', 'read', 'pagination'] }, async () => {
await t.api.post('/v1/lesson-types', { name: 'List Type A', durationMinutes: 30 })
await t.api.post('/v1/lesson-types', { name: 'List Type B', durationMinutes: 60 })
const res = await t.api.get('/v1/lesson-types', { limit: 100 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination.total >= 2)
})
t.test('searches lesson types by name', { tags: ['lesson-types', 'search'] }, async () => {
await t.api.post('/v1/lesson-types', { name: 'Searchable Violin Lesson', instrument: 'Violin', durationMinutes: 30 })
const res = await t.api.get('/v1/lesson-types', { q: 'Violin' })
t.assert.status(res, 200)
t.assert.ok(res.data.data.some((lt: any) => lt.name.includes('Violin')))
})
t.test('sorts lesson types by name descending', { tags: ['lesson-types', 'sort'] }, async () => {
await t.api.post('/v1/lesson-types', { name: 'AAA First Type', durationMinutes: 30 })
await t.api.post('/v1/lesson-types', { name: 'ZZZ Last Type', durationMinutes: 30 })
const res = await t.api.get('/v1/lesson-types', { sort: 'name', order: 'desc', limit: 100 })
t.assert.status(res, 200)
const names = res.data.data.map((lt: any) => lt.name)
const zIdx = names.findIndex((n: string) => n.includes('ZZZ'))
const aIdx = names.findIndex((n: string) => n.includes('AAA'))
t.assert.ok(zIdx < aIdx, 'ZZZ should come before AAA in desc order')
})
t.test('deleted lesson type does not appear in list', { tags: ['lesson-types', 'delete', 'list'] }, async () => {
const created = await t.api.post('/v1/lesson-types', { name: 'Ghost Type XYZ', durationMinutes: 30 })
await t.api.del(`/v1/lesson-types/${created.data.id}`)
const res = await t.api.get('/v1/lesson-types', { q: 'Ghost Type XYZ', limit: 100 })
t.assert.equal(res.data.data.length, 0)
})
})

View File

@@ -0,0 +1,26 @@
-- Phase 1: Lessons foundation — instructor + lesson_type tables
CREATE TYPE "lesson_format" AS ENUM ('private', 'group');
CREATE TABLE "instructor" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" uuid REFERENCES "user"("id"),
"display_name" varchar(255) NOT NULL,
"bio" text,
"instruments" text[],
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE "lesson_type" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" varchar(255) NOT NULL,
"instrument" varchar(100),
"duration_minutes" integer NOT NULL,
"lesson_format" lesson_format NOT NULL DEFAULT 'private',
"base_rate_monthly" varchar(20),
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);

View File

@@ -197,6 +197,13 @@
"when": 1774870000000, "when": 1774870000000,
"tag": "0027_generalize_terminology", "tag": "0027_generalize_terminology",
"breakpoints": true "breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1774880000000,
"tag": "0028_lessons_foundation",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,47 @@
import {
pgTable,
uuid,
varchar,
text,
timestamp,
boolean,
integer,
pgEnum,
} from 'drizzle-orm/pg-core'
import { users } from './users.js'
// --- Enums ---
export const lessonFormatEnum = pgEnum('lesson_format', ['private', 'group'])
// --- Tables ---
export const instructors = pgTable('instructor', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id),
displayName: varchar('display_name', { length: 255 }).notNull(),
bio: text('bio'),
instruments: text('instruments').array(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const lessonTypes = pgTable('lesson_type', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 255 }).notNull(),
instrument: varchar('instrument', { length: 100 }),
durationMinutes: integer('duration_minutes').notNull(),
lessonFormat: lessonFormatEnum('lesson_format').notNull().default('private'),
baseRateMonthly: varchar('base_rate_monthly', { length: 20 }),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
// --- Type exports ---
export type Instructor = typeof instructors.$inferSelect
export type InstructorInsert = typeof instructors.$inferInsert
export type LessonType = typeof lessonTypes.$inferSelect
export type LessonTypeInsert = typeof lessonTypes.$inferInsert

View File

@@ -16,6 +16,7 @@ import { lookupRoutes } from './routes/v1/lookups.js'
import { fileRoutes } from './routes/v1/files.js' import { fileRoutes } from './routes/v1/files.js'
import { rbacRoutes } from './routes/v1/rbac.js' import { rbacRoutes } from './routes/v1/rbac.js'
import { repairRoutes } from './routes/v1/repairs.js' import { repairRoutes } from './routes/v1/repairs.js'
import { lessonRoutes } from './routes/v1/lessons.js'
import { storageRoutes } from './routes/v1/storage.js' import { storageRoutes } from './routes/v1/storage.js'
import { storeRoutes } from './routes/v1/store.js' import { storeRoutes } from './routes/v1/store.js'
import { vaultRoutes } from './routes/v1/vault.js' import { vaultRoutes } from './routes/v1/vault.js'
@@ -104,6 +105,7 @@ export async function buildApp() {
await app.register(withModule('files', fileRoutes), { prefix: '/v1' }) await app.register(withModule('files', fileRoutes), { prefix: '/v1' })
await app.register(withModule('files', storageRoutes), { prefix: '/v1' }) await app.register(withModule('files', storageRoutes), { prefix: '/v1' })
await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' }) await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' })
await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' })
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' }) await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })
// Register WebDAV custom HTTP methods before routes // Register WebDAV custom HTTP methods before routes
app.addHttpMethod('PROPFIND', { hasBody: true }) app.addHttpMethod('PROPFIND', { hasBody: true })

View File

@@ -0,0 +1,95 @@
import type { FastifyPluginAsync } from 'fastify'
import {
PaginationSchema,
InstructorCreateSchema,
InstructorUpdateSchema,
LessonTypeCreateSchema,
LessonTypeUpdateSchema,
} from '@lunarfront/shared/schemas'
import { InstructorService, LessonTypeService } from '../../services/lesson.service.js'
export const lessonRoutes: FastifyPluginAsync = async (app) => {
// --- Instructors ---
app.post('/instructors', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
const parsed = InstructorCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const instructor = await InstructorService.create(app.db, parsed.data)
return reply.status(201).send(instructor)
})
app.get('/instructors', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await InstructorService.list(app.db, params)
return reply.send(result)
})
app.get('/instructors/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const instructor = await InstructorService.getById(app.db, id)
if (!instructor) return reply.status(404).send({ error: { message: 'Instructor not found', statusCode: 404 } })
return reply.send(instructor)
})
app.patch('/instructors/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = InstructorUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const instructor = await InstructorService.update(app.db, id, parsed.data)
if (!instructor) return reply.status(404).send({ error: { message: 'Instructor not found', statusCode: 404 } })
return reply.send(instructor)
})
app.delete('/instructors/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const instructor = await InstructorService.delete(app.db, id)
if (!instructor) return reply.status(404).send({ error: { message: 'Instructor not found', statusCode: 404 } })
return reply.send(instructor)
})
// --- Lesson Types ---
app.post('/lesson-types', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
const parsed = LessonTypeCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const lessonType = await LessonTypeService.create(app.db, parsed.data)
return reply.status(201).send(lessonType)
})
app.get('/lesson-types', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await LessonTypeService.list(app.db, params)
return reply.send(result)
})
app.get('/lesson-types/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const lessonType = await LessonTypeService.getById(app.db, id)
if (!lessonType) return reply.status(404).send({ error: { message: 'Lesson type not found', statusCode: 404 } })
return reply.send(lessonType)
})
app.patch('/lesson-types/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = LessonTypeUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const lessonType = await LessonTypeService.update(app.db, id, parsed.data)
if (!lessonType) return reply.status(404).send({ error: { message: 'Lesson type not found', statusCode: 404 } })
return reply.send(lessonType)
})
app.delete('/lesson-types/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const lessonType = await LessonTypeService.delete(app.db, id)
if (!lessonType) return reply.status(404).send({ error: { message: 'Lesson type not found', statusCode: 404 } })
return reply.send(lessonType)
})
}

View File

@@ -0,0 +1,154 @@
import { eq, and, count, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { instructors, lessonTypes } from '../db/schema/lessons.js'
import type {
InstructorCreateInput,
InstructorUpdateInput,
LessonTypeCreateInput,
LessonTypeUpdateInput,
PaginationInput,
} from '@lunarfront/shared/schemas'
import {
withPagination,
withSort,
buildSearchCondition,
paginatedResponse,
} from '../utils/pagination.js'
export const InstructorService = {
async create(db: PostgresJsDatabase<any>, input: InstructorCreateInput) {
const [instructor] = await db
.insert(instructors)
.values({
userId: input.userId,
displayName: input.displayName,
bio: input.bio,
instruments: input.instruments,
})
.returning()
return instructor
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [instructor] = await db
.select()
.from(instructors)
.where(eq(instructors.id, id))
.limit(1)
return instructor ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(instructors.isActive, true)
const searchCondition = params.q
? buildSearchCondition(params.q, [instructors.displayName])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
display_name: instructors.displayName,
created_at: instructors.createdAt,
}
let query = db.select().from(instructors).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, instructors.displayName)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(instructors).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: InstructorUpdateInput) {
const [instructor] = await db
.update(instructors)
.set({ ...input, updatedAt: new Date() })
.where(eq(instructors.id, id))
.returning()
return instructor ?? null
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [instructor] = await db
.update(instructors)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(instructors.id, id))
.returning()
return instructor ?? null
},
}
export const LessonTypeService = {
async create(db: PostgresJsDatabase<any>, input: LessonTypeCreateInput) {
const [lessonType] = await db
.insert(lessonTypes)
.values({
name: input.name,
instrument: input.instrument,
durationMinutes: input.durationMinutes,
lessonFormat: input.lessonFormat,
baseRateMonthly: input.baseRateMonthly?.toString(),
})
.returning()
return lessonType
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [lessonType] = await db
.select()
.from(lessonTypes)
.where(eq(lessonTypes.id, id))
.limit(1)
return lessonType ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(lessonTypes.isActive, true)
const searchCondition = params.q
? buildSearchCondition(params.q, [lessonTypes.name, lessonTypes.instrument])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
name: lessonTypes.name,
instrument: lessonTypes.instrument,
duration_minutes: lessonTypes.durationMinutes,
created_at: lessonTypes.createdAt,
}
let query = db.select().from(lessonTypes).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, lessonTypes.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(lessonTypes).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: LessonTypeUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.baseRateMonthly !== undefined) values.baseRateMonthly = input.baseRateMonthly.toString()
const [lessonType] = await db
.update(lessonTypes)
.set(values)
.where(eq(lessonTypes.id, id))
.returning()
return lessonType ?? null
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [lessonType] = await db
.update(lessonTypes)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(lessonTypes.id, id))
.returning()
return lessonType ?? null
},
}

View File

@@ -99,3 +99,17 @@ export type {
RepairServiceTemplateCreateInput, RepairServiceTemplateCreateInput,
RepairServiceTemplateUpdateInput, RepairServiceTemplateUpdateInput,
} from './repairs.schema.js' } from './repairs.schema.js'
export {
LessonFormat,
InstructorCreateSchema,
InstructorUpdateSchema,
LessonTypeCreateSchema,
LessonTypeUpdateSchema,
} from './lessons.schema.js'
export type {
InstructorCreateInput,
InstructorUpdateInput,
LessonTypeCreateInput,
LessonTypeUpdateInput,
} from './lessons.schema.js'

View File

@@ -0,0 +1,38 @@
import { z } from 'zod'
/** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */
function opt<T extends z.ZodTypeAny>(schema: T) {
return z.preprocess((v) => (v === '' ? undefined : v), schema.optional())
}
// --- Enums ---
export const LessonFormat = z.enum(['private', 'group'])
export type LessonFormat = z.infer<typeof LessonFormat>
// --- Instructor schemas ---
export const InstructorCreateSchema = z.object({
userId: opt(z.string().uuid()),
displayName: z.string().min(1).max(255),
bio: opt(z.string()),
instruments: z.array(z.string()).optional(),
})
export type InstructorCreateInput = z.infer<typeof InstructorCreateSchema>
export const InstructorUpdateSchema = InstructorCreateSchema.partial()
export type InstructorUpdateInput = z.infer<typeof InstructorUpdateSchema>
// --- Lesson Type schemas ---
export const LessonTypeCreateSchema = z.object({
name: z.string().min(1).max(255),
instrument: opt(z.string().max(100)),
durationMinutes: z.coerce.number().int().min(5).max(480),
lessonFormat: LessonFormat.default('private'),
baseRateMonthly: z.coerce.number().min(0).optional(),
})
export type LessonTypeCreateInput = z.infer<typeof LessonTypeCreateSchema>
export const LessonTypeUpdateSchema = LessonTypeCreateSchema.partial()
export type LessonTypeUpdateInput = z.infer<typeof LessonTypeUpdateSchema>