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:
@@ -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()
|
||||
);
|
||||
@@ -197,6 +197,13 @@
|
||||
"when": 1774870000000,
|
||||
"tag": "0027_generalize_terminology",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1774880000000,
|
||||
"tag": "0028_lessons_foundation",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
47
packages/backend/src/db/schema/lessons.ts
Normal file
47
packages/backend/src/db/schema/lessons.ts
Normal 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
|
||||
@@ -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 })
|
||||
|
||||
95
packages/backend/src/routes/v1/lessons.ts
Normal file
95
packages/backend/src/routes/v1/lessons.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
154
packages/backend/src/services/lesson.service.ts
Normal file
154
packages/backend/src/services/lesson.service.ts
Normal 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
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user