177 lines
16 KiB
TypeScript
177 lines
16 KiB
TypeScript
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: '请填写 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<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: '批注内容须为 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<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: '批注内容须为 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;
|