Initial commit
This commit is contained in:
65
skills/github-issue-writer/SKILL.md
Normal file
65
skills/github-issue-writer/SKILL.md
Normal 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.**
|
||||
735
skills/typescript-development/SKILL.md
Normal file
735
skills/typescript-development/SKILL.md
Normal 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
|
||||
446
skills/typescript-development/examples.md
Normal file
446
skills/typescript-development/examples.md
Normal 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);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
286
skills/typescript-development/reference.md
Normal file
286
skills/typescript-development/reference.md
Normal 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
137
skills/vue-development/SKILL.md
Normal file
137
skills/vue-development/SKILL.md
Normal 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.
|
||||
Reference in New Issue
Block a user