// Expert in Lighthouse Journey Timeline backend architecture, service patterns, testing, and API development using Express.js, TypeScript, and Drizzle ORM. Use when implementing backend services, APIs, controllers, database queries, authentication, permissions, error handling, testing server code, or writing migrations.
| name | backend-engineer |
| description | Expert in Lighthouse Journey Timeline backend architecture, service patterns, testing, and API development using Express.js, TypeScript, and Drizzle ORM. Use when implementing backend services, APIs, controllers, database queries, authentication, permissions, error handling, testing server code, or writing migrations. |
Expert knowledge of backend patterns and architecture for the Lighthouse Journey Timeline.
HTTP Request → Routes → Middleware → Controller → Service → Repository → Database
↓ ↓ ↓
Validation Business Drizzle
& Mapping Logic ORM
| Pattern | Primary Reference | Example Implementation |
|---|---|---|
| New Service | src/services/hierarchy-service.ts | Complex service with multiple dependencies |
| New Controller | src/controllers/user.controller.ts | Request validation, error handling, response mapping |
| New Repository | src/repositories/user-repository.ts | Drizzle patterns, complex queries |
| New Route | src/routes/hierarchy.routes.ts | DI scope resolution, middleware chain |
| Response Mapper | src/mappers/user.mapper.ts | DTO transformation patterns |
| Unit Tests | src/services/__tests__/*.test.ts | Mock setup, test structure |
| Error Codes | src/core/error-codes.ts | 70+ standardized error codes |
| API Documentation | openapi-schema.yaml | Current API specs - MUST UPDATE |
Strong typing with Zod schemas ensures type safety between server and client, catching errors at compile/validation time rather than runtime. EVERY data boundary must have Zod validation.
Never use magic strings. Always use enums or constants for:
src/core/error-codes.ts - STRING enums (not numbers)z.enum(['job', 'education', 'project', 'event'])Example - Error Codes:
// ✅ GOOD: Using enum
export enum ErrorCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
INVALID_PERMISSION = 'INVALID_PERMISSION',
}
throw new AppError(ErrorCode.USER_NOT_FOUND);
// ❌ BAD: Magic strings
throw new AppError('USER_NOT_FOUND');
Example - Zod Enums:
// ✅ GOOD: Zod enum for validation + TypeScript type
const NodeTypeSchema = z.enum(['job', 'education', 'project']);
type NodeType = z.infer<typeof NodeTypeSchema>;
// ❌ BAD: Plain strings
type NodeType = string;
// Define request/response schemas in controller or separate file
const CreateNodeRequestSchema = z.object({
type: z.enum(['job', 'education', 'project', 'event']),
parentId: z.string().uuid().optional(),
meta: z.object({
title: z.string().min(1).max(200),
company: z.string().optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime().optional(),
description: z.string().optional()
})
});
// In controller method
async createNode(req: Request, res: Response) {
// Validate request body
const validatedData = CreateNodeRequestSchema.parse(req.body);
// validatedData is now strongly typed
// Pass to service (type-safe)
const result = await this.nodeService.create(validatedData);
}
// Service method input schemas
const ServiceCreateNodeSchema = z.object({
userId: z.number().positive(),
type: z.enum(['job', 'education', 'project']),
parentId: z.string().uuid().optional(),
meta: z.record(z.any())
});
// Service method
async create(input: z.infer<typeof ServiceCreateNodeSchema>) {
// Additional business validation
const validated = ServiceCreateNodeSchema.parse(input);
// Business rules validation
if (validated.parentId) {
await this.validateParentAccess(validated.parentId, validated.userId);
}
return await this.repository.create(validated);
}
// Database insert/update schemas
const DbNodeInsertSchema = z.object({
id: z.string().uuid().default(() => crypto.randomUUID()),
type: z.string(),
userId: z.number(),
parentId: z.string().uuid().nullable(),
meta: z.any(), // JSON column
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date())
});
// Repository method
async create(data: unknown) {
// Validate before database operation
const dbData = DbNodeInsertSchema.parse(data);
return await this.db.insert(nodes).values(dbData);
}
// Response DTOs with Zod
const NodeResponseSchema = z.object({
id: z.string().uuid(),
type: z.string(),
meta: z.object({
title: z.string(),
// ... other fields
}),
createdAt: z.string().datetime(),
permissions: z.object({
canView: z.boolean(),
canEdit: z.boolean(),
canDelete: z.boolean()
})
});
// In mapper
static toDto(node: unknown): NodeResponseDto {
// Validate and transform
return NodeResponseSchema.parse({
id: node.id,
type: node.type,
// ... mapping
});
}
packages/
├── schema/
│ └── src/
│ ├── api/ # ⭐ API Contracts (PRIMARY - ALWAYS USE)
│ │ ├── auth.schemas.ts # Auth request/response schemas
│ │ ├── user.schemas.ts # User request/response schemas
│ │ ├── timeline.schemas.ts # Timeline request/response schemas
│ │ ├── files.schemas.ts # File upload schemas
│ │ ├── common.schemas.ts # Shared API schemas (pagination, etc.)
│ │ ├── validation-helpers.ts # Reusable validators
│ │ └── index.ts # Re-exports all API schemas
│ ├── schema.ts # Drizzle database schema
│ └── types.ts # Database type exports
└── server/
└── src/
├── controllers/ # Import from @journey/schema/src/api
├── services/ # Import from @journey/schema/src/api
└── mappers/ # Transform DB types → API response types
CRITICAL:
@journey/schema/src/api/server/src/ - always use @journey/schema/src/api/// packages/schema/src/api/common.schemas.ts (or validation-helpers.ts)
export const uuidSchema = z.string().uuid();
export const dateTimeSchema = z.string().datetime();
export const paginationSchema = z.object({
page: z.number().positive().default(1),
limit: z.number().positive().max(100).default(20),
});
// Compose in controller-specific schemas
// packages/schema/src/api/timeline.schemas.ts
import { uuidSchema, paginationSchema } from './common.schemas';
export const getNodesRequestSchema = z.object({
userId: uuidSchema,
...paginationSchema.shape,
});
export type GetNodesRequest = z.infer<typeof getNodesRequestSchema>;
// Generate Zod schemas from Drizzle tables
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { users } from '@journey/schema';
// Auto-generate schemas from Drizzle table definitions
export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users);
// Extend with custom validations
export const createUserSchema = insertUserSchema
.extend({
email: z.string().email(),
password: z.string().min(8).max(100),
})
.omit({ id: true, createdAt: true });
// In controller error handling
import { ZodError } from 'zod';
import { fromZodError } from 'zod-validation-error';
try {
const validated = schema.parse(req.body);
} catch (error) {
if (error instanceof ZodError) {
// Convert to friendly error message
const validationError = fromZodError(error);
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: validationError.toString(),
details: error.issues,
},
});
}
throw error;
}
// Define schema once
const NodeCreateSchema = z.object({
type: z.enum(['job', 'education']),
meta: z.object({
title: z.string(),
company: z.string().optional(),
}),
});
// Infer TypeScript type from schema
type NodeCreateInput = z.infer<typeof NodeCreateSchema>;
// Use throughout application
class NodeService {
async create(input: NodeCreateInput) {
// input is strongly typed
}
}
describe('NodeCreateSchema', () => {
it('should validate correct input', () => {
const input = {
type: 'job',
meta: { title: 'Software Engineer' },
};
expect(() => NodeCreateSchema.parse(input)).not.toThrow();
});
it('should reject invalid type', () => {
const input = {
type: 'invalid',
meta: { title: 'Test' },
};
expect(() => NodeCreateSchema.parse(input)).toThrow(ZodError);
});
});
parse() for runtime validation, safeParse() when you need to handle errors@journey/schema packagez.infer<> instead of manually defining typesz.string().describe('User email address');
src/services/jwt.service.tssrc/services/refresh-token.service.ts// From src/middleware/auth.middleware.ts
requireAuth; // Validates JWT, loads user, fails if invalid
optionalAuth; // Validates JWT if present, continues regardless
requireGuest; // Ensures user is NOT authenticated
requireRole(role); // Role-based access control
requirePermission(); // Permission-based access
/api/auth/refresh// Standard test file structure
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mock, mockDeep } from 'vitest-mock-extended';
describe('ServiceName', () => {
let service: ServiceName;
let mockRepo: MockType<Repository>;
beforeEach(() => {
vi.clearAllMocks();
mockRepo = mock<Repository>();
service = new ServiceName(mockRepo);
});
describe('methodName', () => {
it('should handle success case', async () => {
// Arrange
const input = {
/* test data */
};
const expected = {
/* expected result */
};
mockRepo.findById.mockResolvedValue(expected);
// Act
const result = await service.method(input);
// Assert
expect(result).toEqual(expected);
expect(mockRepo.findById).toHaveBeenCalledWith(input.id);
});
it('should handle error case', async () => {
// Test error scenarios
});
});
});
// Mock with specific return
mockService.method.mockResolvedValue(result);
// Mock with implementation
mockService.method.mockImplementation(async (id) => {
return id === 'valid' ? data : null;
});
// Mock chainable queries (Drizzle)
const mockQuery = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([result]),
};
// Reset mocks
mockReset(mockService); // Clear all mock data
mockClear(mockService); // Clear call history only
packages/schema/src/schema.tspackages/schema/migrations/cd packages/schema
pnpm db:generate --name migration_name # Generate migration
pnpm db:migrate # Run migrations
pnpm db:studio # Open Drizzle Studio
// Repository pattern example
class UserRepository {
constructor(private db: NodePgDatabase) {}
async findById(id: number) {
return this.db.query.users.findFirst({
where: eq(users.id, id),
});
}
async search(term: string) {
return this.db
.select()
.from(users)
.where(
or(
ilike(users.firstName, `%${term}%`),
ilike(users.lastName, `%${term}%`)
)
)
.limit(20);
}
}
Node Level (most specific)
↓
Organization Level (if node belongs to org member)
↓
User Level (node owner)
↓
Public Level (if explicitly set)
Check Node Policies (node_policies table)
Check Ownership
Apply Most Permissive
src/services/node-permission.service.tssrc/repositories/node-permission.repository.tssrc/repositories/sql/permission-cte.ts// Location: src/core/error-codes.ts
// NOTE: Error codes are STRING enums, not numbers
export enum ErrorCode {
// Auth errors
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
// Validation errors
VALIDATION_ERROR = 'VALIDATION_ERROR',
INVALID_REQUEST = 'INVALID_REQUEST',
// Resource errors
NOT_FOUND = 'NOT_FOUND',
ALREADY_EXISTS = 'ALREADY_EXISTS',
// Business errors
BUSINESS_RULE_VIOLATION = 'BUSINESS_RULE_VIOLATION',
CIRCULAR_REFERENCE = 'CIRCULAR_REFERENCE',
MAX_DEPTH_EXCEEDED = 'MAX_DEPTH_EXCEEDED',
// Server errors
INTERNAL_ERROR = 'INTERNAL_ERROR',
DATABASE_ERROR = 'DATABASE_ERROR',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
}
// Check error-codes.ts for complete list (70+ codes)
// In service layer
throw new ValidationError(ErrorCode.VALIDATION_ERROR, 'Detailed error message');
// In controller - automatically handled by error middleware
// Returns standardized ApiErrorResponse
/**
* @route POST /api/v2/timeline/nodes
* @summary Create timeline node
* @description Creates a new node in user's timeline hierarchy
* @body {CreateNodeDto} Node creation data
* @response {201} {ApiSuccessResponse<Node>} Node created
* @response {400} {ApiErrorResponse} Validation error
* @response {403} {ApiErrorResponse} Permission denied
* @security BearerAuth
*/
async createNode(req: Request, res: Response) {
// Implementation
}
packages/server/openapi-schema.yamlpnpm generate:swagger
# Check git diff on openapi-schema.yaml
#!/usr/bin/env tsx
/**
* Script Name and Purpose
*
* Run with: NODE_ENV=development tsx scripts/script-name.ts
*/
import { Container } from '../src/core/container-setup';
import { CONTAINER_TOKENS } from '../src/core/container-tokens';
async function main() {
console.log('🚀 Starting script...');
// Initialize container (reuse app services)
await Container.configure(console);
const container = Container.getRootContainer();
// Resolve services
const userService = container.resolve(CONTAINER_TOKENS.USER_SERVICE);
const hierarchyService = container.resolve(
CONTAINER_TOKENS.HIERARCHY_SERVICE
);
try {
// Use services exactly like controllers do
const users = await userService.getAllUsers();
// Process data...
console.log('✅ Script completed successfully');
} catch (error) {
console.error('❌ Script failed:', error);
process.exit(1);
}
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(console.error);
}
packages/server/scripts/packages/schema/migrations/package.json scripts section// Check for existing mapper
src / mappers / [entity].mapper.ts;
// If none exists, check similar entities
src / mappers / user.mapper.ts; // User-related mappings
src / mappers / experience.mapper.ts; // Experience-related
// Mapper pattern
class EntityMapper {
static toDto(entity: Entity): EntityDto {
return {
id: entity.id,
// Transform fields
// Omit internal fields
// Format dates
};
}
static toDtoArray(entities: Entity[]): EntityDto[] {
return entities.map((e) => this.toDto(e));
}
}
// All responses use standardized format
interface ApiSuccessResponse<T> {
success: true;
data: T;
}
// In controller
return res.status(200).json({
success: true,
data: UserMapper.toDto(user),
});
# From packages/server
pnpm test:unit # Run unit tests only (fast)
pnpm test # All tests including integration
pnpm dev # Start dev server
# Run specific test file (fastest for TDD)
pnpm vitest run --no-coverage src/services/__tests__/user.service.test.ts
# From project root (Nx commands)
pnpm test:changed # Test only changed packages
pnpm test:changed:all # Include e2e for changed packages
.claude/skills/backend-engineer/SKILL.mdmcp__serena__write_memorymcp__memory__create_entities// Infrastructure
CONTAINER_TOKENS.DATABASE;
CONTAINER_TOKENS.LOGGER;
// Services (check container-tokens.ts for complete list)
CONTAINER_TOKENS.USER_SERVICE;
CONTAINER_TOKENS.HIERARCHY_SERVICE;
CONTAINER_TOKENS.NODE_PERMISSION_SERVICE;
// ... 15+ more services
// Controllers
CONTAINER_TOKENS.USER_CONTROLLER;
CONTAINER_TOKENS.HIERARCHY_CONTROLLER;
// ... 9+ controllers
pnpm test:coveragepnpm coverage:htmlRemember: Always check existing patterns before implementing new ones. This maintains consistency and reduces technical debt.