feat(review): 重构项目级单方案验收协作
This commit is contained in:
@@ -4,30 +4,173 @@ import { createCustomerSession, customerSessionCookie, optionalCustomer, require
|
||||
import { verifyPassword } from '../auth.js';
|
||||
import { notesService } from '../services/notesService.js';
|
||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||
import { decideCandidate, ReviewDecisionError } from '../services/reviewService.js';
|
||||
import type { TextAnnotation, WorkComment } from '../../shared/types.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;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,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 router = Router();
|
||||
router.use(optionalCustomer);
|
||||
|
||||
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})});
|
||||
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;
|
||||
};
|
||||
|
||||
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})});
|
||||
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/project',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collections=await database.all<Record<string,unknown>>(`SELECT c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at ORDER BY c.id DESC`,[project.id]);res.json({project:{id:Number(project.id),name:project.name,slug:project.slug,client_description:project.client_description,status:project.status},collections:collections.map((item)=>({...item,id:Number(item.id),project_id:Number(item.project_id),work_count:Number(item.work_count),approved_count:Number(item.approved_count)})),reviewer_name:req.customer!.reviewer_name})});
|
||||
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.get('/:slug/collections/:collectionId/works',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collectionId=Number(req.params.collectionId);const collection=await database.one<Record<string,unknown>>(`SELECT c.*,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.id=? AND c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at`,[collectionId,project.id]);if(!collection){res.status(404).json({error:'作品交付集不存在或尚未发布'});return}const works=(await notesService.list({collectionId})).filter((work)=>work.review_status!=='draft');res.json({collection:{...collection,id:Number(collection.id),project_id:Number(collection.project_id),work_count:Number(collection.work_count),approved_count:Number(collection.approved_count)},works})});
|
||||
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/works/:noteId',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const belongs=await database.one<{review_status:string}>('SELECT n.review_status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=?',[noteId,project.id]);if(!belongs||belongs.review_status==='draft'){res.status(404).json({error:'作品不存在或尚未提交'});return}const version=req.query.version?Number(req.query.version):undefined;const detail=await notesService.getDetail(noteId,version);if(!detail){res.status(404).json({error:'作品版本不存在'});return}res.json(detail)});
|
||||
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.post('/:slug/works/:noteId/comments',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const content=String(req.body?.content??'').trim();const belongs=await database.one<{status:string;round_status:string}>('SELECT c.status,r.status AS round_status FROM notes n JOIN collections c ON c.id=n.collection_id LEFT JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=? AND c.project_id=? AND n.review_status!=?',[noteId,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品不存在'});return}if(belongs.status==='completed'||belongs.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,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
|
||||
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.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const target=req.body?.target;const content=String(req.body?.content??'').trim();if(!Number.isFinite(versionNumber)||!['title','description'].includes(target)){res.status(400).json({error:'批注目标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 1–1000 个字符'});return}const belongs=await database.one<{status:string;round_status:string;review_round_id:number;active_round_id:number|null}>('SELECT c.status,r.status AS round_status,v.review_round_id,n.active_round_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id JOIN review_rounds r ON r.id=v.review_round_id WHERE v.note_id=? AND v.version_number=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品版本不存在'});return}if(belongs.status==='completed'||belongs.round_status!=='reviewing'||Number(belongs.review_round_id)!==Number(belongs.active_round_id)){res.status(409).json({error:'历史验收轮次为只读状态'});return}const id=await database.insertId('INSERT INTO text_annotations (note_id,version_number,target,content,author_name) VALUES (?,?,?,?,?)',[noteId,versionNumber,target,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?',[id]))});
|
||||
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.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 belongs=await database.one<{status:string;round_status:string;review_round_id:number;active_round_id:number|null}>(`SELECT c.status,r.status AS round_status,v.review_round_id,n.active_round_id FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_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 c.project_id=? AND n.review_status!='draft'`,[imageId,project.id]);if(!belongs){res.status(404).json({error:'图片不存在'});return}if(belongs.status==='completed'||belongs.round_status!=='reviewing'||Number(belongs.review_round_id)!==Number(belongs.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}))});
|
||||
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/:noteId/decision',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);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 decideCandidate({noteId,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}});
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user