Files
smart-project/docs/superpowers/plans/2026-04-24-hazards-mvp-implementation.md

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_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

// 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/hazards and /v1/auth/login consistently
  • Shared project_id context is introduced before files and hazard routes depend on it