diff --git a/packages/backend/api-tests/run.ts b/packages/backend/api-tests/run.ts index a1d4b3f..54e0187 100644 --- a/packages/backend/api-tests/run.ts +++ b/packages/backend/api-tests/run.ts @@ -97,7 +97,7 @@ async function setupDatabase() { { 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: '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: 'vault', name: 'Vault', description: 'Encrypted password and secret manager', enabled: true }, { slug: 'email', name: 'Email', description: 'Email campaigns, templates, and sending', enabled: false }, diff --git a/packages/backend/api-tests/suites/lessons.ts b/packages/backend/api-tests/suites/lessons.ts new file mode 100644 index 0000000..7d39865 --- /dev/null +++ b/packages/backend/api-tests/suites/lessons.ts @@ -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) + }) +}) diff --git a/packages/backend/src/db/migrations/0028_lessons_foundation.sql b/packages/backend/src/db/migrations/0028_lessons_foundation.sql new file mode 100644 index 0000000..fb8c380 --- /dev/null +++ b/packages/backend/src/db/migrations/0028_lessons_foundation.sql @@ -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() +); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 4496877..5786799 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1774870000000, "tag": "0027_generalize_terminology", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1774880000000, + "tag": "0028_lessons_foundation", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/lessons.ts b/packages/backend/src/db/schema/lessons.ts new file mode 100644 index 0000000..42ab560 --- /dev/null +++ b/packages/backend/src/db/schema/lessons.ts @@ -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 diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 16511ff..81c7b69 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -16,6 +16,7 @@ import { lookupRoutes } from './routes/v1/lookups.js' import { fileRoutes } from './routes/v1/files.js' import { rbacRoutes } from './routes/v1/rbac.js' import { repairRoutes } from './routes/v1/repairs.js' +import { lessonRoutes } from './routes/v1/lessons.js' import { storageRoutes } from './routes/v1/storage.js' import { storeRoutes } from './routes/v1/store.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', storageRoutes), { 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' }) // Register WebDAV custom HTTP methods before routes app.addHttpMethod('PROPFIND', { hasBody: true }) diff --git a/packages/backend/src/routes/v1/lessons.ts b/packages/backend/src/routes/v1/lessons.ts new file mode 100644 index 0000000..ded07bc --- /dev/null +++ b/packages/backend/src/routes/v1/lessons.ts @@ -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) + }) +} diff --git a/packages/backend/src/services/lesson.service.ts b/packages/backend/src/services/lesson.service.ts new file mode 100644 index 0000000..bbfcb6b --- /dev/null +++ b/packages/backend/src/services/lesson.service.ts @@ -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, 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, id: string) { + const [instructor] = await db + .select() + .from(instructors) + .where(eq(instructors.id, id)) + .limit(1) + return instructor ?? null + }, + + async list(db: PostgresJsDatabase, 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 = { + 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, 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, 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, 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, id: string) { + const [lessonType] = await db + .select() + .from(lessonTypes) + .where(eq(lessonTypes.id, id)) + .limit(1) + return lessonType ?? null + }, + + async list(db: PostgresJsDatabase, 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 = { + 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, id: string, input: LessonTypeUpdateInput) { + const values: Record = { ...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, id: string) { + const [lessonType] = await db + .update(lessonTypes) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(lessonTypes.id, id)) + .returning() + return lessonType ?? null + }, +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 8577f5b..eda63a4 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -99,3 +99,17 @@ export type { RepairServiceTemplateCreateInput, RepairServiceTemplateUpdateInput, } from './repairs.schema.js' + +export { + LessonFormat, + InstructorCreateSchema, + InstructorUpdateSchema, + LessonTypeCreateSchema, + LessonTypeUpdateSchema, +} from './lessons.schema.js' +export type { + InstructorCreateInput, + InstructorUpdateInput, + LessonTypeCreateInput, + LessonTypeUpdateInput, +} from './lessons.schema.js' diff --git a/packages/shared/src/schemas/lessons.schema.ts b/packages/shared/src/schemas/lessons.schema.ts new file mode 100644 index 0000000..898d333 --- /dev/null +++ b/packages/shared/src/schemas/lessons.schema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +/** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */ +function opt(schema: T) { + return z.preprocess((v) => (v === '' ? undefined : v), schema.optional()) +} + +// --- Enums --- + +export const LessonFormat = z.enum(['private', 'group']) +export type LessonFormat = z.infer + +// --- 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 + +export const InstructorUpdateSchema = InstructorCreateSchema.partial() +export type InstructorUpdateInput = z.infer + +// --- 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 + +export const LessonTypeUpdateSchema = LessonTypeCreateSchema.partial() +export type LessonTypeUpdateInput = z.infer