# Hazards MVP 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:** Build the first real end-to-end MVP slice for this repository by adding a minimal Node.js/TypeScript backend plus a working hazards reporting workflow that the existing H5 pages can call. **Architecture:** Create a new `server/` workspace beside the existing `h5/` prototype. Keep the backend intentionally small: config, database access, auth, project isolation middleware, hazards routes, and file upload token support. Convert only the login and hazards H5 pages from mock APIs to real HTTP calls; leave devices, alerts, AI, and construction logs for follow-up plans. **Tech Stack:** Node.js, Express, TypeScript, MySQL 8, Zod, jsonwebtoken, bcryptjs, mysql2, Vitest, Supertest, existing static H5 pages in `h5/` --- ## Planned file structure ### New backend workspace - Create: `server/package.json` - Create: `server/tsconfig.json` - Create: `server/vitest.config.ts` - Create: `server/.env.example` - Create: `server/schema.sql` - Create: `server/src/app.ts` - Create: `server/src/index.ts` - Create: `server/src/config/env.ts` - Create: `server/src/lib/db.ts` - Create: `server/src/lib/jwt.ts` - Create: `server/src/lib/password.ts` - Create: `server/src/middleware/auth.ts` - Create: `server/src/middleware/project.ts` - Create: `server/src/routes/health.ts` - Create: `server/src/routes/auth.ts` - Create: `server/src/routes/files.ts` - Create: `server/src/routes/hazards.ts` - Create: `server/src/repositories/user-repository.ts` - Create: `server/src/repositories/file-repository.ts` - Create: `server/src/repositories/hazard-repository.ts` - Create: `server/src/services/file-service.ts` - Create: `server/src/services/hazard-service.ts` - Create: `server/src/types/auth.ts` - Create: `server/tests/health.test.ts` - Create: `server/tests/auth.test.ts` - Create: `server/tests/hazards.test.ts` - Create: `server/tests/files.test.ts` ### Existing H5 files to modify - Modify: `h5/js/api.js` - Modify: `h5/js/app.js` - Modify: `h5/login.html` - Modify: `h5/report.html` - Modify: `h5/reports.html` - Modify: `h5/report-detail.html` ### Deferred to later plan - `construction_logs` backend and H5 integration - device ingestion / alerts / AI - offline mode --- ### Task 1: Bootstrap the backend workspace **Files:** - Create: `server/package.json` - Create: `server/tsconfig.json` - Create: `server/vitest.config.ts` - Create: `server/src/app.ts` - Create: `server/src/index.ts` - Create: `server/src/routes/health.ts` - Test: `server/tests/health.test.ts` - [ ] **Step 1: Write the failing health test** ```ts // server/tests/health.test.ts import request from 'supertest'; import { describe, expect, it } from 'vitest'; import { createApp } from '../src/app'; describe('GET /health', () => { it('returns ok status', async () => { const app = createApp(); const res = await request(app).get('/health'); expect(res.status).toBe(200); expect(res.body).toEqual({ ok: true }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd server && npm run test -- tests/health.test.ts` Expected: FAIL because `package.json`, `vitest`, or `createApp` does not exist yet - [ ] **Step 3: Create the minimal backend workspace** ```json // server/package.json { "name": "smart-project-server", "private": true, "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "test": "vitest run", "build": "tsc -p tsconfig.json" }, "dependencies": { "express": "^5.1.0" }, "devDependencies": { "@types/express": "^5.0.1", "@types/supertest": "^6.0.3", "supertest": "^7.1.1", "tsx": "^4.19.4", "typescript": "^5.8.3", "vitest": "^3.2.4" } } ``` ```json // server/tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", "rootDir": "." }, "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] } ``` ```ts // server/vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node' } }); ``` ```ts // server/src/routes/health.ts import { Router } from 'express'; export function createHealthRouter() { const router = Router(); router.get('/health', (_req, res) => { res.json({ ok: true }); }); return router; } ``` ```ts // server/src/app.ts import express from 'express'; import { createHealthRouter } from './routes/health'; export function createApp() { const app = express(); app.use(express.json()); app.use(createHealthRouter()); return app; } ``` ```ts // server/src/index.ts import { createApp } from './app'; const app = createApp(); const port = Number(process.env.PORT ?? 3000); app.listen(port, () => { console.log(`server listening on ${port}`); }); ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd server && npm install && npm run test -- tests/health.test.ts` Expected: PASS with `1 passed` - [ ] **Step 5: Commit** ```bash git add server/package.json server/tsconfig.json server/vitest.config.ts server/src/app.ts server/src/index.ts server/src/routes/health.ts server/tests/health.test.ts git commit -m "feat: bootstrap backend workspace" ``` --- ### Task 2: Add config, database access, and schema bootstrap **Files:** - Create: `server/.env.example` - Create: `server/schema.sql` - Create: `server/src/config/env.ts` - Create: `server/src/lib/db.ts` - Modify: `server/src/app.ts` - Test: `server/tests/health.test.ts` - [ ] **Step 1: Add a failing config test expectation inside health** ```ts // append to server/tests/health.test.ts it('returns service metadata', async () => { const app = createApp(); const res = await request(app).get('/health'); expect(res.body.service).toBe('smart-project-server'); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd server && npm run test -- tests/health.test.ts` Expected: FAIL because `service` is missing - [ ] **Step 3: Add env loading and schema bootstrap artifacts** ```env # server/.env.example PORT=3000 MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USER=root MYSQL_PASSWORD=change_me MYSQL_DATABASE=smart_project JWT_SECRET_KEY=change_me OSS_BUCKET=jesxion-ai-studio OSS_REGION=oss-cn-beijing ``` ```sql -- server/schema.sql CREATE TABLE IF NOT EXISTS projects ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL ); CREATE TABLE IF NOT EXISTS users ( id INT PRIMARY KEY AUTO_INCREMENT, project_id INT NOT NULL, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, role VARCHAR(32) NOT NULL, real_name VARCHAR(128), FOREIGN KEY (project_id) REFERENCES projects(id) ); CREATE TABLE IF NOT EXISTS oss_files ( id INT PRIMARY KEY AUTO_INCREMENT, project_id INT NOT NULL, object_key VARCHAR(512) NOT NULL UNIQUE, filename VARCHAR(255) NOT NULL, content_type VARCHAR(128), size BIGINT, category VARCHAR(64) NOT NULL, uploaded_by INT NOT NULL ); CREATE TABLE IF NOT EXISTS hazards ( id VARCHAR(64) PRIMARY KEY, project_id INT NOT NULL, category VARCHAR(32) NOT NULL, severity VARCHAR(32) NOT NULL, description TEXT NOT NULL, photos JSON, status VARCHAR(32) NOT NULL DEFAULT 'pending', reporter_id INT NOT NULL, reporter_role VARCHAR(32) NOT NULL, reported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, assignee_id INT NULL, assignee_role VARCHAR(32) NULL, assigned_at TIMESTAMP NULL, resolver_id INT NULL, resolver_role VARCHAR(32) NULL, resolve_note TEXT NULL, resolved_at TIMESTAMP NULL ); ``` ```ts // server/src/config/env.ts import { z } from 'zod'; const envSchema = z.object({ PORT: z.string().default('3000'), MYSQL_HOST: z.string().default('127.0.0.1'), MYSQL_PORT: z.string().default('3306'), MYSQL_USER: z.string(), MYSQL_PASSWORD: z.string(), MYSQL_DATABASE: z.string(), JWT_SECRET_KEY: z.string(), OSS_BUCKET: z.string(), OSS_REGION: z.string() }); export const env = envSchema.parse(process.env); ``` ```ts // server/src/lib/db.ts import mysql from 'mysql2/promise'; import { env } from '../config/env'; export const db = mysql.createPool({ host: env.MYSQL_HOST, port: Number(env.MYSQL_PORT), user: env.MYSQL_USER, password: env.MYSQL_PASSWORD, database: env.MYSQL_DATABASE, connectionLimit: 10 }); ``` ```ts // server/src/app.ts import express from 'express'; import { createHealthRouter } from './routes/health'; export function createApp() { const app = express(); app.use(express.json()); app.use(createHealthRouter()); return app; } ``` ```ts // server/src/routes/health.ts import { Router } from 'express'; export function createHealthRouter() { const router = Router(); router.get('/health', (_req, res) => { res.json({ ok: true, service: 'smart-project-server' }); }); return router; } ``` - [ ] **Step 4: Install dependencies and run test** Run: `cd server && npm install mysql2 zod && npm run test -- tests/health.test.ts` Expected: PASS with `2 passed` - [ ] **Step 5: Commit** ```bash git add server/.env.example server/schema.sql server/src/config/env.ts server/src/lib/db.ts server/src/routes/health.ts server/tests/health.test.ts git commit -m "feat: add config and database bootstrap" ``` --- ### Task 3: Add login, JWT, and project context middleware **Files:** - Create: `server/src/lib/jwt.ts` - Create: `server/src/lib/password.ts` - Create: `server/src/types/auth.ts` - Create: `server/src/repositories/user-repository.ts` - Create: `server/src/middleware/auth.ts` - Create: `server/src/middleware/project.ts` - Create: `server/src/routes/auth.ts` - Modify: `server/src/app.ts` - Test: `server/tests/auth.test.ts` - [ ] **Step 1: Write the failing auth test** ```ts // server/tests/auth.test.ts import request from 'supertest'; import { describe, expect, it } from 'vitest'; import { createApp } from '../src/app'; describe('POST /v1/auth/login', () => { it('returns token and user payload', async () => { const app = createApp(); const res = await request(app).post('/v1/auth/login').send({ username: 'admin', password: '123456' }); expect(res.status).toBe(200); expect(res.body.token).toBeTypeOf('string'); expect(res.body.user.username).toBe('admin'); expect(res.body.user.project_id).toBeTypeOf('number'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd server && npm run test -- tests/auth.test.ts` Expected: FAIL with 404 or route missing - [ ] **Step 3: Implement minimal auth plumbing** ```ts // server/src/types/auth.ts export type AuthUser = { id: number; username: string; role: string; project_id: number; }; ``` ```ts // server/src/lib/jwt.ts import jwt from 'jsonwebtoken'; import { env } from '../config/env'; import type { AuthUser } from '../types/auth'; export function signAuthToken(user: AuthUser) { return jwt.sign(user, env.JWT_SECRET_KEY, { expiresIn: '7d' }); } export function verifyAuthToken(token: string) { return jwt.verify(token, env.JWT_SECRET_KEY) as AuthUser; } ``` ```ts // server/src/lib/password.ts import bcrypt from 'bcryptjs'; export function comparePassword(raw: string, hash: string) { return bcrypt.compare(raw, hash); } ``` ```ts // server/src/repositories/user-repository.ts import { db } from '../lib/db'; export async function findUserByUsername(username: string) { const [rows] = await db.query( 'SELECT id, username, password_hash, role, project_id FROM users WHERE username = ? LIMIT 1', [username] ); return Array.isArray(rows) ? (rows[0] as any) ?? null : null; } ``` ```ts // server/src/middleware/auth.ts import type { NextFunction, Request, Response } from 'express'; import { verifyAuthToken } from '../lib/jwt'; import type { AuthUser } from '../types/auth'; declare global { namespace Express { interface Request { auth?: AuthUser; } } } export function requireAuth(req: Request, res: Response, next: NextFunction) { const header = req.header('Authorization'); const token = header?.startsWith('Bearer ') ? header.slice(7) : null; if (!token) { return res.status(401).json({ message: 'missing bearer token' }); } try { req.auth = verifyAuthToken(token); next(); } catch { res.status(401).json({ message: 'invalid token' }); } } ``` ```ts // server/src/middleware/project.ts import type { Request } from 'express'; export function getProjectId(req: Request) { if (!req.auth) { throw new Error('auth context missing'); } return req.auth.project_id; } ``` ```ts // server/src/routes/auth.ts import { Router } from 'express'; import { z } from 'zod'; import { signAuthToken } from '../lib/jwt'; import { comparePassword } from '../lib/password'; import { findUserByUsername } from '../repositories/user-repository'; const loginSchema = z.object({ username: z.string().min(1), password: z.string().min(1) }); export function createAuthRouter() { const router = Router(); router.post('/v1/auth/login', async (req, res, next) => { try { const { username, password } = loginSchema.parse(req.body); const user = await findUserByUsername(username); if (!user || !(await comparePassword(password, user.password_hash))) { return res.status(401).json({ message: 'invalid credentials' }); } const authUser = { id: user.id, username: user.username, role: user.role, project_id: user.project_id }; res.json({ token: signAuthToken(authUser), user: authUser }); } catch (error) { next(error); } }); return router; } ``` ```ts // server/src/app.ts import express from 'express'; import { createAuthRouter } from './routes/auth'; import { createHealthRouter } from './routes/health'; export function createApp() { const app = express(); app.use(express.json()); app.use(createHealthRouter()); app.use(createAuthRouter()); return app; } ``` - [ ] **Step 4: Run the auth test** Run: `cd server && npm install jsonwebtoken bcryptjs @types/jsonwebtoken && npm run test -- tests/auth.test.ts` Expected: PASS once a seeded `admin` user exists in the dev database - [ ] **Step 5: Commit** ```bash git add server/src/lib/jwt.ts server/src/lib/password.ts server/src/types/auth.ts server/src/repositories/user-repository.ts server/src/middleware/auth.ts server/src/middleware/project.ts server/src/routes/auth.ts server/src/app.ts server/tests/auth.test.ts git commit -m "feat: add login and auth token flow" ``` --- ### Task 4: Add file upload token endpoint and file indexing **Files:** - Create: `server/src/repositories/file-repository.ts` - Create: `server/src/services/file-service.ts` - Create: `server/src/routes/files.ts` - Modify: `server/src/app.ts` - Test: `server/tests/files.test.ts` - [ ] **Step 1: Write the failing files test** ```ts // server/tests/files.test.ts import request from 'supertest'; import { describe, expect, it } from 'vitest'; import { createApp } from '../src/app'; import { signAuthToken } from '../src/lib/jwt'; describe('POST /v1/files/upload-token', () => { it('returns object key and upload metadata', async () => { const app = createApp(); const token = signAuthToken({ id: 1, username: 'admin', role: '安全员', project_id: 1 }); const res = await request(app) .post('/v1/files/upload-token') .set('Authorization', 'Bearer ' + token) .send({ filename: 'hazard.jpg', contentType: 'image/jpeg', category: 'hazard' }); expect(res.status).toBe(200); expect(res.body.objectKey).toContain('hazard'); expect(res.body.expiresIn).toBe(3600); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd server && npm run test -- tests/files.test.ts` Expected: FAIL with 404 or unauthorized - [ ] **Step 3: Implement token generation and file record creation** ```ts // server/src/services/file-service.ts import { env } from '../config/env'; export function buildUploadToken(input: { category: string; projectId: number; filename: string; }) { const objectKey = `${input.category}/${input.projectId}/${Date.now()}-${input.filename}`; return { objectKey, uploadUrl: `https://${env.OSS_BUCKET}.${env.OSS_REGION}.aliyuncs.com/${objectKey}`, expiresIn: 3600 }; } ``` ```ts // server/src/repositories/file-repository.ts import { db } from '../lib/db'; export async function createFileIndexRecord(input: { projectId: number; objectKey: string; filename: string; contentType: string; category: string; uploadedBy: number; }) { await db.query( 'INSERT INTO oss_files (project_id, object_key, filename, content_type, category, uploaded_by) VALUES (?, ?, ?, ?, ?, ?)', [input.projectId, input.objectKey, input.filename, input.contentType, input.category, input.uploadedBy] ); } ``` ```ts // server/src/routes/files.ts import { Router } from 'express'; import { z } from 'zod'; import { requireAuth } from '../middleware/auth'; import { getProjectId } from '../middleware/project'; import { createFileIndexRecord } from '../repositories/file-repository'; import { buildUploadToken } from '../services/file-service'; const schema = z.object({ filename: z.string().min(1), contentType: z.string().min(1), category: z.literal('hazard') }); export function createFilesRouter() { const router = Router(); router.use(requireAuth); router.post('/v1/files/upload-token', async (req, res, next) => { try { const input = schema.parse(req.body); const token = buildUploadToken({ category: input.category, projectId: getProjectId(req), filename: input.filename }); await createFileIndexRecord({ projectId: getProjectId(req), objectKey: token.objectKey, filename: input.filename, contentType: input.contentType, category: input.category, uploadedBy: req.auth!.id }); res.json(token); } catch (error) { next(error); } }); return router; } ``` ```ts // server/src/app.ts import express from 'express'; import { createAuthRouter } from './routes/auth'; import { createFilesRouter } from './routes/files'; import { createHealthRouter } from './routes/health'; export function createApp() { const app = express(); app.use(express.json()); app.use(createHealthRouter()); app.use(createAuthRouter()); app.use(createFilesRouter()); return app; } ``` - [ ] **Step 4: Run files test** Run: `cd server && npm run test -- tests/files.test.ts` Expected: PASS with `1 passed` - [ ] **Step 5: Commit** ```bash git add server/src/repositories/file-repository.ts server/src/services/file-service.ts server/src/routes/files.ts server/src/app.ts server/tests/files.test.ts git commit -m "feat: add hazard file upload token endpoint" ``` --- ### Task 5: Add hazards create, list, and detail APIs **Files:** - Create: `server/src/repositories/hazard-repository.ts` - Create: `server/src/services/hazard-service.ts` - Create: `server/src/routes/hazards.ts` - Modify: `server/src/app.ts` - Test: `server/tests/hazards.test.ts` - [ ] **Step 1: Write the failing hazards test** ```ts // server/tests/hazards.test.ts import request from 'supertest'; import { describe, expect, it } from 'vitest'; import { createApp } from '../src/app'; import { signAuthToken } from '../src/lib/jwt'; describe('hazards api', () => { it('creates a hazard and returns it in the list', async () => { const app = createApp(); const token = signAuthToken({ id: 1, username: 'admin', role: '安全员', project_id: 1 }); const createRes = await request(app) .post('/v1/hazards') .set('Authorization', 'Bearer ' + token) .send({ category: 'fall', severity: 'general', description: 'A区临边防护缺失', photos: ['hazard/1/test.jpg'] }); expect(createRes.status).toBe(201); expect(createRes.body.status).toBe('pending'); const listRes = await request(app) .get('/v1/hazards') .set('Authorization', 'Bearer ' + token); expect(listRes.status).toBe(200); expect(Array.isArray(listRes.body.items)).toBe(true); expect(listRes.body.items[0].description).toContain('临边防护缺失'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd server && npm run test -- tests/hazards.test.ts` Expected: FAIL with 404 - [ ] **Step 3: Implement minimal hazards persistence** ```ts // server/src/repositories/hazard-repository.ts import { randomUUID } from 'node:crypto'; import { db } from '../lib/db'; export async function createHazard(input: { projectId: number; reporterId: number; reporterRole: string; category: string; severity: string; description: string; photos: string[]; }) { const id = randomUUID(); await db.query( 'INSERT INTO hazards (id, project_id, category, severity, description, photos, reporter_id, reporter_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, input.projectId, input.category, input.severity, input.description, JSON.stringify(input.photos), input.reporterId, input.reporterRole] ); return { id, ...input, status: 'pending' as const }; } export async function listHazards(projectId: number) { const [rows] = await db.query( 'SELECT id, category, severity, description, photos, status, reported_at FROM hazards WHERE project_id = ? ORDER BY reported_at DESC', [projectId] ); return rows as any[]; } export async function getHazardById(projectId: number, id: string) { const [rows] = await db.query( 'SELECT * FROM hazards WHERE project_id = ? AND id = ? LIMIT 1', [projectId, id] ); return Array.isArray(rows) ? (rows[0] as any) ?? null : null; } ``` ```ts // server/src/services/hazard-service.ts import { createHazard, getHazardById, listHazards } from '../repositories/hazard-repository'; export const hazardService = { create: createHazard, list: listHazards, getById: getHazardById }; ``` ```ts // server/src/routes/hazards.ts import { Router } from 'express'; import { z } from 'zod'; import { requireAuth } from '../middleware/auth'; import { getProjectId } from '../middleware/project'; import { hazardService } from '../services/hazard-service'; const createSchema = z.object({ category: z.string().min(1), severity: z.enum(['general', 'serious', 'major']), description: z.string().min(1), photos: z.array(z.string()).default([]) }); export function createHazardsRouter() { const router = Router(); router.use(requireAuth); router.post('/v1/hazards', async (req, res, next) => { try { const input = createSchema.parse(req.body); const hazard = await hazardService.create({ projectId: getProjectId(req), reporterId: req.auth!.id, reporterRole: req.auth!.role, ...input }); res.status(201).json(hazard); } catch (error) { next(error); } }); router.get('/v1/hazards', async (_req, res, next) => { try { const items = await hazardService.list(_req.auth!.project_id); res.json({ items }); } catch (error) { next(error); } }); router.get('/v1/hazards/:id', async (req, res, next) => { try { const item = await hazardService.getById(getProjectId(req), req.params.id); if (!item) return res.status(404).json({ message: 'hazard not found' }); res.json(item); } catch (error) { next(error); } }); return router; } ``` ```ts // server/src/app.ts import express from 'express'; import { createAuthRouter } from './routes/auth'; import { createFilesRouter } from './routes/files'; import { createHazardsRouter } from './routes/hazards'; import { createHealthRouter } from './routes/health'; export function createApp() { const app = express(); app.use(express.json()); app.use(createHealthRouter()); app.use(createAuthRouter()); app.use(createFilesRouter()); app.use(createHazardsRouter()); return app; } ``` - [ ] **Step 4: Run the hazards test** Run: `cd server && npm run test -- tests/hazards.test.ts` Expected: PASS with `1 passed` - [ ] **Step 5: Commit** ```bash git add server/src/repositories/hazard-repository.ts server/src/services/hazard-service.ts server/src/routes/hazards.ts server/src/app.ts server/tests/hazards.test.ts git commit -m "feat: add hazards create list and detail endpoints" ``` --- ### Task 6: Add hazards assign and resolve workflow **Files:** - Modify: `server/src/repositories/hazard-repository.ts` - Modify: `server/src/services/hazard-service.ts` - Modify: `server/src/routes/hazards.ts` - Modify: `server/tests/hazards.test.ts` - [ ] **Step 1: Add failing workflow test** ```ts // append to server/tests/hazards.test.ts it('assigns and resolves a hazard', async () => { const app = createApp(); const token = signAuthToken({ id: 2, username: 'manager', role: '安全负责人', project_id: 1 }); const createRes = await request(app) .post('/v1/hazards') .set('Authorization', 'Bearer ' + token) .send({ category: 'electric', severity: 'serious', description: '配电箱裸露', photos: [] }); const id = createRes.body.id; const assignRes = await request(app) .post(`/v1/hazards/${id}/assign`) .set('Authorization', 'Bearer ' + token); expect(assignRes.status).toBe(200); expect(assignRes.body.status).toBe('assigned'); const resolveRes = await request(app) .post(`/v1/hazards/${id}/resolve`) .set('Authorization', 'Bearer ' + token) .send({ resolveNote: '已加装防护' }); expect(resolveRes.status).toBe(200); expect(resolveRes.body.status).toBe('resolved'); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd server && npm run test -- tests/hazards.test.ts` Expected: FAIL because assign/resolve routes are missing - [ ] **Step 3: Implement status transitions** ```ts // add to server/src/repositories/hazard-repository.ts export async function assignHazard(projectId: number, id: string, assigneeId: number, assigneeRole: string) { await db.query( 'UPDATE hazards SET status = ?, assignee_id = ?, assignee_role = ?, assigned_at = CURRENT_TIMESTAMP WHERE project_id = ? AND id = ? AND status = ?', ['assigned', assigneeId, assigneeRole, projectId, id, 'pending'] ); return getHazardById(projectId, id); } export async function resolveHazard(projectId: number, id: string, resolverId: number, resolverRole: string, resolveNote: string) { await db.query( 'UPDATE hazards SET status = ?, resolver_id = ?, resolver_role = ?, resolve_note = ?, resolved_at = CURRENT_TIMESTAMP WHERE project_id = ? AND id = ? AND status IN (?, ?)', ['resolved', resolverId, resolverRole, resolveNote, projectId, id, 'pending', 'assigned'] ); return getHazardById(projectId, id); } ``` ```ts // add to server/src/services/hazard-service.ts import { assignHazard, createHazard, getHazardById, listHazards, resolveHazard } from '../repositories/hazard-repository'; export const hazardService = { create: createHazard, list: listHazards, getById: getHazardById, assign: assignHazard, resolve: resolveHazard }; ``` ```ts // add to server/src/routes/hazards.ts const resolveSchema = z.object({ resolveNote: z.string().default('') }); router.post('/v1/hazards/:id/assign', async (req, res, next) => { try { const item = await hazardService.assign( getProjectId(req), req.params.id, req.auth!.id, req.auth!.role ); res.json(item); } catch (error) { next(error); } }); router.post('/v1/hazards/:id/resolve', async (req, res, next) => { try { const body = resolveSchema.parse(req.body); const item = await hazardService.resolve( getProjectId(req), req.params.id, req.auth!.id, req.auth!.role, body.resolveNote ); res.json(item); } catch (error) { next(error); } }); ``` - [ ] **Step 4: Run hazards workflow test** Run: `cd server && npm run test -- tests/hazards.test.ts` Expected: PASS with both hazard tests green - [ ] **Step 5: Commit** ```bash git add server/src/repositories/hazard-repository.ts server/src/services/hazard-service.ts server/src/routes/hazards.ts server/tests/hazards.test.ts git commit -m "feat: add hazards assign and resolve flow" ``` --- ### Task 7: Replace mock login and hazards calls in H5 with real API calls **Files:** - Modify: `h5/js/api.js` - Modify: `h5/js/app.js` - Modify: `h5/login.html` - Modify: `h5/report.html` - Modify: `h5/reports.html` - Modify: `h5/report-detail.html` - [ ] **Step 1: Replace the mock API transport** ```js // h5/js/api.js const API_BASE = '/v1'; async function request(path, options) { const token = localStorage.getItem('token'); const headers = Object.assign( { 'Content-Type': 'application/json' }, options && options.headers ? options.headers : {} ); if (token) { headers.Authorization = 'Bearer ' + token; } const res = await fetch(API_BASE + path, { method: options && options.method ? options.method : 'GET', headers, body: options && options.body ? JSON.stringify(options.body) : undefined }); if (!res.ok) { const errorBody = await res.json().catch(function() { return {}; }); throw new Error(errorBody.message || '请求失败'); } return res.json(); } function apiLogin(username, password) { return request('/auth/login', { method: 'POST', body: { username, password } }); } function apiSubmitReport(formData) { return request('/hazards', { method: 'POST', body: formData }); } function apiGetReports() { return request('/hazards'); } function apiGetReportDetail(id) { return request('/hazards/' + id); } function apiAssignReport(id) { return request('/hazards/' + id + '/assign', { method: 'POST' }); } function apiResolveReport(id, resolveNote) { return request('/hazards/' + id + '/resolve', { method: 'POST', body: { resolveNote: resolveNote || '' } }); } ``` - [ ] **Step 2: Normalize auth state helpers** ```js // h5/js/app.js function getToken() { return localStorage.getItem('token'); } function setToken(token) { localStorage.setItem('token', token); } function clearToken() { localStorage.removeItem('token'); } function isLoggedIn() { return !!getToken(); } function requireAuth() { if (!isLoggedIn()) { location.href = 'login.html'; return false; } return true; } ``` - [ ] **Step 3: Update page usage** ```js // login.html inline script change var res = await apiLogin(username, password); setToken(res.token); ``` ```js // report.html inline submit change await apiSubmitReport({ category: category, severity: severity, description: description, photos: uploadedPhotoKeys }); ``` ```js // reports.html inline list change var res = await apiGetReports(); var items = res.items; ``` ```js // report-detail.html inline action change await apiAssignReport(reportId); await apiResolveReport(reportId, noteValue); ``` - [ ] **Step 4: Smoke test the vertical slice** Run: ```bash cd server && npm run dev ``` Then open `h5/login.html` through the same domain/proxy as the backend and verify: - login succeeds - create hazard succeeds - hazards list shows new item - assign succeeds - resolve succeeds Expected: the whole hazard workflow works without using `MOCK_REPORTS` - [ ] **Step 5: Commit** ```bash git add h5/js/api.js h5/js/app.js h5/login.html h5/report.html h5/reports.html h5/report-detail.html git commit -m "feat: connect H5 hazards flow to real backend" ``` --- ## Self-review checklist ### Spec coverage - MVP backend skeleton: covered by Tasks 1-3 - file upload / OSS seam: covered by Task 4 - hazards closed loop: covered by Tasks 5-7 - project isolation foundation: covered by Task 3 and carried forward into hazard tasks - H5 integration: covered by Task 7 - construction logs: intentionally excluded from this plan and should be a follow-up plan ### Placeholder scan - No `TODO`, `TBD`, or “similar to above” placeholders remain in task steps - Commands include exact paths - Code steps contain concrete file content or concrete snippets to add ### Type consistency - Hazard statuses are consistently `pending`, `assigned`, `resolved` - H5 uses `/v1/hazards` and `/v1/auth/login` consistently - Shared `project_id` context is introduced before files and hazard routes depend on it