2026-07-21 15:28:55 +08:00
import { Router , type Response } from 'express' ;
2026-07-21 20:25:52 +08:00
import { database } from '../database.js' ;
2026-07-21 15:28:55 +08:00
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' ;
2026-07-22 16:41:03 +08:00
import { decideRound , ReviewDecisionError } from '../services/reviewService.js' ;
import { addFeedbackReply , findFeedbackTarget , withdrawFeedback } from '../services/feedbackService.js' ;
import type { FeedbackType , TextAnnotation , WorkComment } from '../../shared/types.js' ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
const router = Router ( ) ;
router . use ( optionalCustomer ) ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
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 ;
} ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
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 '' ; } } ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
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 } ) ;
} ) ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
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 } ) ;
} ) ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
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 ,
} ) ;
} ) ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
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 } ) ;
} ) ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
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 ) ;
} ) ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
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 ) ) ;
} ) ;
2026-07-21 15:28:55 +08:00
2026-07-22 16:41:03 +08:00
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 ; }
} ) ;
2026-07-21 15:28:55 +08:00
export default router ;