Skip to content

Conventions

  1. TypeScript strict mode everywhere. Minimize any types. Use Zod for runtime validation.
  2. Thin routes, fat services. Routes parse input, call service, return response. Business logic lives in services.
  3. Zod schemas are the single source of truth for validation. Define in packages/shared, import in both api and web.
  4. All dates in UTC in database and API responses. Store IANA timezone separately. Use Luxon for conversions.
  5. Error early, error clearly. Use the Errors helper. Never swallow errors silently.
  6. Idempotent webhooks. Check webhook_events table before processing.
kebab-case for all files: slot-engine.ts, session-service.ts
kebab-case for directories: credit-ledger/, class-categories/
No PascalCase component files: review-card.tsx, kpi-card.tsx
// Types & Interfaces: PascalCase
interface CreateSessionInput { ... }
// Zod schemas: camelCase + "Schema" suffix
const createManualStudentSchema = z.object({ ... });
// Functions: camelCase, verb-first
function calculateAvailableSlots() {}
function createCheckoutSession() {}
// Constants: SCREAMING_SNAKE_CASE
const 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)
// One component per file (main export), kebab-case filename
// Props inline or named ComponentNameProps
// Hooks: use + PascalCase
function useAvailableSlots(teacherSlug: string) {}
function useOptimisticMutation<T>(...) {}
apps/api/src/routes/teacher/sessions.ts
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 routes
  • request.studentId! for student routes
  • Zod .parse() for request body validation
  • Routes are thin — no business logic, no direct DB queries
apps/api/src/services/scheduling/session-service.ts
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(() => {}))
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' } }

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 records
const templates = await db.query.lessonTemplates.findMany({
where: and(eq(lessonTemplates.teacherId, teacherId), notDeleted(lessonTemplates)),
});
// For raw SQL / CTEs
sql`... AND deleted_at IS NULL`

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);
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 processed
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
};
const { data, isLoading } = useQuery({
queryKey: ['teacher-sessions', teacherId],
queryFn: () => api.get<Session[]>('/teacher/sessions'),
});
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')),
});

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
},
});

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"
<ConfirmDialog
open={showConfirm}
onClose={() => setShowConfirm(false)}
onConfirm={handleDelete}
title={t('sessions.deleteTitle')}
description={t('sessions.deleteDescription')}
variant="danger"
/>

Never use window.confirm().

  • Biome for linting + formatting
  • Single quotes, 2-space indent
  • Run bun run lint to check
feat: add review requests tab with sortable table
fix: correct Spanish diacritics in i18n translations
docs: rewrite SCHEMA.md to match current architecture
Terminal window
DATABASE_URL=postgresql://pinteach:pinteach@localhost:5433/pinteach
REDIS_URL=redis://localhost:6379
APP_URL=http://localhost:3737
PORT=3001