Files
delivery-desk/api/routes/review.ts

177 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ProjectAccess>(
'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: '请填写 230 个字符的姓名' }); 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: '反馈内容须为 12000 个字符' }); 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<WorkComment>('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: '批注内容须为 11000 个字符' }); 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<TextAnnotation>('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: '批注内容须为 11000 个字符' }); 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: '回复内容须为 11000 个字符' }); 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;