/** * 工作台后两个要素的路由:纪要与汇报(编码规范 §3)。 * * 「五要素同源 —— 都是同一份过程事实在员工视角下的投影。」 * 纪要是唯一的主动动作;汇报是系统代劳的自动产出,员工只确认。 */ import type { Hono } from 'hono'; import type { ApiError, MinuteView, ReportView } from '@contracts'; import { appendResolutionRoute, createMinuteRoute, draftReportRoute, listMinutesRoute, listReportsRoute, registerResolutionRoute, reportPeriodSchema, staffIdSchema, submitReportRoute, } from '@contracts'; import { CallInterfaceViolationError } from '@modules/process'; import type { Minute } from '@modules/minute'; import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/minute'; import type { Report } from '@modules/report'; import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report'; import type { Container } from '../container.ts'; import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; export function registerDeskElementRoutes(app: Hono, container: Container): void { const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' }; /** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */ const toReportView = (report: Report): ReportView => ({ id: report.id, period: report.period, title: report.title, window: report.window, sections: report.draft.sections.map((s) => ({ taskId: s.taskId, title: s.title, activityId: s.activityId, ...(s.terminalLabel === undefined ? {} : { terminalLabel: s.terminalLabel }), lines: [...s.lines], })), metrics: report.draft.metrics, counts: report.draft.counts, ...(report.note === undefined ? {} : { note: report.note }), ...(report.submittedAt === undefined ? {} : { submittedAt: report.submittedAt }), generatedAt: report.generatedAt, }); /* ── 纪要 ─────────────────────────────────────────────────────────── */ async function toMinuteView(minute: Minute): Promise { const author = await container.org.getStaff(staffIdSchema.parse(minute.authorId)).catch(() => undefined); return { id: minute.id, title: minute.title, body: minute.body, authorId: staffIdSchema.parse(minute.authorId), authorName: author?.name ?? '—', participants: [...minute.participants], occurredOn: minute.occurredOn, resolutions: minute.resolutions.map((r) => ({ id: r.id, text: r.text, ...(r.ownerId === undefined ? {} : { ownerId: staffIdSchema.parse(r.ownerId) }), state: r.state, ...(r.workflowId === undefined ? {} : { workflowId: r.workflowId }), ...(r.registeredById === undefined ? {} : { registeredById: staffIdSchema.parse(r.registeredById) }), ...(r.registeredAt === undefined ? {} : { registeredAt: r.registeredAt }), })), createdAt: minute.createdAt, updatedAt: minute.updatedAt, }; } app.get(listMinutesRoute.path, async (c) => { // 纪要是团队沉淀:全员可见,不按负责人过滤 const minutes = await container.minute.list(); return c.json({ minutes: await Promise.all(minutes.map(toMinuteView)) }); }); app.post(createMinuteRoute.path, async (c) => { const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = createMinuteRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '纪要不合协议', detail: parsed.error.issues }; return c.json(body, 400); } const minute = await container.minute.create({ title: parsed.data.title, body: parsed.data.body, authorId: staff.id, occurredOn: parsed.data.occurredOn, participants: parsed.data.participants, resolutions: parsed.data.resolutions, }); return c.json(await toMinuteView(minute)); }); app.post(appendResolutionRoute.path, async (c) => { const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = appendResolutionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '决议不合协议' }; return c.json(body, 400); } try { const minute = await container.minute.appendResolution( c.req.param('minuteId'), parsed.data.text, parsed.data.ownerId, ); return c.json(await toMinuteView(minute)); } catch (error) { if (error instanceof MinuteNotFoundError) { const body: ApiError = { code: 'not-found', message: error.message }; return c.json(body, 404); } throw error; } }); /** 决议 → 登记起单:登记人即来源(OS 3.1) */ app.post(registerResolutionRoute.path, async (c) => { const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = registerResolutionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '登记入参不合法' }; return c.json(body, 400); } try { return c.json( await container.minute.register({ resolutionId: c.req.param('resolutionId'), byId: staff.id, processPackageId: parsed.data.processPackageId, data: parsed.data.data, }), ); } catch (error) { if (error instanceof ResolutionAlreadyRegisteredError) { // 同一决议不重复起单 —— 已经有一条带来源的流在跑了 const body: ApiError = { code: 'precondition-failed', message: error.message }; return c.json(body, 412); } if (error instanceof CallInterfaceViolationError) { // 不合调用接口契约:一条工作流都没建,直接回 400 让登记人补齐 const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.errors }; return c.json(body, 400); } if (error instanceof MinuteNotFoundError) { const body: ApiError = { code: 'not-found', message: error.message }; return c.json(body, 404); } throw error; } }); /* ── 汇报 ─────────────────────────────────────────────────────────── */ app.get(listReportsRoute.path, async (c) => { const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const reports = await container.report.list(staff.id); return c.json({ reports: reports.map(toReportView) }); }); app.get(draftReportRoute.path, async (c) => { const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const period = reportPeriodSchema.safeParse(c.req.param('period')); if (!period.success) { const body: ApiError = { code: 'validation-failed', message: '周期只有日 / 周 / 月 / 年四种' }; return c.json(body, 400); } // 未提交的每次取回都重算 —— 汇报是投影,不是账本 return c.json(toReportView(await container.report.draft(staff.id, period.data))); }); app.post(submitReportRoute.path, async (c) => { const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({}))); try { const report = await container.report.submit( c.req.param('reportId'), parsed.success ? parsed.data.note : undefined, ); return c.json(toReportView(report)); } catch (error) { if (error instanceof ReportAlreadySubmittedError) { const body: ApiError = { code: 'precondition-failed', message: error.message }; return c.json(body, 412); } if (error instanceof ReportNotFoundError) { const body: ApiError = { code: 'not-found', message: error.message }; return c.json(body, 404); } throw error; } }); }