1173 lines
32 KiB
Markdown
1173 lines
32 KiB
Markdown
# Production Readiness Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Harden boha-app-ts for production: security fixes, Zod validation, service layer extraction, test suite, and build preparation.
|
|
|
|
**Architecture:** Fastify backend with Prisma ORM (MySQL), React SPA frontend, JWT + TOTP auth. Routes are refactored from fat controllers to thin handlers calling service functions. All request bodies validated with Zod schemas. Tests target the HTTP API layer via Supertest.
|
|
|
|
**Tech Stack:** TypeScript, Fastify 5, Prisma 6, Zod, Vitest, Supertest, bcryptjs, jsonwebtoken, otpauth
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-23-production-readiness-design.md`
|
|
|
|
---
|
|
|
|
## Phase 1: Security Hardening
|
|
|
|
### Task 1: Secrets & Environment
|
|
|
|
**Files:**
|
|
- Verify: `.gitignore`
|
|
- Create: `.env.example`
|
|
|
|
- [ ] **Step 1: Verify .env is gitignored and never committed**
|
|
|
|
```bash
|
|
grep "^\.env$" .gitignore
|
|
git log --all --diff-filter=A -- .env
|
|
```
|
|
|
|
Expected: `.env` appears in `.gitignore`. Git log shows no results (never committed).
|
|
If `.env` was committed, run `git rm --cached .env` and add to `.gitignore`.
|
|
|
|
- [ ] **Step 2: Create .env.example**
|
|
|
|
Create `.env.example` with placeholder values:
|
|
|
|
```env
|
|
# Database
|
|
DATABASE_URL=mysql://user:password@localhost:3306/app
|
|
|
|
# Server
|
|
PORT=3001
|
|
HOST=127.0.0.1
|
|
APP_ENV=local
|
|
|
|
# Auth — MUST regenerate for production: openssl rand -hex 32
|
|
JWT_SECRET=generate-with-openssl-rand-hex-32
|
|
ACCESS_TOKEN_EXPIRY=900
|
|
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
|
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
|
|
|
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
|
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
|
|
|
|
# File storage
|
|
NAS_PATH=Z:/02_PROJEKTY
|
|
MAX_UPLOAD_SIZE=52428800
|
|
|
|
# Email
|
|
CONTACT_EMAIL_TO=
|
|
CONTACT_EMAIL_FROM=
|
|
SMTP_FROM=
|
|
|
|
# CORS (production only, comma-separated)
|
|
CORS_ORIGINS=
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add .env.example .gitignore
|
|
git commit -m "chore: add .env.example with placeholder values"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Rate Limiting — Login Endpoint
|
|
|
|
**Files:**
|
|
- Modify: `src/server.ts`
|
|
- Modify: `src/routes/admin/auth.ts`
|
|
|
|
- [ ] **Step 1: Add per-route rate limit to login endpoint**
|
|
|
|
In `src/routes/admin/auth.ts`, add rate limit config to the login route:
|
|
|
|
```typescript
|
|
fastify.post('/login', {
|
|
config: {
|
|
rateLimit: {
|
|
max: 20,
|
|
timeWindow: '1 minute',
|
|
},
|
|
},
|
|
}, async (request, reply) => {
|
|
// ... existing login handler
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Verify global rate limit stays at 100/min**
|
|
|
|
Confirm `src/server.ts` line 53-56 remains:
|
|
```typescript
|
|
await app.register(rateLimit, {
|
|
max: 100,
|
|
timeWindow: '1 minute',
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/routes/admin/auth.ts
|
|
git commit -m "security: add stricter rate limit on login endpoint (20/min)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Security Headers — CSP & Permissions-Policy
|
|
|
|
**Files:**
|
|
- Modify: `src/middleware/security.ts`
|
|
|
|
- [ ] **Step 1: Add CSP (production only) and Permissions-Policy**
|
|
|
|
Replace `src/middleware/security.ts` with:
|
|
|
|
```typescript
|
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
|
import { config } from '../config/env';
|
|
|
|
export async function securityHeaders(
|
|
_request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
): Promise<void> {
|
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
reply.header('X-Frame-Options', 'DENY');
|
|
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
|
|
if (config.isProduction) {
|
|
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
reply.header(
|
|
'Content-Security-Policy',
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'",
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/middleware/security.ts
|
|
git commit -m "security: add CSP (prod) and Permissions-Policy headers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Request Body Limits
|
|
|
|
**Files:**
|
|
- Modify: `src/server.ts`
|
|
|
|
- [ ] **Step 1: Add global body limit to Fastify**
|
|
|
|
In `src/server.ts`, add `bodyLimit` to the Fastify constructor:
|
|
|
|
```typescript
|
|
const app = Fastify({
|
|
logger: {
|
|
level: config.isProduction ? 'warn' : 'info',
|
|
},
|
|
trustProxy: true,
|
|
bodyLimit: 1048576, // 1MB global limit
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Add 10KB limit to auth routes**
|
|
|
|
In `src/routes/admin/auth.ts`, add bodyLimit to login, refresh, and TOTP routes:
|
|
|
|
```typescript
|
|
fastify.post('/login', {
|
|
bodyLimit: 10240, // 10KB
|
|
config: {
|
|
rateLimit: { max: 20, timeWindow: '1 minute' },
|
|
},
|
|
}, async (request, reply) => { /* ... */ });
|
|
|
|
fastify.post('/refresh', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ });
|
|
```
|
|
|
|
Also in `src/routes/admin/totp.ts`, add to verify endpoints:
|
|
|
|
```typescript
|
|
fastify.post('/verify', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ });
|
|
fastify.post('/login/totp', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ });
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/server.ts src/routes/admin/auth.ts src/routes/admin/totp.ts
|
|
git commit -m "security: add request body size limits (1MB global, 10KB auth)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Timing-Safe Auth
|
|
|
|
**Files:**
|
|
- Modify: `src/services/auth.ts`
|
|
|
|
- [ ] **Step 1: Add dummy hash constant and timing-safe comparison**
|
|
|
|
At the top of `src/services/auth.ts`, add a pre-computed dummy hash:
|
|
|
|
```typescript
|
|
// Pre-computed bcrypt hash for timing-safe comparison when user not found
|
|
const DUMMY_HASH = '$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO';
|
|
```
|
|
|
|
Then in the `login()` function, replace the early return on user-not-found:
|
|
|
|
```typescript
|
|
if (!user) {
|
|
// Timing-safe: run bcrypt even when user not found
|
|
await bcrypt.compare(password, DUMMY_HASH);
|
|
return { type: 'error', message: 'Neplatné přihlašovací údaje', status: 401 };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/services/auth.ts
|
|
git commit -m "security: timing-safe auth to prevent username enumeration"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Input Validation (Zod)
|
|
|
|
### Task 6: Install Zod, Create Common Helper & Auth Schema
|
|
|
|
**Files:**
|
|
- Create: `src/schemas/common.ts`
|
|
- Create: `src/schemas/auth.schema.ts`
|
|
- Modify: `src/routes/admin/auth.ts`
|
|
|
|
- [ ] **Step 1: Install zod**
|
|
|
|
```bash
|
|
npm install zod
|
|
```
|
|
|
|
- [ ] **Step 2: Create common validation helper**
|
|
|
|
Create `src/schemas/common.ts`:
|
|
|
|
```typescript
|
|
import { ZodSchema, ZodError } from 'zod';
|
|
|
|
export function parseBody<T>(schema: ZodSchema<T>, body: unknown): { data: T } | { error: string } {
|
|
try {
|
|
return { data: schema.parse(body) };
|
|
} catch (e) {
|
|
if (e instanceof ZodError) {
|
|
return { error: e.errors.map(err => err.message).join(', ') };
|
|
}
|
|
return { error: 'Neplatný požadavek' };
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create auth validation schemas**
|
|
|
|
Create `src/schemas/auth.schema.ts`:
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
|
|
export const LoginSchema = z.object({
|
|
username: z.string().min(1, 'Uživatelské jméno je povinné'),
|
|
password: z.string().min(1, 'Heslo je povinné'),
|
|
remember_me: z.boolean().optional().default(false),
|
|
});
|
|
|
|
export const TotpVerifySchema = z.object({
|
|
login_token: z.string().min(1, 'Token je povinný'),
|
|
totp_code: z.string().length(6, 'Kód musí mít 6 číslic'),
|
|
});
|
|
|
|
export const TotpBackupSchema = z.object({
|
|
login_token: z.string().min(1, 'Token je povinný'),
|
|
backup_code: z.string().min(1, 'Záložní kód je povinný'),
|
|
});
|
|
|
|
export type LoginInput = z.infer<typeof LoginSchema>;
|
|
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
|
|
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;
|
|
```
|
|
|
|
- [ ] **Step 4: Apply schemas in auth routes**
|
|
|
|
In `src/routes/admin/auth.ts`, import and use:
|
|
|
|
```typescript
|
|
import { parseBody } from '../../schemas/common';
|
|
import { LoginSchema, TotpVerifySchema } from '../../schemas/auth.schema';
|
|
```
|
|
|
|
Replace existing manual validation in the login handler with:
|
|
|
|
```typescript
|
|
const parsed = parseBody(LoginSchema, request.body);
|
|
if ('error' in parsed) return error(reply, parsed.error, 400);
|
|
const { username, password, remember_me } = parsed.data;
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/schemas/common.ts src/schemas/auth.schema.ts src/routes/admin/auth.ts package.json package-lock.json
|
|
git commit -m "feat: add Zod validation for auth endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Create Validation Schemas for All Domains
|
|
|
|
**Files:**
|
|
- Create: `src/schemas/users.schema.ts`
|
|
- Create: `src/schemas/offers.schema.ts`
|
|
- Create: `src/schemas/orders.schema.ts`
|
|
- Create: `src/schemas/invoices.schema.ts`
|
|
- Create: `src/schemas/projects.schema.ts`
|
|
- Create: `src/schemas/customers.schema.ts`
|
|
- Create: `src/schemas/attendance.schema.ts`
|
|
- Create: `src/schemas/trips.schema.ts`
|
|
- Create: `src/schemas/vehicles.schema.ts`
|
|
- Create: `src/schemas/leave-requests.schema.ts`
|
|
- Create: `src/schemas/roles.schema.ts`
|
|
- Create: `src/schemas/bank-accounts.schema.ts`
|
|
- Create: `src/schemas/company-settings.schema.ts`
|
|
- Create: `src/schemas/received-invoices.schema.ts`
|
|
- Create: `src/schemas/scope-templates.schema.ts`
|
|
- Create: `src/schemas/profile.schema.ts`
|
|
|
|
- [ ] **Step 1: Create domain schemas**
|
|
|
|
Create each schema file based on the existing `request.body as Record<string, unknown>` patterns in each route file. Example for `src/schemas/users.schema.ts`:
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
|
|
export const CreateUserSchema = z.object({
|
|
username: z.string().min(1, 'Uživatelské jméno je povinné'),
|
|
email: z.string().email('Neplatný formát e-mailu'),
|
|
password: z.string().min(8, 'Heslo musí mít alespoň 8 znaků'),
|
|
first_name: z.string().optional().default(''),
|
|
last_name: z.string().optional().default(''),
|
|
role_id: z.number().int().positive().optional(),
|
|
is_active: z.boolean().optional().default(true),
|
|
});
|
|
|
|
export const UpdateUserSchema = CreateUserSchema.partial().omit({ password: true }).extend({
|
|
password: z.string().min(8, 'Heslo musí mít alespoň 8 znaků').optional(),
|
|
});
|
|
|
|
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
|
|
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
|
```
|
|
|
|
Follow the same pattern for all domains — each schema mirrors the fields currently extracted from `request.body`. Cover ALL route files that use `as Record<string, unknown>`: users, offers, orders, invoices, projects, customers, attendance, trips, vehicles, leave-requests, roles, bank-accounts, company-settings, received-invoices, scope-templates, profile, totp.
|
|
|
|
- [ ] **Step 2: Apply schemas in all route files**
|
|
|
|
For each route file, replace `const body = request.body as Record<string, unknown>` with:
|
|
|
|
```typescript
|
|
const parsed = parseBody(CreateOfferSchema, request.body);
|
|
if ('error' in parsed) return error(reply, parsed.error, 400);
|
|
const body = parsed.data;
|
|
```
|
|
|
|
- [ ] **Step 3: Remove `as Record<string, unknown>` casts**
|
|
|
|
Search all route files for remaining `as Record<string, unknown>` and replace with typed Zod parsing.
|
|
|
|
```bash
|
|
grep -rn "as Record<string, unknown>" src/routes/
|
|
```
|
|
|
|
Expected: zero results after cleanup.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/schemas/ src/routes/
|
|
git commit -m "feat: add Zod validation schemas for all domain routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Service Layer Refactor
|
|
|
|
### Task 8: Create Numbering Service
|
|
|
|
**Files:**
|
|
- Create: `src/services/numbering.service.ts`
|
|
- Modify: `src/routes/admin/orders.ts`
|
|
- Modify: `src/routes/admin/projects.ts`
|
|
- Modify: `src/routes/admin/quotations.ts`
|
|
|
|
- [ ] **Step 1: Create numbering service**
|
|
|
|
Create `src/services/numbering.service.ts`:
|
|
|
|
```typescript
|
|
import prisma from '../config/database';
|
|
|
|
/**
|
|
* Shared number generator for orders and projects.
|
|
* Format: YYtypeCode + 4-digit sequence (e.g., 26710003)
|
|
* Queries MAX from both orders and projects tables.
|
|
*/
|
|
export async function generateSharedNumber(): Promise<string> {
|
|
const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } });
|
|
const typeCode = settings?.order_type_code || '71';
|
|
const yy = String(new Date().getFullYear()).slice(-2);
|
|
const prefix = `${yy}${typeCode}`;
|
|
const prefixLen = prefix.length;
|
|
const likePattern = `${prefix}%`;
|
|
|
|
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
|
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
|
|
SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
|
|
FROM orders WHERE order_number LIKE ${likePattern}
|
|
UNION ALL
|
|
SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
|
|
FROM projects WHERE project_number LIKE ${likePattern}
|
|
) combined
|
|
`;
|
|
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
|
return `${prefix}${String(nextNum).padStart(4, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* Next offer number. Queries MAX from quotations table.
|
|
* Format: YEAR/PREFIX/NNN (e.g., 2026/NA/008)
|
|
*/
|
|
export async function generateOfferNumber(): Promise<string> {
|
|
const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } });
|
|
const prefix = settings?.quotation_prefix || 'NA';
|
|
const year = new Date().getFullYear();
|
|
const likePattern = `${year}/${prefix}/%`;
|
|
|
|
const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>`
|
|
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num
|
|
FROM quotations
|
|
WHERE quotation_number LIKE ${likePattern}
|
|
`;
|
|
const nextNum = Number(result[0]?.max_num ?? 0) + 1;
|
|
return `${year}/${prefix}/${String(nextNum).padStart(3, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* Next invoice number via atomic sequence table.
|
|
*/
|
|
export async function generateInvoiceNumber(year: number): Promise<number> {
|
|
return prisma.$transaction(async (tx) => {
|
|
const existing = await tx.number_sequences.findFirst({
|
|
where: { type: 'invoice', year },
|
|
});
|
|
|
|
if (existing) {
|
|
const nextNum = (existing.last_number ?? 0) + 1;
|
|
await tx.number_sequences.update({
|
|
where: { id: existing.id },
|
|
data: { last_number: nextNum },
|
|
});
|
|
return nextNum;
|
|
}
|
|
|
|
await tx.number_sequences.create({
|
|
data: { type: 'invoice', year, last_number: 1 },
|
|
});
|
|
return 1;
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update orders.ts to use numbering service**
|
|
|
|
Replace `generateOrderNumber()` and `generateProjectNumber()` in `src/routes/admin/orders.ts` with imports from the service:
|
|
|
|
```typescript
|
|
import { generateSharedNumber } from '../../services/numbering.service';
|
|
```
|
|
|
|
Remove the local `generateOrderNumber()`, `generateProjectNumber()`, and `generateSharedNumber()` functions. Replace all calls:
|
|
|
|
```typescript
|
|
const orderNumber = await generateSharedNumber();
|
|
const projectNumber = await generateSharedNumber();
|
|
```
|
|
|
|
- [ ] **Step 3: Update projects.ts and quotations.ts similarly**
|
|
|
|
In `src/routes/admin/projects.ts`, replace the inline next-number logic with:
|
|
|
|
```typescript
|
|
import { generateSharedNumber } from '../../services/numbering.service';
|
|
```
|
|
|
|
In `src/routes/admin/quotations.ts`, replace the inline offer number logic with:
|
|
|
|
```typescript
|
|
import { generateOfferNumber } from '../../services/numbering.service';
|
|
```
|
|
|
|
- [ ] **Step 4: Update sequence.ts to re-export from numbering service**
|
|
|
|
In `src/utils/sequence.ts`, replace the implementation with a re-export to avoid breaking any remaining callers:
|
|
|
|
```typescript
|
|
// Re-export from canonical location
|
|
export { generateInvoiceNumber as getNextNumber } from '../services/numbering.service';
|
|
```
|
|
|
|
Check for any remaining imports of `sequence.ts` and update them to import from `numbering.service.ts` directly:
|
|
|
|
```bash
|
|
grep -rn "from.*sequence" src/
|
|
```
|
|
|
|
If no callers remain, delete `src/utils/sequence.ts`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/services/numbering.service.ts src/routes/admin/orders.ts src/routes/admin/projects.ts src/routes/admin/quotations.ts src/utils/sequence.ts
|
|
git commit -m "refactor: extract numbering logic into numbering.service.ts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Extract Offers Service
|
|
|
|
**Files:**
|
|
- Create: `src/services/offers.service.ts`
|
|
- Modify: `src/routes/admin/quotations.ts`
|
|
|
|
- [ ] **Step 1: Create offers service**
|
|
|
|
Extract the business logic from each route handler in `quotations.ts` into `src/services/offers.service.ts`. The service functions should handle Prisma queries, calculations, and data enrichment. Example:
|
|
|
|
```typescript
|
|
import prisma from '../config/database';
|
|
import { generateOfferNumber } from './numbering.service';
|
|
|
|
export async function listOffers(params: { page: number; limit: number; skip: number; sort: string; order: string; search?: string; status?: string; customerId?: number }) {
|
|
const where: Record<string, unknown> = {};
|
|
if (params.status) where.status = params.status;
|
|
if (params.customerId) where.customer_id = params.customerId;
|
|
// ... build where clause, run query, enrich results
|
|
// Return { offers, total }
|
|
}
|
|
|
|
export async function getOffer(id: number) { /* ... */ }
|
|
export async function createOffer(data: CreateOfferInput) { /* ... */ }
|
|
export async function updateOffer(id: number, data: UpdateOfferInput) { /* ... */ }
|
|
export async function duplicateOffer(id: number) { /* ... */ }
|
|
export async function getNextOfferNumber() { return generateOfferNumber(); }
|
|
```
|
|
|
|
- [ ] **Step 2: Slim down quotations.ts routes**
|
|
|
|
Each route handler becomes:
|
|
|
|
```typescript
|
|
fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
|
const query = request.query as Record<string, unknown>;
|
|
const pagination = parsePagination(query);
|
|
const result = await listOffers({ ...pagination, status: query.status as string, customerId: query.customer_id ? Number(query.customer_id) : undefined });
|
|
return reply.send({ success: true, data: result.offers, pagination: buildPaginationMeta(result.total, pagination.page, pagination.limit) });
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/services/offers.service.ts src/routes/admin/quotations.ts
|
|
git commit -m "refactor: extract offers business logic into offers.service.ts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10a: Extract Orders Service
|
|
|
|
**Files:**
|
|
- Create: `src/services/orders.service.ts`
|
|
- Modify: `src/routes/admin/orders.ts`
|
|
|
|
- [ ] **Step 1:** Follow the same pattern as Task 9. Move order CRUD, status transitions, project creation, and item/section management from `src/routes/admin/orders.ts` into `src/services/orders.service.ts`.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/services/orders.service.ts src/routes/admin/orders.ts
|
|
git commit -m "refactor: extract orders business logic into orders.service.ts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10b: Extract Invoices Service
|
|
|
|
**Files:**
|
|
- Create: `src/services/invoices.service.ts`
|
|
- Modify: `src/routes/admin/invoices.ts`
|
|
|
|
- [ ] **Step 1:** Move invoice CRUD, stats calculation, overdue auto-update, and PDF data preparation from `src/routes/admin/invoices.ts` into `src/services/invoices.service.ts`.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/services/invoices.service.ts src/routes/admin/invoices.ts
|
|
git commit -m "refactor: extract invoices business logic into invoices.service.ts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10c: Extract Projects Service
|
|
|
|
**Files:**
|
|
- Create: `src/services/projects.service.ts`
|
|
- Modify: `src/routes/admin/projects.ts`
|
|
|
|
- [ ] **Step 1:** Move project CRUD, notes management from `src/routes/admin/projects.ts` into `src/services/projects.service.ts`.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/services/projects.service.ts src/routes/admin/projects.ts
|
|
git commit -m "refactor: extract projects business logic into projects.service.ts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10d: Extract Users Service
|
|
|
|
**Files:**
|
|
- Create: `src/services/users.service.ts`
|
|
- Modify: `src/routes/admin/users.ts`
|
|
|
|
- [ ] **Step 1:** Move user CRUD, role assignment, password reset from `src/routes/admin/users.ts` into `src/services/users.service.ts`.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/services/users.service.ts src/routes/admin/users.ts
|
|
git commit -m "refactor: extract users business logic into users.service.ts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10e: Extract Attendance Service
|
|
|
|
**Files:**
|
|
- Create: `src/services/attendance.service.ts`
|
|
- Modify: `src/routes/admin/attendance.ts`
|
|
|
|
- [ ] **Step 1:** Move clock in/out, shift management, monthly calculations from `src/routes/admin/attendance.ts` into `src/services/attendance.service.ts`.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/services/attendance.service.ts src/routes/admin/attendance.ts
|
|
git commit -m "refactor: extract attendance business logic into attendance.service.ts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10f: Verify Service Extraction
|
|
|
|
- [ ] **Step 1: Check remaining Prisma calls in routes**
|
|
|
|
```bash
|
|
grep -rn "prisma\." src/routes/admin/ | grep -v "import" | wc -l
|
|
```
|
|
|
|
Goal: minimal direct Prisma calls in the 7 refactored route files (offers, orders, invoices, projects, users, attendance). Other route files (trips, vehicles, leave-requests, bank-accounts, etc.) are intentionally not refactored — they are simpler CRUD with minimal business logic.
|
|
|
|
- [ ] **Step 2: Commit any remaining fixes**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "refactor: finalize service layer extraction"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Testing
|
|
|
|
### Task 11: Testing Infrastructure
|
|
|
|
**Files:**
|
|
- Create: `vitest.config.ts`
|
|
- Create: `.env.test`
|
|
- Create: `src/__tests__/setup.ts`
|
|
- Modify: `package.json`
|
|
|
|
- [ ] **Step 1: Install test dependencies**
|
|
|
|
```bash
|
|
npm install -D vitest supertest @types/supertest
|
|
```
|
|
|
|
- [ ] **Step 2: Create vitest config**
|
|
|
|
Create `vitest.config.ts`:
|
|
|
|
```typescript
|
|
import { defineConfig } from 'vitest/config';
|
|
|
|
export default defineConfig({
|
|
test: {
|
|
globals: true,
|
|
environment: 'node',
|
|
setupFiles: ['./src/__tests__/setup.ts'],
|
|
testTimeout: 15000,
|
|
hookTimeout: 15000,
|
|
},
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Add .env.test to .gitignore and create .env.test.example**
|
|
|
|
Add `.env.test` to `.gitignore`:
|
|
|
|
```
|
|
.env.test
|
|
```
|
|
|
|
Create `.env.test.example` (committed, no real credentials):
|
|
|
|
```env
|
|
DATABASE_URL=mysql://user:password@127.0.0.1:3306/app_test
|
|
JWT_SECRET=test-jwt-secret-do-not-use-in-production
|
|
TOTP_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
APP_ENV=local
|
|
PORT=3099
|
|
HOST=127.0.0.1
|
|
```
|
|
|
|
Then create the actual `.env.test` locally (not committed) with real credentials.
|
|
|
|
- [ ] **Step 4: Create test setup**
|
|
|
|
Create `src/__tests__/setup.ts`:
|
|
|
|
```typescript
|
|
import dotenv from 'dotenv';
|
|
dotenv.config({ path: '.env.test' });
|
|
```
|
|
|
|
- [ ] **Step 5: Add test scripts to package.json**
|
|
|
|
```json
|
|
"test": "vitest run",
|
|
"test:watch": "vitest"
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add vitest.config.ts .env.test.example .gitignore src/__tests__/setup.ts package.json package-lock.json
|
|
git commit -m "chore: add vitest testing infrastructure"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Auth Flow Tests
|
|
|
|
**Files:**
|
|
- Create: `src/__tests__/helpers.ts`
|
|
- Create: `src/__tests__/auth.test.ts`
|
|
|
|
- [ ] **Step 1: Create test helpers**
|
|
|
|
Create `src/__tests__/helpers.ts`:
|
|
|
|
```typescript
|
|
import Fastify from 'fastify';
|
|
import cookie from '@fastify/cookie';
|
|
import rateLimit from '@fastify/rate-limit';
|
|
import authRoutes from '../routes/admin/auth';
|
|
import totpRoutes from '../routes/admin/totp';
|
|
|
|
export async function buildApp() {
|
|
const app = Fastify({ logger: false });
|
|
await app.register(cookie);
|
|
await app.register(rateLimit, { max: 1000, timeWindow: '1 minute' });
|
|
await app.register(authRoutes, { prefix: '/api/admin' });
|
|
await app.register(totpRoutes, { prefix: '/api/admin/totp' });
|
|
return app;
|
|
}
|
|
|
|
export function extractCookie(response: any, name: string): string | undefined {
|
|
const cookies = response.headers['set-cookie'];
|
|
if (!cookies) return undefined;
|
|
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
|
for (const c of arr) {
|
|
if (c.startsWith(`${name}=`)) {
|
|
return c.split(';')[0].split('=')[1];
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write auth tests**
|
|
|
|
Create `src/__tests__/auth.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { buildApp, extractCookie } from './helpers';
|
|
|
|
let app: Awaited<ReturnType<typeof buildApp>>;
|
|
|
|
beforeAll(async () => { app = await buildApp(); });
|
|
afterAll(async () => { await app.close(); });
|
|
|
|
describe('POST /api/admin/login', () => {
|
|
it('returns 401 for invalid credentials', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/login',
|
|
payload: { username: 'nonexistent', password: 'wrong' },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.json().success).toBe(false);
|
|
});
|
|
|
|
it('returns 400 for missing fields', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/login',
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('returns access token and sets refresh cookie on valid login', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/login',
|
|
payload: { username: 'admin', password: 'test-password' },
|
|
});
|
|
// Depends on test DB having an admin user
|
|
if (res.statusCode === 200) {
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.access_token).toBeDefined();
|
|
expect(extractCookie(res, 'refresh_token')).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('POST /api/admin/refresh', () => {
|
|
it('returns 401 without refresh token', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/refresh',
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/admin/logout', () => {
|
|
it('clears refresh token cookie', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/logout',
|
|
});
|
|
// Should succeed even without a valid token
|
|
expect(res.statusCode).toBeLessThan(500);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
```bash
|
|
npm test
|
|
```
|
|
|
|
Expected: tests pass (or skip gracefully if test DB not seeded).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/__tests__/
|
|
git commit -m "test: add auth flow integration tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Permissions & CRUD Tests
|
|
|
|
**Files:**
|
|
- Create: `src/__tests__/permissions.test.ts`
|
|
- Create: `src/__tests__/numbering.test.ts`
|
|
|
|
- [ ] **Step 1: Write permissions tests**
|
|
|
|
Create `src/__tests__/permissions.test.ts` testing:
|
|
- Unauthenticated requests return 401
|
|
- Non-admin without permission returns 403
|
|
- Admin can access all endpoints
|
|
|
|
- [ ] **Step 2: Write numbering tests**
|
|
|
|
Create `src/__tests__/numbering.test.ts` testing:
|
|
- `generateSharedNumber()` returns correct format (YYtypeCode + 4 digits)
|
|
- `generateOfferNumber()` returns correct format (YEAR/PREFIX/NNN)
|
|
- Sequential calls return incrementing numbers
|
|
|
|
- [ ] **Step 3: Run full test suite**
|
|
|
|
```bash
|
|
npm test
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/__tests__/
|
|
git commit -m "test: add permissions and numbering tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: Production Build Preparation
|
|
|
|
### Task 14: Graceful Shutdown
|
|
|
|
**Files:**
|
|
- Modify: `src/server.ts`
|
|
- Modify: `src/config/database.ts`
|
|
|
|
- [ ] **Step 1: Add shutdown handlers**
|
|
|
|
At the end of the `start()` function in `src/server.ts`, after `app.listen()`, add:
|
|
|
|
```typescript
|
|
const shutdown = async (signal: string) => {
|
|
app.log.info(`${signal} received, shutting down gracefully...`);
|
|
try {
|
|
await app.close();
|
|
const { default: prisma } = await import('./config/database');
|
|
await prisma.$disconnect();
|
|
app.log.info('Server shut down successfully');
|
|
process.exit(0);
|
|
} catch (err) {
|
|
app.log.error(err, 'Error during shutdown');
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/server.ts
|
|
git commit -m "feat: add graceful shutdown handling (SIGTERM/SIGINT)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Static File Serving (Production)
|
|
|
|
**Files:**
|
|
- Modify: `src/server.ts`
|
|
|
|
- [ ] **Step 1: Add @fastify/static for production**
|
|
|
|
In `src/server.ts`, after the dev-only Vite block, add production static serving:
|
|
|
|
```typescript
|
|
if (config.isProduction) {
|
|
const fastifyStatic = (await import('@fastify/static')).default;
|
|
await app.register(fastifyStatic, {
|
|
root: path.join(__dirname, '..', 'dist-client'),
|
|
prefix: '/',
|
|
wildcard: false,
|
|
});
|
|
|
|
app.setNotFoundHandler((request, reply) => {
|
|
if (request.url.startsWith('/api/')) {
|
|
return reply.status(404).send({ success: false, error: 'Not found' });
|
|
}
|
|
return reply.sendFile('index.html');
|
|
});
|
|
}
|
|
```
|
|
|
|
Add `import path from 'path'` at the top.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/server.ts
|
|
git commit -m "feat: serve static frontend assets in production via @fastify/static"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Prisma Migration Baseline
|
|
|
|
- [ ] **Step 1: Create baseline migration**
|
|
|
|
```bash
|
|
mkdir -p prisma/migrations/0_init
|
|
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql
|
|
npx prisma migrate resolve --applied 0_init
|
|
```
|
|
|
|
This marks the existing schema as the baseline without running any SQL.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add prisma/migrations/
|
|
git commit -m "chore: create Prisma migration baseline from existing schema"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: TOTP Re-encryption Script
|
|
|
|
**Files:**
|
|
- Create: `scripts/rotate-totp-key.ts`
|
|
|
|
- [ ] **Step 1: Create rotation script**
|
|
|
|
Create `scripts/rotate-totp-key.ts`:
|
|
|
|
```typescript
|
|
import crypto from 'crypto';
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
function decrypt(ciphertext: string, keyHex: string): string {
|
|
const key = Buffer.from(keyHex, 'hex');
|
|
const [ivHex, encHex, tagHex] = ciphertext.split(':');
|
|
const iv = Buffer.from(ivHex, 'hex');
|
|
const encrypted = Buffer.from(encHex, 'hex');
|
|
const tag = Buffer.from(tagHex, 'hex');
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
decipher.setAuthTag(tag);
|
|
return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
|
|
}
|
|
|
|
function encrypt(plaintext: string, keyHex: string): string {
|
|
const key = Buffer.from(keyHex, 'hex');
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`;
|
|
}
|
|
|
|
async function main() {
|
|
const oldKey = process.argv[2];
|
|
const newKey = process.argv[3];
|
|
const dryRun = process.argv.includes('--dry-run');
|
|
|
|
if (!oldKey || !newKey) {
|
|
console.error('Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]');
|
|
process.exit(1);
|
|
}
|
|
|
|
const users = await prisma.users.findMany({
|
|
where: { totp_enabled: true, totp_secret: { not: null } },
|
|
select: { id: true, username: true, totp_secret: true },
|
|
});
|
|
|
|
console.log(`Found ${users.length} users with TOTP enabled`);
|
|
|
|
if (dryRun) {
|
|
for (const user of users) {
|
|
try {
|
|
const decrypted = decrypt(user.totp_secret!, oldKey);
|
|
const reEncrypted = encrypt(decrypted, newKey);
|
|
const verify = decrypt(reEncrypted, newKey);
|
|
console.log(` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`);
|
|
} catch (e) {
|
|
console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`);
|
|
}
|
|
}
|
|
console.log('\nDry run complete. No changes made.');
|
|
return;
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const user of users) {
|
|
const decrypted = decrypt(user.totp_secret!, oldKey);
|
|
const reEncrypted = encrypt(decrypted, newKey);
|
|
await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } });
|
|
console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`);
|
|
}
|
|
});
|
|
|
|
console.log('\nAll TOTP secrets re-encrypted successfully.');
|
|
await prisma.$disconnect();
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|
|
```
|
|
|
|
- [ ] **Step 2: Test with dry run**
|
|
|
|
```bash
|
|
npx tsx scripts/rotate-totp-key.ts <current-key> <new-key> --dry-run
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add scripts/rotate-totp-key.ts
|
|
git commit -m "chore: add TOTP encryption key rotation script"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 18: Final Cleanup
|
|
|
|
- [ ] **Step 1: Check for unused dependencies**
|
|
|
|
```bash
|
|
npx depcheck
|
|
```
|
|
|
|
Review output and remove any unused packages.
|
|
|
|
- [ ] **Step 2: Verify .gitignore completeness**
|
|
|
|
Ensure `dist/`, `dist-client/`, `.env`, `.env.test`, `node_modules/` are all ignored.
|
|
|
|
- [ ] **Step 3: Verify production build works**
|
|
|
|
```bash
|
|
npm run build
|
|
APP_ENV=production node dist/server.js
|
|
```
|
|
|
|
Expected: server starts without errors, serves static files, API responds.
|
|
|
|
- [ ] **Step 4: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: production readiness cleanup"
|
|
```
|