32 KiB
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_logsbackend 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
// 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
// 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"
}
}
// 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"]
}
// server/vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node'
}
});
// 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;
}
// 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;
}
// 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
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
// 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
# 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
-- 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
);
// 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);
// 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
});
// 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;
}
// 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
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
// 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
// server/src/types/auth.ts
export type AuthUser = {
id: number;
username: string;
role: string;
project_id: number;
};
// 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;
}
// server/src/lib/password.ts
import bcrypt from 'bcryptjs';
export function comparePassword(raw: string, hash: string) {
return bcrypt.compare(raw, hash);
}
// 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;
}
// 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' });
}
}
// 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;
}
// 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;
}
// 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
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
// 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
// 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
};
}
// 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]
);
}
// 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;
}
// 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
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
// 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
// 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;
}
// server/src/services/hazard-service.ts
import { createHazard, getHazardById, listHazards } from '../repositories/hazard-repository';
export const hazardService = {
create: createHazard,
list: listHazards,
getById: getHazardById
};
// 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;
}
// 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
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
// 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
// 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);
}
// 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
};
// 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
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
// 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
// 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
// login.html inline script change
var res = await apiLogin(username, password);
setToken(res.token);
// report.html inline submit change
await apiSubmitReport({
category: category,
severity: severity,
description: description,
photos: uploadedPhotoKeys
});
// reports.html inline list change
var res = await apiGetReports();
var items = res.items;
// report-detail.html inline action change
await apiAssignReport(reportId);
await apiResolveReport(reportId, noteValue);
- Step 4: Smoke test the vertical slice
Run:
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
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/hazardsand/v1/auth/loginconsistently - Shared
project_idcontext is introduced before files and hazard routes depend on it