2026-07-21 15:28:55 +08:00
import sharp from 'sharp' ;
2026-07-22 16:41:03 +08:00
import type { ImageWithAnnotations , Note , NoteDetail , ReviewStatus , WorkFeedbackBundle , WorkRound } from '../../shared/types.js' ;
2026-07-21 15:28:55 +08:00
import { notesRepository } from '../repositories/notesRepository.js' ;
import { imagesRepository } from '../repositories/imagesRepository.js' ;
import { annotationsRepository } from '../repositories/annotationsRepository.js' ;
2026-07-21 20:25:52 +08:00
import { database , databaseDialect , withTransaction , type QueryContext } from '../database.js' ;
2026-07-21 15:28:55 +08:00
import { storeUploadedFile } from '../storage.js' ;
2026-07-21 19:23:58 +08:00
import { recalculateCollectionStatus } from './collectionsService.js' ;
2026-07-22 16:41:03 +08:00
import { ensureProjectCompatibilityCollection , recalculateProjectReviewStatus } from './projectsService.js' ;
2026-07-21 15:28:55 +08:00
export interface UploadedFile { filename : string ; originalname? : string ; mimetype? : string ; path : string }
2026-07-22 16:41:03 +08:00
export interface UploadRound { title : string ; description : string ; tags : string [ ] ; files : UploadedFile [ ] }
export interface UrlRound { title : string ; description : string ; tags : string [ ] ; images : string [ ] }
2026-07-21 20:25:52 +08:00
type StoredImage = { url : string ; width : number ; height : number ; storageProvider : 'local' | 'tencent_cos' | 'external' ; storageKey : string } ;
2026-07-22 16:41:03 +08:00
type PreparedRound = { title : string ; description : string ; tags : string [ ] ; images : StoredImage [ ] } ;
2026-07-21 15:28:55 +08:00
async function readImageSize ( filePath : string ) : Promise < { width : number ; height : number } > {
try { const meta = await sharp ( filePath ) . metadata ( ) ; return { width : meta.width ? ? 0 , height : meta.height ? ? 0 } ; }
catch { return { width : 0 , height : 0 } ; }
}
2026-07-21 20:25:52 +08:00
async function prepareFiles ( files : UploadedFile [ ] ) : Promise < StoredImage [ ] > {
2026-07-21 15:28:55 +08:00
return Promise . all ( files . map ( async ( file ) = > ( { . . . ( await readImageSize ( file . path ) ) , . . . ( await storeUploadedFile ( file ) ) } ) ) ) ;
}
2026-07-21 20:25:52 +08:00
function externalImages ( images : string [ ] ) : StoredImage [ ] {
return images . map ( ( url ) = > ( { url , width : 0 , height : 0 , storageProvider : 'external' , storageKey : '' } ) ) ;
}
async function createRoundInTransaction (
tx : QueryContext ,
noteId : number ,
2026-07-22 16:41:03 +08:00
projectId : number ,
2026-07-21 20:25:52 +08:00
collectionId : number ,
2026-07-22 16:41:03 +08:00
round : PreparedRound ,
2026-07-21 20:25:52 +08:00
createdBy : number | undefined ,
fromStatus : ReviewStatus ,
2026-07-22 16:41:03 +08:00
) : Promise < { roundId : number ; roundNumber : number ; versionNumber : number } > {
const note = await tx . one < { active_round_id : number | null } > (
` SELECT active_round_id FROM notes WHERE id = ? ${ databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : '' } ` ,
[ noteId ] ,
) ;
2026-07-21 20:25:52 +08:00
if ( note ? . active_round_id ) {
2026-07-22 16:41:03 +08:00
await tx . execute ( "UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND review_status != 'approved'" , [ note . active_round_id ] ) ;
await tx . execute ( "UPDATE review_rounds SET status = 'completed', completion_reason = 'superseded', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status IN ('draft', 'reviewing')" , [ new Date ( ) . toISOString ( ) , note . active_round_id ] ) ;
2026-07-21 20:25:52 +08:00
}
2026-07-22 16:41:03 +08:00
2026-07-21 20:25:52 +08:00
const maxima = await tx . one < { max_version : number | string | null ; max_round : number | string | null } > (
` SELECT (SELECT MAX(version_number) FROM work_versions WHERE note_id = ?) AS max_version,
( SELECT MAX ( round_number ) FROM review_rounds WHERE note_id = ? ) AS max_round ` ,
[ noteId , noteId ] ,
) ;
2026-07-22 16:41:03 +08:00
const versionNumber = Number ( maxima ? . max_version ? ? 0 ) + 1 ;
2026-07-21 20:25:52 +08:00
const roundNumber = Number ( maxima ? . max_round ? ? 0 ) + 1 ;
const roundId = await tx . insertId (
"INSERT INTO review_rounds (note_id, round_number, status, created_by) VALUES (?, ?, 'reviewing', ?)" ,
[ noteId , roundNumber , createdBy ? ? null ] ,
) ;
2026-07-22 16:41:03 +08:00
await tx . execute (
"INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, review_round_id, candidate_name, candidate_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?, '', 'pending', ?)" ,
[ noteId , versionNumber , round . title , round . description , JSON . stringify ( round . tags ) , roundId , createdBy ? ? null ] ,
) ;
await imagesRepository . createMany ( noteId , round . images , versionNumber , tx ) ;
await tx . execute (
"INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')" ,
[ noteId , versionNumber , fromStatus , '工作台' ] ,
) ;
2026-07-21 20:25:52 +08:00
await tx . execute (
"UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, active_round_id = ?, approved_version_number = NULL, review_status = 'pending' WHERE id = ?" ,
2026-07-22 16:41:03 +08:00
[ round . title , round . description , JSON . stringify ( round . tags ) , versionNumber , roundId , noteId ] ,
2026-07-21 20:25:52 +08:00
) ;
await recalculateCollectionStatus ( collectionId , tx ) ;
2026-07-22 16:41:03 +08:00
await recalculateProjectReviewStatus ( projectId , tx ) ;
return { roundId , roundNumber , versionNumber } ;
}
async function prepareUploadRound ( round : UploadRound ) : Promise < PreparedRound > {
return { title : round.title , description : round.description , tags : round.tags , images : await prepareFiles ( round . files ) } ;
2026-07-21 20:25:52 +08:00
}
2026-07-22 16:41:03 +08:00
function mapRound ( row : Omit < WorkRound , 'tags' > & { tags : string } ) : WorkRound {
return {
. . . row ,
version_number : Number ( row . version_number ) ,
review_round_id : Number ( row . review_round_id ) ,
round_number : Number ( row . round_number ) ,
tags : JSON.parse ( row . tags || '[]' ) as string [ ] ,
} ;
2026-07-21 20:25:52 +08:00
}
2026-07-21 15:28:55 +08:00
export const notesService = {
2026-07-21 18:28:21 +08:00
async list ( query : { sort? : string ; order? : string ; q? : string ; collectionId? : number ; status? : ReviewStatus ; tag? : string ; projectId? : number ; groupId? : number ; externalId? : string } ) {
return notesRepository . list ( { sort : query.sort === 'annotations' ? 'annotations' : 'created_at' , order : query.order === 'asc' ? 'asc' : 'desc' , q : query.q , collectionId : query.collectionId , status : query.status , tag : query.tag , projectId : query.projectId , groupId : query.groupId , externalId : query.externalId } ) ;
2026-07-21 15:28:55 +08:00
} ,
2026-07-22 16:41:03 +08:00
async getDetail ( id : number , requestedVersion? : number , requestedRound? : number ) : Promise < NoteDetail | null > {
2026-07-21 15:28:55 +08:00
const current = await notesRepository . findById ( id ) ;
if ( ! current ) return null ;
2026-07-22 16:41:03 +08:00
const requested = requestedRound
? await database . one < { version_number : number } > ( 'SELECT v.version_number FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id WHERE v.note_id = ? AND r.round_number = ?' , [ id , requestedRound ] )
2026-07-21 15:28:55 +08:00
: undefined ;
2026-07-22 16:41:03 +08:00
const targetVersion = requested ? Number ( requested . version_number ) : requestedVersion ;
const selectedVersion = targetVersion && targetVersion !== current . version_number
? await database . one < { version_number : number ; title : string ; description : string ; tags : string ; review_status : ReviewStatus } > ( 'SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?' , [ id , targetVersion ] )
: undefined ;
if ( targetVersion && targetVersion !== current . version_number && ! selectedVersion ) return null ;
2026-07-21 15:28:55 +08:00
const note : Note = selectedVersion ? { . . . current , . . . selectedVersion , version_number : Number ( selectedVersion . version_number ) , tags : JSON.parse ( selectedVersion . tags || '[]' ) as string [ ] } : current ;
const images = await imagesRepository . listByNote ( id , note . version_number ) ;
2026-07-22 16:41:03 +08:00
const project = await database . one < { id : number ; name : string ; slug : string ; status : NoteDetail [ 'project' ] [ 'status' ] ; review_status : NoteDetail [ 'project' ] [ 'review_status' ] } > ( 'SELECT p.id, p.name, p.slug, p.status, p.review_status FROM notes n JOIN projects p ON p.id = n.project_id WHERE n.id = ?' , [ id ] ) ;
if ( ! project ) return null ;
const roundRows = await database . all < Array < Omit < WorkRound , 'tags' > & { tags : string } > [ number ] > ( ` SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id,
v . created_at , r . round_number , r . status AS round_status , r . completion_reason
2026-07-21 20:25:52 +08:00
FROM work_versions v JOIN review_rounds r ON r . id = v . review_round_id
2026-07-22 16:41:03 +08:00
WHERE v . note_id = ? ORDER BY r . round_number DESC ` , [id]);
2026-07-21 15:28:55 +08:00
const result : NoteDetail = {
. . . note ,
images : [ ] as ImageWithAnnotations [ ] ,
2026-07-22 16:41:03 +08:00
text_annotations : await database . all < NoteDetail [ 'text_annotations' ] [ number ] > ( 'SELECT * FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC' , [ id , note . version_number ] ) ,
comments : await database . all < NoteDetail [ 'comments' ] [ number ] > ( 'SELECT * FROM work_comments WHERE note_id = ? AND version_number = ? ORDER BY id ASC' , [ id , note . version_number ] ) ,
feedback_replies : await database . all < NoteDetail [ 'feedback_replies' ] [ number ] > ( 'SELECT * FROM feedback_replies WHERE note_id = ? AND version_number = ? ORDER BY id ASC' , [ id , note . version_number ] ) ,
rounds : roundRows.map ( mapRound ) ,
review_events : await database . all < NoteDetail [ 'review_events' ] [ number ] > ( 'SELECT * FROM review_events WHERE note_id = ? AND version_number = ? ORDER BY id DESC' , [ id , note . version_number ] ) ,
project : { id : Number ( project . id ) , name : project.name , slug : project.slug , status : project.status , review_status : project.review_status } ,
2026-07-21 15:28:55 +08:00
} ;
for ( const image of images ) result . images . push ( { . . . image , annotations : await annotationsRepository . listByImage ( image . id ) } ) ;
return result ;
} ,
2026-07-22 16:41:03 +08:00
async createInProject ( projectId : number , title : string , description : string , files : UploadedFile [ ] , tags : string [ ] , externalId : string | null = null ) : Promise < Note > {
const prepared = await prepareFiles ( files ) ;
return withTransaction ( async ( tx ) = > {
const collectionId = await ensureProjectCompatibilityCollection ( projectId , tx ) ;
const id = await tx . insertId ( "INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')" , [ projectId , collectionId , externalId , title , description , JSON . stringify ( tags ) ] ) ;
await createRoundInTransaction ( tx , id , projectId , collectionId , { title , description , tags , images : prepared } , undefined , 'draft' ) ;
return ( await notesRepository . findById ( id ) ) ! ;
} ) ;
} ,
async createInProjectFromUrls ( projectId : number , title : string , description : string , imageUrls : string [ ] , tags : string [ ] , externalId : string | null = null ) : Promise < Note > {
return withTransaction ( async ( tx ) = > {
const collectionId = await ensureProjectCompatibilityCollection ( projectId , tx ) ;
const id = await tx . insertId ( "INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')" , [ projectId , collectionId , externalId , title , description , JSON . stringify ( tags ) ] ) ;
await createRoundInTransaction ( tx , id , projectId , collectionId , { title , description , tags , images : externalImages ( imageUrls ) } , undefined , 'draft' ) ;
return ( await notesRepository . findById ( id ) ) ! ;
} ) ;
} ,
2026-07-21 18:28:21 +08:00
async create ( title : string , description : string , files : UploadedFile [ ] , collectionId : number , tags : string [ ] , externalId : string | null = null ) : Promise < Note > {
2026-07-22 16:41:03 +08:00
const collection = await database . one < { project_id : number } > ( 'SELECT project_id FROM collections WHERE id = ?' , [ collectionId ] ) ;
if ( ! collection ) throw new Error ( '作品交付集不存在' ) ;
const projectId = Number ( collection . project_id ) ;
2026-07-21 15:28:55 +08:00
const prepared = await prepareFiles ( files ) ;
2026-07-22 16:41:03 +08:00
return withTransaction ( async ( tx ) = > {
const id = await tx . insertId ( "INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')" , [ projectId , collectionId , externalId , title , description , JSON . stringify ( tags ) ] ) ;
await createRoundInTransaction ( tx , id , projectId , collectionId , { title , description , tags , images : prepared } , undefined , 'draft' ) ;
return ( await notesRepository . findById ( id ) ) ! ;
2026-07-21 15:28:55 +08:00
} ) ;
} ,
2026-07-21 18:28:21 +08:00
async createFromUrls ( title : string , description : string , imageUrls : string [ ] , collectionId : number , tags : string [ ] , externalId : string | null = null ) : Promise < Note > {
2026-07-22 16:41:03 +08:00
const collection = await database . one < { project_id : number } > ( 'SELECT project_id FROM collections WHERE id = ?' , [ collectionId ] ) ;
if ( ! collection ) throw new Error ( '作品交付集不存在' ) ;
const projectId = Number ( collection . project_id ) ;
return withTransaction ( async ( tx ) = > {
const id = await tx . insertId ( "INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')" , [ projectId , collectionId , externalId , title , description , JSON . stringify ( tags ) ] ) ;
await createRoundInTransaction ( tx , id , projectId , collectionId , { title , description , tags , images : externalImages ( imageUrls ) } , undefined , 'draft' ) ;
return ( await notesRepository . findById ( id ) ) ! ;
2026-07-21 18:28:21 +08:00
} ) ;
} ,
2026-07-22 16:41:03 +08:00
async findByExternalId ( collectionId : number , externalId : string ) : Promise < Note | null > { return notesRepository . findByExternalId ( collectionId , externalId ) ; } ,
async findByProjectExternalId ( projectId : number , externalId : string ) : Promise < Note | null > { return notesRepository . findByProjectExternalId ( projectId , externalId ) ; } ,
2026-07-21 18:28:21 +08:00
2026-07-22 16:41:03 +08:00
async createRound ( id : number , round : UploadRound , createdBy? : number ) : Promise < Note > {
2026-07-21 15:28:55 +08:00
const current = await notesRepository . findById ( id ) ;
if ( ! current ) throw new Error ( '作品不存在' ) ;
2026-07-22 16:41:03 +08:00
const prepared = await prepareUploadRound ( round ) ;
await withTransaction ( ( tx ) = > createRoundInTransaction ( tx , id , current . project_id , current . collection_id , prepared , createdBy , current . review_status ) ) ;
2026-07-21 15:28:55 +08:00
return ( await notesRepository . findById ( id ) ) ! ;
} ,
2026-07-22 16:41:03 +08:00
async createRoundFromUrls ( id : number , round : UrlRound , createdBy? : number ) : Promise < Note > {
2026-07-21 18:28:21 +08:00
const current = await notesRepository . findById ( id ) ;
if ( ! current ) throw new Error ( '作品不存在' ) ;
2026-07-22 16:41:03 +08:00
const prepared = { . . . round , images : externalImages ( round . images ) } ;
await withTransaction ( ( tx ) = > createRoundInTransaction ( tx , id , current . project_id , current . collection_id , prepared , createdBy , current . review_status ) ) ;
2026-07-21 18:28:21 +08:00
return ( await notesRepository . findById ( id ) ) ! ;
} ,
2026-07-21 20:25:52 +08:00
async createVersion ( id : number , title : string , description : string , files : UploadedFile [ ] , tags : string [ ] , createdBy? : number ) : Promise < Note > {
2026-07-22 16:41:03 +08:00
return this . createRound ( id , { title , description , tags , files } , createdBy ) ;
2026-07-21 20:25:52 +08:00
} ,
async createVersionFromUrls ( id : number , title : string , description : string , imageUrls : string [ ] , tags : string [ ] , createdBy? : number ) : Promise < Note > {
2026-07-22 16:41:03 +08:00
return this . createRoundFromUrls ( id , { title , description , tags , images : imageUrls } , createdBy ) ;
} ,
async getFeedback ( id : number ) : Promise < WorkFeedbackBundle | null > {
const rounds = await database . all < { round_number : number ; version_number : number } > ( 'SELECT r.round_number, v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id = r.id WHERE r.note_id = ? ORDER BY r.round_number DESC' , [ id ] ) ;
if ( ! rounds . length && ! await notesRepository . findById ( id ) ) return null ;
const imageAnnotations = await database . all < Array < WorkFeedbackBundle [ 'rounds' ] [ number ] [ 'image_annotations' ] [ number ] & { version_number : number } > [ number ] > ( ` SELECT a.*, i.id AS image_id, i.url AS image_url, i.version_number FROM annotations a JOIN images i ON i.id = a.image_id WHERE i.note_id = ? ORDER BY a.id ` , [ id ] ) ;
const textAnnotations = await database . all < Array < NoteDetail [ 'text_annotations' ] [ number ] & { version_number : number } > [ number ] > ( 'SELECT * FROM text_annotations WHERE note_id = ? ORDER BY id' , [ id ] ) ;
const comments = await database . all < NoteDetail [ 'comments' ] [ number ] > ( 'SELECT * FROM work_comments WHERE note_id = ? ORDER BY id' , [ id ] ) ;
const replies = await database . all < NoteDetail [ 'feedback_replies' ] [ number ] > ( 'SELECT * FROM feedback_replies WHERE note_id = ? ORDER BY id' , [ id ] ) ;
const events = await database . all < NoteDetail [ 'review_events' ] [ number ] > ( 'SELECT * FROM review_events WHERE note_id = ? ORDER BY id' , [ id ] ) ;
return {
work_id : id ,
rounds : rounds.map ( ( round ) = > ( {
round_number : Number ( round . round_number ) ,
version_number : Number ( round . version_number ) ,
image_annotations : imageAnnotations.filter ( ( item ) = > Number ( item . version_number ) === Number ( round . version_number ) ) ,
text_annotations : textAnnotations.filter ( ( item ) = > Number ( item . version_number ) === Number ( round . version_number ) ) ,
comments : comments.filter ( ( item ) = > Number ( item . version_number ) === Number ( round . version_number ) ) ,
feedback_replies : replies.filter ( ( item ) = > Number ( item . version_number ) === Number ( round . version_number ) ) ,
review_events : events.filter ( ( item ) = > Number ( item . version_number ) === Number ( round . version_number ) ) ,
} ) ) ,
} ;
2026-07-21 20:25:52 +08:00
} ,
2026-07-21 19:23:58 +08:00
async remove ( id : number ) {
return withTransaction ( async ( tx ) = > {
2026-07-22 16:41:03 +08:00
const note = await tx . one < { collection_id : number ; project_id : number } > ( 'SELECT collection_id, project_id FROM notes WHERE id = ?' , [ id ] ) ;
2026-07-21 19:23:58 +08:00
if ( ! note ) return false ;
const removed = ( await tx . execute ( 'DELETE FROM notes WHERE id = ?' , [ id ] ) ) . changes > 0 ;
2026-07-22 16:41:03 +08:00
if ( removed ) {
await recalculateCollectionStatus ( Number ( note . collection_id ) , tx ) ;
await recalculateProjectReviewStatus ( Number ( note . project_id ) , tx ) ;
}
2026-07-21 19:23:58 +08:00
return removed ;
} ) ;
} ,
2026-07-21 20:25:52 +08:00
2026-07-21 19:23:58 +08:00
async setStatus ( id : number , status : ReviewStatus ) {
return withTransaction ( async ( tx ) = > {
2026-07-22 16:41:03 +08:00
const note = await tx . one < { collection_id : number ; project_id : number ; active_round_id : number | null } > ( 'SELECT collection_id, project_id, active_round_id FROM notes WHERE id = ?' , [ id ] ) ;
2026-07-21 19:23:58 +08:00
if ( ! note ) return false ;
await tx . execute ( 'UPDATE notes SET review_status = ? WHERE id = ?' , [ status , id ] ) ;
2026-07-21 20:25:52 +08:00
if ( note . active_round_id ) {
2026-07-22 16:41:03 +08:00
await tx . execute ( "UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ?" , [ status , status === 'draft' ? 'draft' : 'pending' , note . active_round_id ] ) ;
await tx . execute ( "UPDATE review_rounds SET status = ?, completion_reason = '', selected_version_number = NULL, completed_at = NULL WHERE id = ?" , [ status === 'draft' ? 'draft' : 'reviewing' , note . active_round_id ] ) ;
2026-07-21 20:25:52 +08:00
}
2026-07-21 19:23:58 +08:00
await recalculateCollectionStatus ( Number ( note . collection_id ) , tx ) ;
2026-07-22 16:41:03 +08:00
await recalculateProjectReviewStatus ( Number ( note . project_id ) , tx ) ;
2026-07-21 19:23:58 +08:00
return true ;
} ) ;
} ,
2026-07-21 15:28:55 +08:00
} ;