Conventions
General Principles
Section titled “General Principles”- TypeScript strict mode everywhere. Minimize
anytypes. Use Zod for runtime validation. - Thin routes, fat services. Routes parse input, call service, return response. Business logic lives in services.
- Zod schemas are the single source of truth for validation. Define in
packages/shared, import in both api and web. - All dates in UTC in database and API responses. Store IANA timezone separately. Use Luxon for conversions.
- Error early, error clearly. Use the
Errorshelper. Never swallow errors silently. - Idempotent webhooks. Check
webhook_eventstable before processing.
Naming Conventions
Section titled “Naming Conventions”Files & Directories
Section titled “Files & Directories”kebab-case for all files: slot-engine.ts, session-service.tskebab-case for directories: credit-ledger/, class-categories/No PascalCase component files: review-card.tsx, kpi-card.tsxTypeScript
Section titled “TypeScript”// Types & Interfaces: PascalCaseinterface CreateSessionInput { ... }
// Zod schemas: camelCase + "Schema" suffixconst createManualStudentSchema = z.object({ ... });
// Functions: camelCase, verb-firstfunction calculateAvailableSlots() {}function createCheckoutSession() {}
// Constants: SCREAMING_SNAKE_CASEconst CREDIT_ACTIONS = ['GRANT', 'RESERVE', 'CONSUME', 'RELEASE', 'EXPIRE'] as const;
// Database columns: snake_case (Drizzle maps to camelCase in TS)// API request/response: camelCase// Enums: lowercase values (scheduled, completed, pending)React Components
Section titled “React Components”// One component per file (main export), kebab-case filename// Props inline or named ComponentNameProps
// Hooks: use + PascalCasefunction useAvailableSlots(teacherSlug: string) {}function useOptimisticMutation<T>(...) {}Backend Patterns
Section titled “Backend Patterns”Route Structure (Fastify)
Section titled “Route Structure (Fastify)”import type { FastifyPluginAsync } from 'fastify';import { SessionService } from '../../services/scheduling/session-service';import { createSessionSchema } from '@pinteach/shared';
const sessionRoutes: FastifyPluginAsync = async (app) => { // POST /api/teacher/sessions app.post('/', async (request) => { const input = createSessionSchema.parse(request.body); return SessionService.createSession(request.teacherId!, input); });};
export default sessionRoutes;Key conventions:
request.teacherId!is set by the auth plugin for teacher routesrequest.studentId!for student routes- Zod
.parse()for request body validation - Routes are thin — no business logic, no direct DB queries
Service Structure
Section titled “Service Structure”import { db } from '@pinteach/db';import { classSessions } from '@pinteach/db/schema';import { notDeleted } from '@pinteach/db';import { eq, and } from 'drizzle-orm';import { Errors } from '../../lib/errors';
export class SessionService { static async createSession(teacherId: string, input: CreateSessionInput) { const [session] = await db.insert(classSessions).values({ teacherId, ...input, status: 'scheduled', }).returning();
// Side effects (fire-and-forget) AuditService.logFromRequest(request, { action: 'session.create', entityType: 'session', entityId: session.id, }).catch(() => {});
return session; }}Key conventions:
- Services are static classes (no instances)
- Always scope queries by
teacherId(multi-tenancy) - Use
notDeleted(table)for soft-delete tables - Audit logging is fire-and-forget (
.catch(() => {}))
Error Handling
Section titled “Error Handling”export const Errors = { unauthorized: () => ..., forbidden: () => ..., notFound: (entity: string) => ..., conflict: (message: string) => ..., badRequest: (message: string) => ...,} as const;
// Usage in services:const student = await db.query.students.findFirst({ ... });if (!student) throw Errors.notFound('Student');
// Response format: { error: { code: 'NOT_FOUND', message: 'Student not found' } }Soft-Delete Pattern
Section titled “Soft-Delete Pattern”8 tables use deletedAt instead of hard deletes: lesson_templates, teacher_resources, material_folders, students, class_categories, tags, services, discount_codes.
import { notDeleted } from '@pinteach/db';
// Always filter soft-deleted recordsconst templates = await db.query.lessonTemplates.findMany({ where: and(eq(lessonTemplates.teacherId, teacherId), notDeleted(lessonTemplates)),});
// For raw SQL / CTEssql`... AND deleted_at IS NULL`Credit Operations
Section titled “Credit Operations”Never update enrollment counters directly. Always use SessionCreditHandler:
import { SessionCreditHandler } from '../billing/session-credit-handler';
await SessionCreditHandler.onReserve(enrollmentId, sessionId, studentId);await SessionCreditHandler.onCompleted(enrollmentId, sessionId, studentId);await SessionCreditHandler.onCanceled(enrollmentId, sessionId, studentId);Webhook Pattern (Idempotent)
Section titled “Webhook Pattern (Idempotent)”const [inserted] = await db.insert(webhookEvents).values({ stripeEventId: event.id, type: event.type,}).onConflictDoNothing().returning();
if (!inserted) return reply.status(200).send({ received: true }); // Already processedFrontend Patterns
Section titled “Frontend Patterns”API Client
Section titled “API Client”export const api = { get: <T>(path: string) => apiRequest<T>(path), post: <T>(path: string, body: unknown) => apiRequest<T>(path, { method: 'POST', body: JSON.stringify(body) }), patch: <T>(path: string, body: unknown) => apiRequest<T>(path, { method: 'PATCH', body: JSON.stringify(body) }), delete: <T>(path: string) => apiRequest<T>(path, { method: 'DELETE' }), // No Content-Type header};Query Pattern (TanStack Query)
Section titled “Query Pattern (TanStack Query)”const { data, isLoading } = useQuery({ queryKey: ['teacher-sessions', teacherId], queryFn: () => api.get<Session[]>('/teacher/sessions'),});Mutation Pattern
Section titled “Mutation Pattern”const mutation = useMutation({ mutationFn: (input: CreateInput) => api.post('/teacher/sessions', input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['teacher-sessions'] }); toast.success(t('sessions.created')); }, onError: () => toast.error(t('common.error')),});Optimistic Mutations
Section titled “Optimistic Mutations”For frequent, low-risk actions (toggle favorite, complete session, reorder):
import { useOptimisticMutation } from '@/hooks/mutations';
const mutation = useOptimisticMutation({ mutationFn: (id: string) => api.post(`/teacher/sessions/${id}/complete`), queryKeys: [['teacher-sessions'], ['teacher-calendar']], onOptimisticUpdate: ({ queryClient, snapshot }) => { // Update cache optimistically },});i18n Pattern
Section titled “i18n Pattern”All user-facing strings use react-i18next. No hardcoded strings.
const { t } = useTranslation();// t('reviews.title') → "Resenas" (ES) / "Reviews" (EN)// t('common.save') → "Guardar" / "Save"// t('sessions.count', { count: 5 }) → "5 sesiones" / "5 sessions"Confirmation Dialog
Section titled “Confirmation Dialog”<ConfirmDialog open={showConfirm} onClose={() => setShowConfirm(false)} onConfirm={handleDelete} title={t('sessions.deleteTitle')} description={t('sessions.deleteDescription')} variant="danger"/>Never use window.confirm().
Formatting & Linting
Section titled “Formatting & Linting”- Biome for linting + formatting
- Single quotes, 2-space indent
- Run
bun run lintto check
Git Conventions
Section titled “Git Conventions”feat: add review requests tab with sortable tablefix: correct Spanish diacritics in i18n translationsdocs: rewrite SCHEMA.md to match current architectureEnvironment
Section titled “Environment”DATABASE_URL=postgresql://pinteach:pinteach@localhost:5433/pinteachREDIS_URL=redis://localhost:6379APP_URL=http://localhost:3737PORT=3001