Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:18:11 +08:00
commit 7c433e0dfd
14 changed files with 3103 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
---
name: github-issue-writer
description: Creates well-structured Github issues for the upkeep-io project following standardized templates and best practices. Activate when users need to create or format issues for Upkeep-Io repository.
---
# Github Issue Writer
## Instructions
You are assisting with drafting a high-quality Github Issues following Upkeep-Io standardized format.
### Issue Structure
Create Issue using the following structure:
1. **User Story Format** (for features/enhancements):
```
As a [user type/role]
I want to [action/capability]
So that [benefit/value]
```
2. **Context Section**:
- Provide background information and business justification
- Explain how this fits into the larger product strategy
- Include references to related work or dependencies
- Clearly identify what's in and out of scope
3. **Success Criteria**:
- Write specific, testable acceptance criteria as scenario blocks
- Format as "Given/When/Then" statements
- Group related criteria under descriptive headers
- Each criterion should be independently verifiable
4. **Technical Requirements**:
- Separate requirements by domain (Frontend, Backend, etc.)
- Include implementation guidelines, patterns, and approaches
- Specify security considerations
- Reference design materials when available
5. **Definition of Done**:
- Include a checklist of completion criteria
- Cover testing requirements, documentation, and reviews
### Best Practices
- **Be Specific**: Avoid vague language; use concrete, measurable terms
- **Be Comprehensive**: Ensure all aspects of implementation are covered
- **Be User-Focused**: Connect technical requirements to user value
- **Be Realistic**: Break large tasks into manageable pieces
- **Prioritize Security**: Always include relevant security considerations
### Process (MANDATORY ORDER)
1. Ask clarifying questions to gather necessary details
2. **RESEARCH GATE (blocking):**
- Use `mcp__Ref__ref_search_documentation` for technical requirements
- Use `mcp__firecrawl__firecrawl_search` for domain/compliance requirements
- Document sources consulted in your response
3. Structure information into the template sections
4. Cite sources in issue body where relevant
5. Ensure all required fields are completed
6. Format the final ticket for readability with proper markdown
**You cannot proceed to step 3 without completing step 2. Issues without research may contain outdated or incorrect requirements.**

View File

@@ -0,0 +1,735 @@
---
name: typescript-development
description: Helps build and extend TypeScript Express APIs using Clean Architecture, inversify dependency injection, Prisma ORM, and Railway deployment patterns established in the upkeep-io project.
---
# TypeScript Development
## Research Protocol
**MANDATORY:** Follow the research protocol in `@shared/research-protocol.md` before implementing backend features.
### When to Research
You MUST use `mcp__Ref__ref_search_documentation` before:
- Using Prisma features you haven't verified this session
- Implementing inversify patterns
- Using Express middleware patterns
- Making Zod validation decisions
- Advising on JWT or authentication patterns
**Never assume training data reflects current library versions. When in doubt, verify.**
## Project Context
This is a **monorepo** property management system with shared libraries:
```
upkeep-io/
├── apps/
│ ├── backend/ # Node/Express API (CommonJS)
│ └── frontend/ # Vue 3 SPA (ES Modules)
└── libs/ # Shared libraries
├── domain/ # Entities, errors (Property, MaintenanceWork, User)
├── validators/ # Zod schemas (shared validation)
└── auth/ # JWT utilities
```
**Key Principle:** Backend and frontend share validation schemas and domain entities from `libs/` for maximum code reuse.
## Capabilities
- Build new features following Clean Architecture with inversify DI
- Implement JWT + bcrypt authentication
- Create comprehensive unit tests with mocked repositories
- Set up production logging for Railway deployment
- Configure Prisma repositories with type transformations
- Implement shared validation using Zod schemas
## Creating a New Feature
Follow this 8-step workflow that matches the actual project structure:
### 1. Define Domain Entity (if needed)
```typescript
// libs/domain/src/entities/Resource.ts
export interface CreateResourceData {
userId: string;
name: string;
description?: string;
}
export interface Resource extends CreateResourceData {
id: string;
createdAt: Date;
updatedAt: Date;
}
```
### 2. Create Validation Schema
```typescript
// libs/validators/src/resource.ts
import { z } from 'zod';
export const createResourceSchema = z.object({
userId: z.string().uuid(),
name: z.string().min(1).max(255),
description: z.string().max(1000).optional()
});
export type CreateResourceInput = z.infer<typeof createResourceSchema>;
```
### 3. Create Repository Interface
```typescript
// apps/backend/src/domain/repositories/IResourceRepository.ts
import { Resource, CreateResourceData } from '@domain/entities';
export interface IResourceRepository {
create(data: CreateResourceData): Promise<Resource>;
findById(id: string): Promise<Resource | null>;
findByUserId(userId: string): Promise<Resource[]>;
update(id: string, data: Partial<Resource>): Promise<Resource>;
delete(id: string): Promise<void>;
}
```
### 4. Implement Use Case
```typescript
// apps/backend/src/application/resource/CreateResourceUseCase.ts
import { injectable, inject } from 'inversify';
import { IResourceRepository } from '../../domain/repositories';
import { ILogger } from '../../domain/services';
import { ValidationError } from '@domain/errors';
import { createResourceSchema } from '@validators/resource';
import { Resource } from '@domain/entities';
interface CreateResourceInput {
userId: string;
name: string;
description?: string;
}
@injectable()
export class CreateResourceUseCase {
constructor(
@inject('IResourceRepository') private repository: IResourceRepository,
@inject('ILogger') private logger: ILogger
) {}
async execute(input: CreateResourceInput): Promise<Resource> {
// Validate with shared schema
const validation = createResourceSchema.safeParse(input);
if (!validation.success) {
throw new ValidationError(validation.error.errors[0].message);
}
// Execute business logic
const resource = await this.repository.create(validation.data);
this.logger.info('Resource created', { resourceId: resource.id, userId: input.userId });
return resource;
}
}
```
### 5. Create Prisma Repository
```typescript
// apps/backend/src/infrastructure/repositories/PrismaResourceRepository.ts
import { injectable } from 'inversify';
import { PrismaClient } from '@prisma/client';
import { IResourceRepository } from '../../domain/repositories';
import { Resource, CreateResourceData } from '@domain/entities';
@injectable()
export class PrismaResourceRepository implements IResourceRepository {
private prisma: PrismaClient;
constructor() {
this.prisma = new PrismaClient();
}
async create(data: CreateResourceData): Promise<Resource> {
const result = await this.prisma.resource.create({ data });
// Transform Prisma nulls to undefined for domain entity
return {
...result,
description: result.description ?? undefined
};
}
async findById(id: string): Promise<Resource | null> {
const result = await this.prisma.resource.findUnique({ where: { id } });
if (!result) return null;
return {
...result,
description: result.description ?? undefined
};
}
async findByUserId(userId: string): Promise<Resource[]> {
const results = await this.prisma.resource.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
});
return results.map(r => ({
...r,
description: r.description ?? undefined
}));
}
async update(id: string, data: Partial<Resource>): Promise<Resource> {
const result = await this.prisma.resource.update({ where: { id }, data });
return {
...result,
description: result.description ?? undefined
};
}
async delete(id: string): Promise<void> {
await this.prisma.resource.delete({ where: { id } });
}
}
```
### 6. Register in Container
```typescript
// apps/backend/src/container.ts
import { IResourceRepository } from './domain/repositories';
import { PrismaResourceRepository } from './infrastructure/repositories';
import { CreateResourceUseCase } from './application/resource';
import { ResourceController } from './presentation/controllers';
export function createContainer(): Container {
const container = new Container();
// ... existing bindings ...
// Repository
container
.bind<IResourceRepository>('IResourceRepository')
.to(PrismaResourceRepository)
.inTransientScope();
// Use Case
container.bind(CreateResourceUseCase).toSelf().inTransientScope();
// Controller
container.bind(ResourceController).toSelf().inTransientScope();
return container;
}
```
### 7. Create Controller
```typescript
// apps/backend/src/presentation/controllers/ResourceController.ts
import { injectable, inject } from 'inversify';
import { Response, NextFunction } from 'express';
import { CreateResourceUseCase } from '../../application/resource';
import { AuthRequest } from '../middleware';
@injectable()
export class ResourceController {
constructor(
@inject(CreateResourceUseCase) private createUseCase: CreateResourceUseCase
) {}
async create(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const resource = await this.createUseCase.execute({
...req.body,
userId: req.user.userId
});
res.status(201).json(resource);
} catch (error) {
next(error);
}
}
}
```
### 8. Write Unit Tests
```typescript
// apps/backend/src/application/resource/CreateResourceUseCase.unit.test.ts
import { CreateResourceUseCase } from './CreateResourceUseCase';
import { IResourceRepository } from '../../domain/repositories';
import { ILogger } from '../../domain/services';
import { ValidationError } from '@domain/errors';
import { Resource } from '@domain/entities';
describe('CreateResourceUseCase', () => {
let useCase: CreateResourceUseCase;
let mockRepository: jest.Mocked<IResourceRepository>;
let mockLogger: jest.Mocked<ILogger>;
beforeEach(() => {
mockRepository = {
create: jest.fn(),
findById: jest.fn(),
findByUserId: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn()
};
useCase = new CreateResourceUseCase(mockRepository, mockLogger);
});
it('should create resource with valid input', async () => {
const input = {
userId: 'user-123',
name: 'Test Resource',
description: 'Test description'
};
const expected: Resource = {
id: 'resource-456',
...input,
createdAt: new Date(),
updatedAt: new Date()
};
mockRepository.create.mockResolvedValue(expected);
const result = await useCase.execute(input);
expect(result).toEqual(expected);
expect(mockRepository.create).toHaveBeenCalledWith(input);
expect(mockLogger.info).toHaveBeenCalledWith('Resource created', {
resourceId: expected.id,
userId: input.userId
});
});
it('should throw ValidationError when name is empty', async () => {
const input = {
userId: 'user-123',
name: '' // Invalid
};
await expect(useCase.execute(input)).rejects.toThrow(ValidationError);
});
});
```
## Authentication Pattern (JWT + bcrypt)
This project uses **JWT tokens with bcrypt password hashing**, not OAuth.
### Signup Flow
```typescript
// apps/backend/src/application/auth/CreateUserUseCase.ts
@injectable()
export class CreateUserUseCase {
constructor(
@inject('IUserRepository') private userRepository: IUserRepository,
@inject('IPasswordHasher') private passwordHasher: IPasswordHasher,
@inject('ITokenGenerator') private tokenGenerator: ITokenGenerator
) {}
async execute(input: CreateUserInput): Promise<CreateUserOutput> {
// 1. Validate with shared schema
const validation = signupSchema.safeParse(input);
if (!validation.success) {
throw new ValidationError(validation.error.errors[0].message);
}
// 2. Check for existing user
const existingUser = await this.userRepository.findByEmail(input.email);
if (existingUser) {
throw new ValidationError('User already exists');
}
// 3. Hash password
const passwordHash = await this.passwordHasher.hash(input.password);
// 4. Create user
const user = await this.userRepository.create({
email: input.email,
passwordHash,
name: input.name
});
// 5. Generate JWT
const token = this.tokenGenerator.generate({
userId: user.id,
email: user.email
});
return { user, token };
}
}
```
### JWT Middleware
```typescript
// apps/backend/src/presentation/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthRequest extends Request {
user?: {
userId: string;
email: string;
};
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string;
email: string;
};
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
```
## Production Logging for Railway (Next Feature)
You're about to deploy to Railway and need diagnostic logging. Here's how to implement it:
### Option 1: Pino (Recommended for Railway)
**Pros:**
- Fastest JSON logger (optimized for stdout)
- Railway-friendly (structured JSON output)
- Low overhead, great for high-throughput APIs
- Built-in request correlation IDs
**Installation:**
```bash
npm install pino pino-pretty
```
**Setup:**
```typescript
// apps/backend/src/infrastructure/services/PinoLogger.ts
import pino from 'pino';
import { ILogger } from '../../domain/services';
export function createPinoLogger(): ILogger {
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'development' ? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname'
}
} : undefined,
// Railway captures these fields for log aggregation
base: {
service: 'upkeep-api',
environment: process.env.NODE_ENV || 'development'
}
});
return {
info: (message: string, context?: object) => logger.info(context, message),
warn: (message: string, context?: object) => logger.warn(context, message),
error: (message: string, context?: object) => logger.error(context, message),
debug: (message: string, context?: object) => logger.debug(context, message)
};
}
```
### Option 2: Winston
**Pros:**
- More features (multiple transports, custom formats)
- Better for complex logging requirements
- Larger ecosystem
**Cons:**
- Heavier than Pino
- More configuration needed
### Option 3: Enhanced Console Logger
Keep it simple if you don't need advanced features:
```typescript
// apps/backend/src/infrastructure/services/StructuredConsoleLogger.ts
import { ILogger } from '../../domain/services';
export class StructuredConsoleLogger implements ILogger {
info(message: string, context?: object): void {
console.log(JSON.stringify({
level: 'info',
message,
timestamp: new Date().toISOString(),
...context
}));
}
error(message: string, context?: object): void {
console.error(JSON.stringify({
level: 'error',
message,
timestamp: new Date().toISOString(),
...context
}));
}
warn(message: string, context?: object): void {
console.warn(JSON.stringify({
level: 'warn',
message,
timestamp: new Date().toISOString(),
...context
}));
}
debug(message: string, context?: object): void {
if (process.env.LOG_LEVEL === 'debug') {
console.debug(JSON.stringify({
level: 'debug',
message,
timestamp: new Date().toISOString(),
...context
}));
}
}
}
```
### Request Correlation IDs
Track requests across use cases and repositories:
```typescript
// apps/backend/src/presentation/middleware/requestId.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
const requestId = uuidv4();
req.headers['x-request-id'] = requestId;
res.setHeader('x-request-id', requestId);
next();
}
// Use in use cases:
this.logger.info('Creating resource', {
requestId: req.headers['x-request-id'],
userId: input.userId
});
```
### Performance Logging
Track slow operations:
```typescript
async execute(input: CreateResourceInput): Promise<Resource> {
const start = Date.now();
try {
// ... business logic ...
const duration = Date.now() - start;
this.logger.info('Resource created', {
resourceId: resource.id,
duration
});
if (duration > 1000) {
this.logger.warn('Slow operation detected', {
operation: 'CreateResource',
duration
});
}
return resource;
} catch (error) {
this.logger.error('Failed to create resource', {
error: error.message,
stack: error.stack,
userId: input.userId
});
throw error;
}
}
```
## Railway Deployment
### Required Configuration
**railway.json:**
```json
{
"build": {
"builder": "NIXPACKS",
"buildCommand": "npm ci && npm run build && npx prisma generate"
},
"deploy": {
"startCommand": "npm run start",
"healthcheckPath": "/api/health",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 3
}
}
```
### Environment Variables
Set in Railway dashboard:
```env
# Database (Railway provides this)
DATABASE_URL=${{Postgres.DATABASE_URL}}
# Server
PORT=${{PORT}}
NODE_ENV=production
LOG_LEVEL=info
# Authentication
JWT_SECRET=<generate-secure-random-string>
JWT_EXPIRY=7d
# Frontend (for CORS)
FRONTEND_URL=https://your-frontend.railway.app
```
### Database Migrations
**Development (Prisma):**
```bash
npm run migrate:dev # Create and apply migration
npm run generate # Regenerate Prisma client
```
**Production (Flyway):**
1. Prisma generates SQL in `prisma/migrations/`
2. Copy to `migrations/V{number}__{name}.sql`
3. GitHub Actions runs Flyway before deployment
4. Atomic, transactional migrations with rollback
### Health Check Endpoint
```typescript
// apps/backend/src/presentation/routes/health.ts
router.get('/api/health', async (req, res) => {
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`;
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message
});
}
});
```
## Key Patterns
### inversify Dependency Injection
```typescript
// ALWAYS required in tsconfig.json:
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
// MUST be first import in server.ts:
import 'reflect-metadata';
```
### Shared Validation (DRY)
```typescript
// libs/validators/src/property.ts - SINGLE SOURCE OF TRUTH
export const createPropertySchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/)
});
// Backend use case imports it
import { createPropertySchema } from '@validators/property';
// Frontend form imports THE SAME schema
import { toTypedSchema } from '@vee-validate/zod';
import { createPropertySchema } from '@validators/property';
const schema = toTypedSchema(createPropertySchema);
```
### Type Transformations (Prisma → Domain)
```typescript
// Prisma returns Decimal and nulls, domain expects number and undefined
return {
...property,
address2: property.address2 ?? undefined,
purchasePrice: property.purchasePrice ? property.purchasePrice.toNumber() : undefined
};
```
## References
See [reference.md](reference.md) for:
- Clean Architecture layer details
- Testing strategy and mock factories
- Railway deployment configuration
- API design patterns
- Security middleware setup
See [examples.md](examples.md) for:
- Complete feature implementation (MaintenanceWork)
- Full use case examples with tests
- Repository patterns with Prisma
- Controller and routing setup

View File

@@ -0,0 +1,446 @@
# Examples
## Creating a Maintenance Tracking Feature
This example shows how to add a complete maintenance tracking feature following Clean Architecture patterns.
### 1. Define the Domain Entity
```typescript
// libs/domain/src/entities/MaintenanceWork.ts
export interface CreateMaintenanceWorkData {
userId: string;
propertyId: string;
description: string;
status?: 'pending' | 'in-progress' | 'completed';
scheduledDate?: Date;
cost?: number;
}
export interface MaintenanceWork extends CreateMaintenanceWorkData {
id: string;
status: 'pending' | 'in-progress' | 'completed';
completedDate?: Date;
createdAt: Date;
updatedAt: Date;
}
```
### 2. Create Repository Interface
```typescript
// apps/backend/src/domain/repositories/IMaintenanceWorkRepository.ts
import { MaintenanceWork, CreateMaintenanceWorkData } from '@domain/entities';
export interface IMaintenanceWorkRepository {
create(data: CreateMaintenanceWorkData): Promise<MaintenanceWork>;
findById(id: string): Promise<MaintenanceWork | null>;
findByPropertyId(propertyId: string): Promise<MaintenanceWork[]>;
update(id: string, data: Partial<CreateMaintenanceWorkData>): Promise<MaintenanceWork>;
delete(id: string): Promise<void>;
}
```
### 3. Implement Use Case
```typescript
// apps/backend/src/application/maintenance/CreateMaintenanceWorkUseCase.ts
import { injectable, inject } from 'inversify';
import { IMaintenanceWorkRepository } from '../../domain/repositories/IMaintenanceWorkRepository';
import { IPropertyRepository } from '../../domain/repositories/IPropertyRepository';
import { ILogger } from '../../domain/services';
import { ValidationError, NotFoundError } from '@domain/errors';
import { createMaintenanceWorkSchema } from '@validators/maintenance';
import { MaintenanceWork } from '@domain/entities';
export interface CreateMaintenanceWorkInput {
userId: string;
propertyId: string;
description: string;
scheduledDate?: string;
cost?: number;
}
@injectable()
export class CreateMaintenanceWorkUseCase {
constructor(
@inject('IMaintenanceWorkRepository')
private maintenanceRepository: IMaintenanceWorkRepository,
@inject('IPropertyRepository')
private propertyRepository: IPropertyRepository,
@inject('ILogger')
private logger: ILogger
) {}
async execute(input: CreateMaintenanceWorkInput): Promise<MaintenanceWork> {
this.logger.info('Creating maintenance work', {
userId: input.userId,
propertyId: input.propertyId
});
// Validate input
const validation = createMaintenanceWorkSchema.safeParse(input);
if (!validation.success) {
this.logger.warn('Validation failed', { errors: validation.error.errors });
throw new ValidationError(validation.error.errors[0].message);
}
// Verify property exists and user owns it
const property = await this.propertyRepository.findById(input.propertyId);
if (!property) {
throw new NotFoundError('Property not found');
}
if (property.userId !== input.userId) {
throw new ValidationError('You do not own this property');
}
// Create maintenance work
const maintenanceWork = await this.maintenanceRepository.create({
...validation.data,
scheduledDate: validation.data.scheduledDate
? new Date(validation.data.scheduledDate)
: undefined,
status: 'pending'
});
this.logger.info('Maintenance work created', {
maintenanceWorkId: maintenanceWork.id
});
return maintenanceWork;
}
}
```
### 4. Create Prisma Schema
```prisma
// prisma/schema.prisma
model MaintenanceWork {
id String @id @default(uuid())
userId String
propertyId String
description String
status String @default("pending")
scheduledDate DateTime?
completedDate DateTime?
cost Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
performers WorkPerformer[]
@@index([userId])
@@index([propertyId])
@@index([status])
}
```
### 5. Implement Prisma Repository
```typescript
// apps/backend/src/infrastructure/repositories/PrismaMaintenanceWorkRepository.ts
import { injectable } from 'inversify';
import { PrismaClient, Prisma } from '@prisma/client';
import { IMaintenanceWorkRepository } from '../../domain/repositories/IMaintenanceWorkRepository';
import { MaintenanceWork, CreateMaintenanceWorkData } from '@domain/entities';
@injectable()
export class PrismaMaintenanceWorkRepository implements IMaintenanceWorkRepository {
private prisma: PrismaClient;
constructor() {
this.prisma = new PrismaClient();
}
async create(data: CreateMaintenanceWorkData): Promise<MaintenanceWork> {
const result = await this.prisma.maintenanceWork.create({
data: {
...data,
cost: data.cost ? new Prisma.Decimal(data.cost) : undefined
}
});
// Transform Prisma types to domain types
return {
...result,
scheduledDate: result.scheduledDate ?? undefined,
completedDate: result.completedDate ?? undefined,
cost: result.cost ? result.cost.toNumber() : undefined
};
}
async findById(id: string): Promise<MaintenanceWork | null> {
const result = await this.prisma.maintenanceWork.findUnique({
where: { id }
});
if (!result) return null;
return {
...result,
scheduledDate: result.scheduledDate ?? undefined,
completedDate: result.completedDate ?? undefined,
cost: result.cost ? result.cost.toNumber() : undefined
};
}
async findByPropertyId(propertyId: string): Promise<MaintenanceWork[]> {
const results = await this.prisma.maintenanceWork.findMany({
where: { propertyId },
orderBy: { scheduledDate: 'asc' }
});
return results.map(r => ({
...r,
scheduledDate: r.scheduledDate ?? undefined,
completedDate: r.completedDate ?? undefined,
cost: r.cost ? r.cost.toNumber() : undefined
}));
}
async update(id: string, data: Partial<CreateMaintenanceWorkData>): Promise<MaintenanceWork> {
const result = await this.prisma.maintenanceWork.update({
where: { id },
data: {
...data,
cost: data.cost ? new Prisma.Decimal(data.cost) : undefined
}
});
return {
...result,
scheduledDate: result.scheduledDate ?? undefined,
completedDate: result.completedDate ?? undefined,
cost: result.cost ? result.cost.toNumber() : undefined
};
}
async delete(id: string): Promise<void> {
await this.prisma.maintenanceWork.delete({
where: { id }
});
}
}
```
### 6. Register in Container
```typescript
// apps/backend/src/container.ts
import { IMaintenanceWorkRepository } from './domain/repositories/IMaintenanceWorkRepository';
import { PrismaMaintenanceWorkRepository } from './infrastructure/repositories/PrismaMaintenanceWorkRepository';
import { CreateMaintenanceWorkUseCase } from './application/maintenance/CreateMaintenanceWorkUseCase';
import { MaintenanceWorkController } from './presentation/controllers/MaintenanceWorkController';
export function createContainer(): Container {
const container = new Container();
// ... existing bindings ...
// Repository
container
.bind<IMaintenanceWorkRepository>('IMaintenanceWorkRepository')
.to(PrismaMaintenanceWorkRepository)
.inTransientScope();
// Use Cases
container.bind(CreateMaintenanceWorkUseCase).toSelf().inTransientScope();
// Controller
container.bind(MaintenanceWorkController).toSelf().inTransientScope();
return container;
}
```
### 7. Create Controller
```typescript
// apps/backend/src/presentation/controllers/MaintenanceWorkController.ts
import { injectable, inject } from 'inversify';
import { Response, NextFunction } from 'express';
import { CreateMaintenanceWorkUseCase } from '../../application/maintenance/CreateMaintenanceWorkUseCase';
import { AuthRequest } from '../middleware';
@injectable()
export class MaintenanceWorkController {
constructor(
@inject(CreateMaintenanceWorkUseCase)
private createUseCase: CreateMaintenanceWorkUseCase
) {}
async create(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const maintenanceWork = await this.createUseCase.execute({
...req.body,
userId: req.user.userId
});
res.status(201).json(maintenanceWork);
} catch (error) {
next(error);
}
}
}
```
### 8. Add Routes
```typescript
// apps/backend/src/presentation/routes/maintenance.routes.ts
import { Router } from 'express';
import { Container } from 'inversify';
import { MaintenanceWorkController } from '../controllers/MaintenanceWorkController';
import { authenticate } from '../middleware/auth';
export function createMaintenanceRoutes(container: Container): Router {
const router = Router();
const controller = container.get(MaintenanceWorkController);
router.use(authenticate); // Require authentication for all routes
router.post('/', (req, res, next) => controller.create(req, res, next));
// Add other routes as needed
return router;
}
// In main routes file (apps/backend/src/server.ts)
app.use('/api/maintenance-works', createMaintenanceRoutes(container));
```
### 9. Write Tests
```typescript
// apps/backend/src/application/maintenance/CreateMaintenanceWorkUseCase.unit.test.ts
import { CreateMaintenanceWorkUseCase } from './CreateMaintenanceWorkUseCase';
import { IMaintenanceWorkRepository } from '../../domain/repositories/IMaintenanceWorkRepository';
import { IPropertyRepository } from '../../domain/repositories/IPropertyRepository';
import { ILogger } from '../../domain/services';
import { ValidationError, NotFoundError } from '@domain/errors';
import { MaintenanceWork, Property } from '@domain/entities';
describe('CreateMaintenanceWorkUseCase', () => {
let useCase: CreateMaintenanceWorkUseCase;
let mockMaintenanceRepo: jest.Mocked<IMaintenanceWorkRepository>;
let mockPropertyRepo: jest.Mocked<IPropertyRepository>;
let mockLogger: jest.Mocked<ILogger>;
beforeEach(() => {
mockMaintenanceRepo = {
create: jest.fn(),
findById: jest.fn(),
findByPropertyId: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
mockPropertyRepo = {
findById: jest.fn(),
findByUserId: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn()
};
useCase = new CreateMaintenanceWorkUseCase(
mockMaintenanceRepo,
mockPropertyRepo,
mockLogger
);
});
it('should create maintenance work for owned property', async () => {
const input = {
userId: 'user-123',
propertyId: 'property-456',
description: 'Fix leaking faucet',
scheduledDate: '2024-12-01'
};
const property: Property = {
id: 'property-456',
userId: 'user-123',
street: '123 Test St',
city: 'Test City',
state: 'CA',
zipCode: '94102',
createdAt: new Date(),
updatedAt: new Date()
};
const expected: MaintenanceWork = {
id: 'maintenance-789',
userId: input.userId,
propertyId: input.propertyId,
description: input.description,
status: 'pending',
scheduledDate: new Date(input.scheduledDate),
createdAt: new Date(),
updatedAt: new Date()
};
mockPropertyRepo.findById.mockResolvedValue(property);
mockMaintenanceRepo.create.mockResolvedValue(expected);
const result = await useCase.execute(input);
expect(result).toEqual(expected);
expect(mockPropertyRepo.findById).toHaveBeenCalledWith('property-456');
expect(mockMaintenanceRepo.create).toHaveBeenCalled();
});
it('should throw error if property not found', async () => {
const input = {
userId: 'user-123',
propertyId: 'non-existent',
description: 'Fix something'
};
mockPropertyRepo.findById.mockResolvedValue(null);
await expect(useCase.execute(input))
.rejects.toThrow(NotFoundError);
});
it('should throw error if user does not own property', async () => {
const input = {
userId: 'user-123',
propertyId: 'property-456',
description: 'Fix something'
};
const property: Property = {
id: 'property-456',
userId: 'different-user',
street: '123 Test St',
city: 'Test City',
state: 'CA',
zipCode: '94102',
createdAt: new Date(),
updatedAt: new Date()
};
mockPropertyRepo.findById.mockResolvedValue(property);
await expect(useCase.execute(input))
.rejects.toThrow(ValidationError);
});
});
```

View File

@@ -0,0 +1,286 @@
# References
## Clean Architecture
### Layer Separation
The application follows Clean Architecture with strict dependency rules:
```
Presentation → Application → Domain → Core
↓ ↓ ↓
Infrastructure Infrastructure (No dependencies)
```
- **Core Layer**: Pure domain entities with no external dependencies
- **Domain Layer**: Repository and service interfaces
- **Application Layer**: Use cases containing business logic
- **Infrastructure Layer**: Concrete implementations (Prisma, external services)
- **Presentation Layer**: HTTP controllers, middleware, routes
### Dependency Injection with inversify
```typescript
// Required in tsconfig.json
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
// Required in server.ts (must be first import)
import 'reflect-metadata';
```
## Testing Strategy
### Test Organization
```
apps/backend/src/
├── application/
│ └── property/
│ ├── CreatePropertyUseCase.ts
│ └── CreatePropertyUseCase.unit.test.ts # Unit tests next to use cases
└── __tests__/
└── integration/ # Integration tests
└── property.integration.test.ts
```
### Mock Factories
```typescript
import { ILogger } from '../domain/services';
export function createMockRepository<T>(): jest.Mocked<T> {
return {
create: jest.fn(),
findById: jest.fn(),
findByUserId: jest.fn(),
update: jest.fn(),
delete: jest.fn()
} as any;
}
export function createMockLogger(): jest.Mocked<ILogger> {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn()
};
}
```
## Railway Deployment
### Environment Variables
```env
# Database (Railway provides this)
DATABASE_URL=${{Postgres.DATABASE_URL}}
# Server
PORT=${{PORT}}
NODE_ENV=production
LOG_LEVEL=info
# Authentication
JWT_SECRET=<generate-secure-random-string>
JWT_EXPIRY=7d
# Frontend (for CORS)
FRONTEND_URL=https://your-frontend.railway.app
```
### Database Migrations
Railway uses Flyway for production migrations. Convert Prisma migrations:
1. Generate Prisma migration: `npx prisma migrate dev --name feature_name`
2. Copy SQL to Flyway format: `migrations/V{number}__{name}.sql`
3. Commit both Prisma and Flyway migrations
4. Railway runs Flyway on deploy
## API Patterns
### RESTful Endpoints
```
GET /api/resources # List with pagination
GET /api/resources/:id # Get single resource
POST /api/resources # Create new resource
PUT /api/resources/:id # Update resource
DELETE /api/resources/:id # Delete resource
```
### Response Format
```typescript
// Success - direct data return
res.status(200).json(property);
// Error - simple error message
res.status(400).json({ error: 'Validation failed' });
// With pagination metadata
res.status(200).json({
items: properties,
total: 100,
page: 1,
limit: 20
});
```
### Pagination
```typescript
interface PaginationOptions {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages: number;
hasNext: boolean;
hasPrevious: boolean;
}
```
## Security Middleware
### Essential Security Setup
```typescript
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
// Security headers
app.use(helmet());
// CORS
app.use(cors({
origin: process.env.FRONTEND_URL,
credentials: true
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.use('/api/', limiter);
// Auth-specific rate limiting
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true
});
app.use('/api/auth/', authLimiter);
```
## Production Logging Strategy
### Current Implementation (ILogger Interface)
```typescript
// apps/backend/src/domain/services/ILogger.ts
export interface ILogger {
info(message: string, context?: object): void;
warn(message: string, context?: object): void;
error(message: string, context?: object): void;
debug(message: string, context?: object): void;
}
// apps/backend/src/infrastructure/services/ConsoleLogger.ts
export class ConsoleLogger implements ILogger {
info(message: string, context?: object): void {
console.log(message, context);
}
// ... other methods
}
```
### Railway Logging Requirements
For production deployment to Railway, you need:
- **Structured JSON output** - Railway aggregates JSON logs
- **Stdout/stderr** - Railway captures console output
- **No file logging** - Containers are ephemeral
- **Request correlation** - Track requests across services
- **Performance metrics** - Identify slow operations
### Recommended Logging Solutions
**Option 1: Pino (Recommended)**
- Fastest JSON logger
- Built for cloud deployments
- Low overhead
```bash
npm install pino pino-pretty
```
**Option 2: Winston**
- More features
- Heavier, more complex
**Option 3: Enhanced ConsoleLogger**
- Keep it simple with structured JSON
```typescript
export class StructuredConsoleLogger implements ILogger {
info(message: string, context?: object): void {
console.log(JSON.stringify({
level: 'info',
message,
timestamp: new Date().toISOString(),
...context
}));
}
}
```
### Request Correlation Pattern
```typescript
// apps/backend/src/presentation/middleware/requestId.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
const requestId = uuidv4();
req.headers['x-request-id'] = requestId;
res.setHeader('x-request-id', requestId);
next();
}
// Use in use cases:
this.logger.info('Creating resource', {
requestId: req.headers['x-request-id'],
userId: input.userId
});
```
### Performance Logging Pattern
```typescript
async execute(input: CreateResourceInput): Promise<Resource> {
const start = Date.now();
try {
const resource = await this.repository.create(input);
const duration = Date.now() - start;
this.logger.info('Resource created', { resourceId: resource.id, duration });
if (duration > 1000) {
this.logger.warn('Slow operation', { operation: 'CreateResource', duration });
}
return resource;
} catch (error) {
this.logger.error('Failed to create resource', {
error: error.message,
stack: error.stack,
userId: input.userId
});
throw error;
}
}
```

View File

@@ -0,0 +1,137 @@
---
name: vue-development
description: Use when planning or implementing Vue 3 projects - helps architect component structure, plan feature implementation, and enforce TypeScript-first patterns with Composition API, defineModel for bindings, Testing Library for user-behavior tests, and MSW for API mocking. Especially useful in planning phase to guide proper patterns before writing code.
---
# Vue Development
## Research Protocol
**MANDATORY:** Follow the research protocol in `@shared/research-protocol.md` before implementing Vue patterns.
### When to Research
You MUST use `mcp__Ref__ref_search_documentation` before:
- Using Vue APIs you haven't verified this session (defineModel, defineProps, defineEmits)
- Writing tests with Testing Library or Vitest
- Implementing routing patterns with Vue Router 4
- Using version-specific features (Vue 3.4+, Vue 3.5+)
**If official documentation differs from this skill, documentation takes precedence.**
## Overview
Modern Vue 3 development with TypeScript, Composition API, and user-behavior testing. **Core principle:** Use TypeScript generics (not runtime validation), modern APIs (defineModel not manual props), and test user behavior (not implementation details).
## Red Flags - STOP and Fix
If you catch yourself thinking or doing ANY of these, STOP:
- "For speed" / "quick demo" / "emergency" → Using shortcuts
- "We can clean it up later" → Accepting poor patterns
- "TypeScript is too verbose" → Skipping types
- "This is production-ready" → Without type safety
- "Following existing code style" → When existing code uses legacy patterns
- "Task explicitly stated..." → Following bad requirements literally
- Using `const props = defineProps()` without using props in script
- Manual `modelValue` prop + `update:modelValue` emit → Use defineModel()
- "Component that takes value and emits changes" → Use defineModel(), NOT manual props/emit
- Using runtime prop validation when TypeScript is available
- Array syntax for emits: `defineEmits(['event'])` → Missing type safety
- `setTimeout()` in tests → Use proper async utilities
- Testing `wrapper.vm.*` internal state → Test user-visible behavior
- Using `index.vue` in routes → Use route groups `(name).vue`
- Generic route params `[id]` → Use explicit `[userId]`, `[postSlug]`
- Composables calling `showToast()`, `alert()`, or modals → Expose error state, component handles UI
- External composable used in only ONE component → Start inline, extract when reused
**All of these mean: Use the modern pattern. No exceptions.**
## Quick Rules
**Components:** `defineProps<{ }>()` (no const unless used in script), `defineEmits<{ event: [args] }>()`, `defineModel<type>()` for v-model. See @references/component-patterns.md
**Testing:** `@testing-library/vue` + MSW. Use `findBy*` or `waitFor()` for async. NEVER `setTimeout()` or test internal state. See @references/testing-patterns.md
**Routing:** Explicit params `[userId]` not `[id]`. Avoid `index.vue`, use `(name).vue`. Use `.` for nesting: `users.edit.vue``/users/edit`. See @references/routing-patterns.md
**Composables:** START INLINE for component-specific logic, extract to external file when reused. External composables: prefix `use`, NO UI logic (expose error state instead). See @references/composable-patterns.md
## Key Pattern: defineModel()
The most important pattern to remember - use for ALL two-way binding:
```vue
<script setup lang="ts">
// ✅ For simple v-model
const value = defineModel<string>({ required: true })
// ✅ For multiple v-models
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>
<template>
<input v-model="value" />
<!-- Parent uses: <Component v-model="data" /> -->
</template>
```
**Why:** Reduces 5 lines of boilerplate to 1. No manual `modelValue` prop + `update:modelValue` emit.
## Component Implementation Workflow
When implementing complex Vue components, use TodoWrite to track progress:
```
TodoWrite checklist for component implementation:
- [ ] Define TypeScript interfaces for props/emits/models
- [ ] Implement props with defineProps<{ }>() (no const unless used in script)
- [ ] Implement emits with defineEmits<{ event: [args] }>()
- [ ] Add v-model with defineModel<type>() if needed
- [ ] Write user-behavior tests with Testing Library
- [ ] Test async behavior with findBy* queries or waitFor()
- [ ] Verify: No red flags, no setTimeout in tests, all types present
```
**When to create TodoWrite todos:**
- Implementing new components with state, v-model, and testing
- Refactoring components to modern patterns
- Adding routing with typed params
- Creating composables with async logic
## Rationalizations Table
| Excuse | Reality |
|--------|---------|
| "For speed/emergency/no time" | Correct patterns take SAME time. TypeScript IS fast. |
| "TypeScript is too verbose" | `defineProps<{ count: number }>()` is LESS code. |
| "We can clean it up later" | Write it right the first time. |
| "This is production-ready" | Without type safety, it's not production-ready. |
| "Simple array syntax is fine" | Missing types = runtime errors TypeScript would catch. |
| "Manual modelValue was correct" | That was Vue 2. Use defineModel() in Vue 3.4+. |
| "Tests are flaky, add timeout" | Timeouts mask bugs. Use proper async handling. |
| "Following existing code style" | Legacy code exists. Use modern patterns to improve. |
| "Task explicitly stated X" | Understand INTENT. Bad requirements need good implementation. |
| "Composables can show toasts" | UI belongs in components. Expose error state. |
| "[id] is industry standard" | Explicit names prevent bugs, enable TypeScript autocomplete. |
| "counter.ts is fine" | Must prefix with 'use': useCounter.ts |
| "test-utils is the standard" | Testing Library is gold standard for user-behavior. |
## Detailed References
See @references/ directory for comprehensive guides: component-patterns.md, testing-patterns.md, testing-composables.md, routing-patterns.md, composable-patterns.md
## When NOT to Use This Skill
- Vue 2 projects (different API)
- Options API codebases (this is Composition API focused)
- Projects without TypeScript (though you should add it)
## Real-World Impact
**Baseline:** 37.5% correct patterns under pressure
**With skill:** 100% correct patterns under pressure
Type safety prevents runtime errors. defineModel() reduces boilerplate. Testing Library catches real user issues.