diff --git a/packages/backend/api-tests/suites/lessons.ts b/packages/backend/api-tests/suites/lessons.ts index 7d39865..9d69839 100644 --- a/packages/backend/api-tests/suites/lessons.ts +++ b/packages/backend/api-tests/suites/lessons.ts @@ -216,4 +216,272 @@ suite('Lessons', { tags: ['lessons'] }, (t) => { const res = await t.api.get('/v1/lesson-types', { q: 'Ghost Type XYZ', limit: 100 }) t.assert.equal(res.data.data.length, 0) }) + + // ─── Schedule Slots: CRUD ─── + + t.test('creates a schedule slot', { tags: ['schedule-slots', 'create'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Slot Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Slot Lesson', durationMinutes: 30 }) + + const res = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 2, // Tuesday + startTime: '16:00', + room: 'Room A', + maxStudents: 1, + }) + t.assert.status(res, 201) + t.assert.equal(res.data.dayOfWeek, 2) + t.assert.equal(res.data.startTime, '16:00:00') + t.assert.equal(res.data.room, 'Room A') + t.assert.equal(res.data.maxStudents, 1) + t.assert.equal(res.data.isActive, true) + }) + + t.test('creates a group slot with higher max students', { tags: ['schedule-slots', 'create'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Group Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Group Lesson', durationMinutes: 60, lessonFormat: 'group' }) + + const res = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 4, // Thursday + startTime: '17:00', + room: 'Room B', + maxStudents: 6, + }) + t.assert.status(res, 201) + t.assert.equal(res.data.maxStudents, 6) + }) + + t.test('rejects schedule slot without required fields', { tags: ['schedule-slots', 'create', 'validation'] }, async () => { + const res = await t.api.post('/v1/schedule-slots', {}) + t.assert.status(res, 400) + }) + + t.test('rejects invalid day of week', { tags: ['schedule-slots', 'create', 'validation'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Day Validation Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Day Validation Type', durationMinutes: 30 }) + + const res = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 7, + startTime: '10:00', + }) + t.assert.status(res, 400) + }) + + t.test('rejects invalid time format', { tags: ['schedule-slots', 'create', 'validation'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Time Validation Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Time Validation Type', durationMinutes: 30 }) + + const res = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 1, + startTime: '3pm', + }) + t.assert.status(res, 400) + }) + + t.test('detects overlapping slot for same instructor, day, and time', { tags: ['schedule-slots', 'create', 'conflict'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Conflict Instructor' }) + const type1 = await t.api.post('/v1/lesson-types', { name: 'Conflict Type 1', durationMinutes: 30 }) + const type2 = await t.api.post('/v1/lesson-types', { name: 'Conflict Type 2', durationMinutes: 30 }) + + const first = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: type1.data.id, + dayOfWeek: 3, + startTime: '14:00', + }) + t.assert.status(first, 201) + + const second = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: type2.data.id, + dayOfWeek: 3, + startTime: '14:00', + }) + t.assert.status(second, 409) + }) + + t.test('allows same time for different instructors', { tags: ['schedule-slots', 'create', 'conflict'] }, async () => { + const instructor1 = await t.api.post('/v1/instructors', { displayName: 'No Conflict Instructor A' }) + const instructor2 = await t.api.post('/v1/instructors', { displayName: 'No Conflict Instructor B' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'No Conflict Type', durationMinutes: 30 }) + + const first = await t.api.post('/v1/schedule-slots', { + instructorId: instructor1.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 1, + startTime: '10:00', + }) + t.assert.status(first, 201) + + const second = await t.api.post('/v1/schedule-slots', { + instructorId: instructor2.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 1, + startTime: '10:00', + }) + t.assert.status(second, 201) + }) + + t.test('gets schedule slot by id', { tags: ['schedule-slots', 'read'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Get Slot Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Get Slot Type', durationMinutes: 30 }) + const created = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 5, + startTime: '09:00', + }) + + const res = await t.api.get(`/v1/schedule-slots/${created.data.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.dayOfWeek, 5) + }) + + t.test('returns 404 for missing schedule slot', { tags: ['schedule-slots', 'read'] }, async () => { + const res = await t.api.get('/v1/schedule-slots/a0000000-0000-0000-0000-999999999999') + t.assert.status(res, 404) + }) + + t.test('updates a schedule slot', { tags: ['schedule-slots', 'update'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Update Slot Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Update Slot Type', durationMinutes: 30 }) + const created = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 0, + startTime: '11:00', + room: 'Old Room', + }) + + const res = await t.api.patch(`/v1/schedule-slots/${created.data.id}`, { + room: 'New Room', + maxStudents: 3, + }) + t.assert.status(res, 200) + t.assert.equal(res.data.room, 'New Room') + t.assert.equal(res.data.maxStudents, 3) + }) + + t.test('update detects conflict when changing time', { tags: ['schedule-slots', 'update', 'conflict'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Update Conflict Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Update Conflict Type', durationMinutes: 30 }) + + await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 2, + startTime: '15:00', + }) + const second = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 2, + startTime: '16:00', + }) + + const res = await t.api.patch(`/v1/schedule-slots/${second.data.id}`, { + startTime: '15:00', + }) + t.assert.status(res, 409) + }) + + t.test('soft-deletes a schedule slot', { tags: ['schedule-slots', 'delete'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Delete Slot Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Delete Slot Type', durationMinutes: 30 }) + const created = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 6, + startTime: '12:00', + }) + + const res = await t.api.del(`/v1/schedule-slots/${created.data.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.isActive, false) + }) + + // ─── Schedule Slots: List, Filter ─── + + t.test('lists schedule slots with pagination', { tags: ['schedule-slots', 'read', 'pagination'] }, async () => { + const res = await t.api.get('/v1/schedule-slots', { limit: 100 }) + t.assert.status(res, 200) + t.assert.ok(res.data.pagination) + t.assert.ok(res.data.data.length >= 1) + }) + + t.test('filters schedule slots by instructor', { tags: ['schedule-slots', 'filter'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Filter Slot Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Filter Slot Type', durationMinutes: 30 }) + await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 1, + startTime: '08:00', + }) + + const res = await t.api.get('/v1/schedule-slots', { instructorId: instructor.data.id, limit: 100 }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.every((s: any) => s.instructorId === instructor.data.id)) + }) + + t.test('filters schedule slots by day of week', { tags: ['schedule-slots', 'filter'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Day Filter Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Day Filter Type', durationMinutes: 30 }) + await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 0, + startTime: '13:00', + }) + + const res = await t.api.get('/v1/schedule-slots', { dayOfWeek: '0', limit: 100 }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.every((s: any) => s.dayOfWeek === 0)) + }) + + t.test('deleted slot does not appear in list', { tags: ['schedule-slots', 'delete', 'list'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Ghost Slot Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Ghost Slot Type', durationMinutes: 30 }) + const created = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 4, + startTime: '19:00', + room: 'Ghost Room XYZ', + }) + await t.api.del(`/v1/schedule-slots/${created.data.id}`) + + const res = await t.api.get('/v1/schedule-slots', { q: 'Ghost Room XYZ', limit: 100 }) + t.assert.equal(res.data.data.length, 0) + }) + + t.test('deactivated slot frees the time for new slot', { tags: ['schedule-slots', 'create', 'conflict'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Reuse Slot Instructor' }) + const lessonType = await t.api.post('/v1/lesson-types', { name: 'Reuse Slot Type', durationMinutes: 30 }) + + const first = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 5, + startTime: '18:00', + }) + t.assert.status(first, 201) + await t.api.del(`/v1/schedule-slots/${first.data.id}`) + + const second = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, + lessonTypeId: lessonType.data.id, + dayOfWeek: 5, + startTime: '18:00', + }) + t.assert.status(second, 201) + }) }) diff --git a/packages/backend/src/db/migrations/0029_schedule_slots.sql b/packages/backend/src/db/migrations/0029_schedule_slots.sql new file mode 100644 index 0000000..7eb4b86 --- /dev/null +++ b/packages/backend/src/db/migrations/0029_schedule_slots.sql @@ -0,0 +1,14 @@ +-- Phase 2: Schedule slots — recurring weekly time slots + +CREATE TABLE "schedule_slot" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "instructor_id" uuid NOT NULL REFERENCES "instructor"("id"), + "lesson_type_id" uuid NOT NULL REFERENCES "lesson_type"("id"), + "day_of_week" integer NOT NULL, + "start_time" time NOT NULL, + "room" varchar(100), + "max_students" integer NOT NULL DEFAULT 1, + "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 5786799..b8513bf 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1774880000000, "tag": "0028_lessons_foundation", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1774890000000, + "tag": "0029_schedule_slots", + "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 index 42ab560..bd11e13 100644 --- a/packages/backend/src/db/schema/lessons.ts +++ b/packages/backend/src/db/schema/lessons.ts @@ -3,6 +3,7 @@ import { uuid, varchar, text, + time, timestamp, boolean, integer, @@ -39,9 +40,28 @@ export const lessonTypes = pgTable('lesson_type', { updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }) +export const scheduleSlots = pgTable('schedule_slot', { + id: uuid('id').primaryKey().defaultRandom(), + instructorId: uuid('instructor_id') + .notNull() + .references(() => instructors.id), + lessonTypeId: uuid('lesson_type_id') + .notNull() + .references(() => lessonTypes.id), + dayOfWeek: integer('day_of_week').notNull(), // 0=Sunday, 6=Saturday + startTime: time('start_time').notNull(), + room: varchar('room', { length: 100 }), + maxStudents: integer('max_students').notNull().default(1), + 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 +export type ScheduleSlot = typeof scheduleSlots.$inferSelect +export type ScheduleSlotInsert = typeof scheduleSlots.$inferInsert diff --git a/packages/backend/src/routes/v1/lessons.ts b/packages/backend/src/routes/v1/lessons.ts index ded07bc..e40b0b6 100644 --- a/packages/backend/src/routes/v1/lessons.ts +++ b/packages/backend/src/routes/v1/lessons.ts @@ -5,8 +5,10 @@ import { InstructorUpdateSchema, LessonTypeCreateSchema, LessonTypeUpdateSchema, + ScheduleSlotCreateSchema, + ScheduleSlotUpdateSchema, } from '@lunarfront/shared/schemas' -import { InstructorService, LessonTypeService } from '../../services/lesson.service.js' +import { InstructorService, LessonTypeService, ScheduleSlotService } from '../../services/lesson.service.js' export const lessonRoutes: FastifyPluginAsync = async (app) => { // --- Instructors --- @@ -92,4 +94,57 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => { if (!lessonType) return reply.status(404).send({ error: { message: 'Lesson type not found', statusCode: 404 } }) return reply.send(lessonType) }) + + // --- Schedule Slots --- + + app.post('/schedule-slots', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const parsed = ScheduleSlotCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const result = await ScheduleSlotService.create(app.db, parsed.data) + if ('error' in result) { + return reply.status(409).send({ error: { message: result.error, statusCode: 409 } }) + } + return reply.status(201).send(result) + }) + + app.get('/schedule-slots', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => { + const query = request.query as Record + const params = PaginationSchema.parse(query) + const filters = { + instructorId: query.instructorId, + dayOfWeek: query.dayOfWeek !== undefined ? Number(query.dayOfWeek) : undefined, + } + const result = await ScheduleSlotService.list(app.db, params, filters) + return reply.send(result) + }) + + app.get('/schedule-slots/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const slot = await ScheduleSlotService.getById(app.db, id) + if (!slot) return reply.status(404).send({ error: { message: 'Schedule slot not found', statusCode: 404 } }) + return reply.send(slot) + }) + + app.patch('/schedule-slots/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = ScheduleSlotUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const result = await ScheduleSlotService.update(app.db, id, parsed.data) + if (!result) return reply.status(404).send({ error: { message: 'Schedule slot not found', statusCode: 404 } }) + if ('error' in result) { + return reply.status(409).send({ error: { message: result.error, statusCode: 409 } }) + } + return reply.send(result) + }) + + app.delete('/schedule-slots/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const slot = await ScheduleSlotService.delete(app.db, id) + if (!slot) return reply.status(404).send({ error: { message: 'Schedule slot not found', statusCode: 404 } }) + return reply.send(slot) + }) } diff --git a/packages/backend/src/services/lesson.service.ts b/packages/backend/src/services/lesson.service.ts index bbfcb6b..b31fa36 100644 --- a/packages/backend/src/services/lesson.service.ts +++ b/packages/backend/src/services/lesson.service.ts @@ -1,11 +1,13 @@ -import { eq, and, count, type Column } from 'drizzle-orm' +import { eq, and, count, type Column, type SQL } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' -import { instructors, lessonTypes } from '../db/schema/lessons.js' +import { instructors, lessonTypes, scheduleSlots } from '../db/schema/lessons.js' import type { InstructorCreateInput, InstructorUpdateInput, LessonTypeCreateInput, LessonTypeUpdateInput, + ScheduleSlotCreateInput, + ScheduleSlotUpdateInput, PaginationInput, } from '@lunarfront/shared/schemas' import { @@ -152,3 +154,128 @@ export const LessonTypeService = { return lessonType ?? null }, } + +export const ScheduleSlotService = { + async create(db: PostgresJsDatabase, input: ScheduleSlotCreateInput) { + // Check for overlapping slot on same instructor, day, and time + const [conflict] = await db + .select({ id: scheduleSlots.id }) + .from(scheduleSlots) + .where( + and( + eq(scheduleSlots.instructorId, input.instructorId), + eq(scheduleSlots.dayOfWeek, input.dayOfWeek), + eq(scheduleSlots.startTime, input.startTime), + eq(scheduleSlots.isActive, true), + ), + ) + .limit(1) + if (conflict) { + return { error: 'Instructor already has a slot at this day and time' } + } + + const [slot] = await db + .insert(scheduleSlots) + .values({ + instructorId: input.instructorId, + lessonTypeId: input.lessonTypeId, + dayOfWeek: input.dayOfWeek, + startTime: input.startTime, + room: input.room, + maxStudents: input.maxStudents, + }) + .returning() + return slot + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [slot] = await db + .select() + .from(scheduleSlots) + .where(eq(scheduleSlots.id, id)) + .limit(1) + return slot ?? null + }, + + async list(db: PostgresJsDatabase, params: PaginationInput, filters?: { + instructorId?: string + dayOfWeek?: number + }) { + const conditions: SQL[] = [eq(scheduleSlots.isActive, true)] + + if (params.q) { + const search = buildSearchCondition(params.q, [scheduleSlots.room]) + if (search) conditions.push(search) + } + if (filters?.instructorId) { + conditions.push(eq(scheduleSlots.instructorId, filters.instructorId)) + } + if (filters?.dayOfWeek !== undefined) { + conditions.push(eq(scheduleSlots.dayOfWeek, filters.dayOfWeek)) + } + + const where = and(...conditions) + + const sortableColumns: Record = { + day_of_week: scheduleSlots.dayOfWeek, + start_time: scheduleSlots.startTime, + room: scheduleSlots.room, + created_at: scheduleSlots.createdAt, + } + + let query = db.select().from(scheduleSlots).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, scheduleSlots.dayOfWeek) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(scheduleSlots).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) + }, + + async update(db: PostgresJsDatabase, id: string, input: ScheduleSlotUpdateInput) { + // If changing day/time/instructor, check for conflicts + if (input.instructorId || input.dayOfWeek !== undefined || input.startTime) { + const existing = await ScheduleSlotService.getById(db, id) + if (!existing) return null + + const checkInstructorId = input.instructorId ?? existing.instructorId + const checkDay = input.dayOfWeek ?? existing.dayOfWeek + const checkTime = input.startTime ?? existing.startTime + + const [conflict] = await db + .select({ id: scheduleSlots.id }) + .from(scheduleSlots) + .where( + and( + eq(scheduleSlots.instructorId, checkInstructorId), + eq(scheduleSlots.dayOfWeek, checkDay), + eq(scheduleSlots.startTime, checkTime), + eq(scheduleSlots.isActive, true), + ), + ) + .limit(1) + if (conflict && conflict.id !== id) { + return { error: 'Instructor already has a slot at this day and time' } + } + } + + const [slot] = await db + .update(scheduleSlots) + .set({ ...input, updatedAt: new Date() }) + .where(eq(scheduleSlots.id, id)) + .returning() + return slot ?? null + }, + + async delete(db: PostgresJsDatabase, id: string) { + const [slot] = await db + .update(scheduleSlots) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(scheduleSlots.id, id)) + .returning() + return slot ?? null + }, +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index eda63a4..7bbfc8f 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -106,10 +106,14 @@ export { InstructorUpdateSchema, LessonTypeCreateSchema, LessonTypeUpdateSchema, + ScheduleSlotCreateSchema, + ScheduleSlotUpdateSchema, } from './lessons.schema.js' export type { InstructorCreateInput, InstructorUpdateInput, LessonTypeCreateInput, LessonTypeUpdateInput, + ScheduleSlotCreateInput, + ScheduleSlotUpdateInput, } from './lessons.schema.js' diff --git a/packages/shared/src/schemas/lessons.schema.ts b/packages/shared/src/schemas/lessons.schema.ts index 898d333..d343582 100644 --- a/packages/shared/src/schemas/lessons.schema.ts +++ b/packages/shared/src/schemas/lessons.schema.ts @@ -36,3 +36,18 @@ export type LessonTypeCreateInput = z.infer export const LessonTypeUpdateSchema = LessonTypeCreateSchema.partial() export type LessonTypeUpdateInput = z.infer + +// --- Schedule Slot schemas --- + +export const ScheduleSlotCreateSchema = z.object({ + instructorId: z.string().uuid(), + lessonTypeId: z.string().uuid(), + dayOfWeek: z.coerce.number().int().min(0).max(6), + startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM format'), + room: opt(z.string().max(100)), + maxStudents: z.coerce.number().int().min(1).default(1), +}) +export type ScheduleSlotCreateInput = z.infer + +export const ScheduleSlotUpdateSchema = ScheduleSlotCreateSchema.partial() +export type ScheduleSlotUpdateInput = z.infer