import { Router, type Response } from 'express'; import { database } from '../database.js'; import { createCustomerSession, customerSessionCookie, optionalCustomer, requireCustomerProject, type CustomerRequest } from '../customerAuth.js'; import { verifyPassword } from '../auth.js'; import { notesService } from '../services/notesService.js'; import { annotationsRepository } from '../repositories/annotationsRepository.js'; import { decideRound, ReviewDecisionError } from '../services/reviewService.js'; import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js'; import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js'; const router = Router(); router.use(optionalCustomer); type ProjectAccess = { id: number; name: string; slug: string; client_description: string; status: string; review_status: string; customer_access_enabled: boolean | number; access_password_hash: string; access_expires_at: string | Date | null; }; const projectBySlug = (slug: string) => database.one( 'SELECT id,name,slug,client_description,status,review_status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?', [slug], ); const expired = (value: string | Date | null) => Boolean(value && new Date(value).getTime() <= Date.now()); const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null; const storedTagsText = (value: string) => { try { const parsed = JSON.parse(value || '[]'); return Array.isArray(parsed) ? parsed.map(String).join(' ') : ''; } catch { return ''; } }; router.get('/:slug/access', async (req: CustomerRequest, res: Response) => { const project = await projectBySlug(req.params.slug); if (!project || project.status === 'archived') { res.status(404).json({ error: '项目不存在' }); return; } res.json({ project_name: project.name, client_description: project.client_description, enabled: Boolean(project.customer_access_enabled), expired: expired(project.access_expires_at), authenticated: Boolean(req.customer?.project_id === Number(project.id)), reviewer_name: req.customer?.project_id === Number(project.id) ? req.customer.reviewer_name : null }); }); router.post('/:slug/login', async (req: CustomerRequest, res: Response) => { const project = await projectBySlug(req.params.slug); const reviewerName = String(req.body?.reviewer_name ?? '').trim(); const password = String(req.body?.password ?? ''); if (!project || project.status === 'archived') { res.status(404).json({ error: '项目不存在' }); return; } if (!project.customer_access_enabled) { res.status(403).json({ error: '该项目暂未开放客户访问' }); return; } if (expired(project.access_expires_at)) { res.status(403).json({ error: '项目访问链接已到期' }); return; } if (reviewerName.length < 2 || reviewerName.length > 30) { res.status(400).json({ error: '请填写 2–30 个字符的姓名' }); return; } if (!project.access_password_hash || !verifyPassword(password, project.access_password_hash)) { res.status(401).json({ error: '访问密码错误' }); return; } const token = await createCustomerSession(Number(project.id), reviewerName); res.setHeader('Set-Cookie', customerSessionCookie(token)); res.json({ success: true, reviewer_name: reviewerName }); }); router.get('/:slug/project', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft'); res.json({ project: { id: Number(project.id), name: project.name, slug: project.slug, client_description: project.client_description, status: project.status, review_status: project.review_status }, works, reviewer_name: req.customer!.reviewer_name, }); }); router.get('/:slug/collections/:collectionId/works', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft'); res.setHeader('Deprecation', 'true'); res.json({ redirect_to: `/review/${project.slug}`, works }); }); router.get('/:slug/works/:workId', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId); const belongs = await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id=? AND project_id=?', [workId, project.id]); if (!belongs || belongs.review_status === 'draft') { res.status(404).json({ error: '作品不存在或尚未提交' }); return; } const round = req.query.round ? Number(req.query.round) : undefined; const version = req.query.version ? Number(req.query.version) : undefined; const detail = await notesService.getDetail(workId, version, round); if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; } res.json(detail); }); router.get('/:slug/works/:workId/annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId); if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=? AND review_status!=?', [workId, project.id, 'draft'])) { res.status(404).json({ error: '作品不存在' }); return; } res.json(await notesService.getFeedback(workId)); }); router.post('/:slug/works/:workId/comments', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId); const content = String(req.body?.content ?? '').trim(); const work = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=? AND n.project_id=? AND n.review_status!=?', [workId, project.id, 'draft']); if (!work) { res.status(404).json({ error: '作品不存在' }); return; } if (project.status !== 'active' || project.review_status === 'completed' || work.round_status !== 'reviewing') { res.status(409).json({ error: '当前轮次、已关闭或已完成项目为只读状态' }); return; } if (!content || content.length > 2000) { res.status(400).json({ error: '反馈内容须为 1–2000 个字符' }); return; } const id = await database.insertId("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?,'client')", [workId, work.version_number, content, req.customer!.reviewer_name]); res.status(201).json(await database.one('SELECT * FROM work_comments WHERE id=?', [id])); }); router.post('/:slug/works/:workId/text-annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId); const roundNumber = Number(req.body?.round_number); const target = req.body?.target as 'title' | 'description' | 'tags'; const startOffset = Number(req.body?.start_offset); const endOffset = Number(req.body?.end_offset); const selectedText = String(req.body?.selected_text ?? ''); const content = String(req.body?.content ?? '').trim(); const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,r.status AS round_status FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id WHERE v.note_id=? AND r.round_number=? AND n.project_id=?`, [workId, roundNumber, project.id]); if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; } if (project.status !== 'active' || project.review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? storedTagsText(version.tags) : ''; if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; } if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 1–1000 个字符' }); return; } const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role) VALUES (?,?,?,?,?,?,?,?,?,?,'client')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, source.slice(Math.max(0, startOffset - 24), startOffset), source.slice(endOffset, endOffset + 24), content, req.customer!.reviewer_name]); res.status(201).json(await database.one('SELECT * FROM text_annotations WHERE id=?', [id])); }); router.post('/:slug/images/:imageId/annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const imageId = Number(req.params.imageId); const { x, y } = req.body ?? {}; const content = String(req.body?.content ?? '').trim(); const context = await database.one<{ review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.review_round_id,n.active_round_id,r.status AS round_status FROM images i JOIN notes n ON n.id=i.note_id JOIN work_versions v ON v.note_id=i.note_id AND v.version_number=i.version_number JOIN review_rounds r ON r.id=v.review_round_id WHERE i.id=? AND n.project_id=? AND n.review_status!='draft'`, [imageId, project.id]); if (!context) { res.status(404).json({ error: '图片不存在' }); return; } if (project.status !== 'active' || project.review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } if (typeof x !== 'number' || typeof y !== 'number' || x < 0 || x > 1 || y < 0 || y > 1) { res.status(400).json({ error: '批注坐标无效' }); return; } if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 1–1000 个字符' }); return; } res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.customer!.reviewer_name, author_role: 'client' })); }); router.post('/:slug/works/:workId/feedback/:type/:feedbackId/replies', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const content = String(req.body?.content ?? '').trim(); if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; } if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; } const target = await findFeedbackTarget(workId, type, feedbackId); const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]); if (!target) { res.status(404).json({ error: '反馈不存在' }); return; } if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 1–1000 个字符' }); return; } res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.customer!.reviewer_name, 'client')); }); router.post('/:slug/works/:workId/feedback/:type/:feedbackId/withdraw', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; } if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; } const target = await findFeedbackTarget(workId, type, feedbackId); if (!target) { res.status(404).json({ error: '反馈不存在' }); return; } const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]); if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } if (target.withdrawn_at) { res.json({ success: true }); return; } if (target.author_role !== 'client' || target.author_name !== req.customer!.reviewer_name) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; } await withdrawFeedback(type, feedbackId); res.json({ success: true }); }); router.post('/:slug/works/:workId/decision', requireCustomerProject, async (req: CustomerRequest, res: Response) => { const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId); const roundNumber = Number(req.body?.round_number); const legacyVersion = Number(req.body?.version_number); const round = Number.isInteger(roundNumber) ? await database.one<{ version_number: number }>('SELECT v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id=r.id WHERE r.note_id=? AND r.round_number=?', [workId, roundNumber]) : undefined; const versionNumber = round ? Number(round.version_number) : legacyVersion; const decision = req.body?.decision; const reason = String(req.body?.reason ?? '').trim(); if (!Number.isInteger(versionNumber) || versionNumber < 1) { res.status(400).json({ error: '验收决定必须明确指定轮次' }); return; } if (!['approved', 'changes_requested'].includes(decision)) { res.status(400).json({ error: '验收决定无效' }); return; } if (reason.length > 2000) { res.status(400).json({ error: '验收原因不能超过 2000 个字符' }); return; } if (decision === 'changes_requested' && !reason) { res.status(400).json({ error: '要求修改时必须填写原因' }); return; } try { res.json(await decideRound({ noteId: workId, versionNumber, projectId: Number(project.id), decision, reason, actorName: req.customer!.reviewer_name, actorRole: 'client' })); } catch (error) { if (error instanceof ReviewDecisionError) { res.status(error.statusCode).json({ error: error.message }); return; } throw error; } }); export default router;