2026-07-21 15:28:55 +08:00
import sharp from 'sharp' ;
2026-07-21 20:25:52 +08:00
import type { ImageWithAnnotations , Note , NoteDetail , ReviewStatus } 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-21 15:28:55 +08:00
export interface UploadedFile { filename : string ; originalname? : string ; mimetype? : string ; path : string }
2026-07-21 20:25:52 +08:00
export interface UploadCandidate { candidate_name : string ; title : string ; description : string ; tags : string [ ] ; files : UploadedFile [ ] }
export interface UrlCandidate { candidate_name : string ; title : string ; description : string ; tags : string [ ] ; images : string [ ] }
type StoredImage = { url : string ; width : number ; height : number ; storageProvider : 'local' | 'tencent_cos' | 'external' ; storageKey : string } ;
type PreparedCandidate = { candidate_name : string ; 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 ,
collectionId : number ,
candidates : PreparedCandidate [ ] ,
createdBy : number | undefined ,
fromStatus : ReviewStatus ,
) : Promise < { roundId : number ; roundNumber : number ; firstVersion : 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 ] ) ;
if ( note ? . active_round_id ) {
await tx . execute ( "UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND candidate_status = 'pending'" , [ note . active_round_id ] ) ;
await tx . execute ( "UPDATE review_rounds SET status = 'completed', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status = 'reviewing'" , [ new Date ( ) . toISOString ( ) , note . active_round_id ] ) ;
}
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 ] ,
) ;
const firstVersion = Number ( maxima ? . max_version ? ? 0 ) + 1 ;
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 ] ,
) ;
for ( let index = 0 ; index < candidates . length ; index += 1 ) {
const candidate = candidates [ index ] ;
const versionNumber = firstVersion + index ;
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 , candidate . title , candidate . description , JSON . stringify ( candidate . tags ) , roundId , candidate . candidate_name , createdBy ? ? null ] ,
) ;
await imagesRepository . createMany ( noteId , candidate . 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 , '工作台' ] ,
) ;
}
const first = candidates [ 0 ] ;
await tx . execute (
"UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, active_round_id = ?, approved_version_number = NULL, review_status = 'pending' WHERE id = ?" ,
[ first . title , first . description , JSON . stringify ( first . tags ) , firstVersion , roundId , noteId ] ,
) ;
await recalculateCollectionStatus ( collectionId , tx ) ;
return { roundId , roundNumber , firstVersion } ;
}
async function prepareUploadCandidates ( candidates : UploadCandidate [ ] ) : Promise < PreparedCandidate [ ] > {
return Promise . all ( candidates . map ( async ( candidate ) = > ( {
candidate_name : candidate.candidate_name ,
title : candidate.title ,
description : candidate.description ,
tags : candidate.tags ,
images : await prepareFiles ( candidate . files ) ,
} ) ) ) ;
}
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
} ,
async getDetail ( id : number , requestedVersion? : number ) : Promise < NoteDetail | null > {
const current = await notesRepository . findById ( id ) ;
if ( ! current ) return null ;
const selectedVersion = requestedVersion && requestedVersion !== 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 , requestedVersion ] )
: undefined ;
if ( requestedVersion && requestedVersion !== current . version_number && ! selectedVersion ) return null ;
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-21 19:23:58 +08:00
const workContext = await database . one < { project_id : number ; project_name : string ; slug : string ; collection_id : number ; collection_name : string ; collection_status : NoteDetail [ 'collection' ] [ 'status' ] } > ( ` SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name, c.status AS collection_status FROM notes n JOIN collections c ON c.id = n.collection_id JOIN projects p ON p.id = c.project_id WHERE n.id = ? ` , [ id ] ) ;
2026-07-21 15:28:55 +08:00
if ( ! workContext ) return null ;
2026-07-21 20:25:52 +08:00
const versionRows = await database . all < Array < Omit < NoteDetail [ 'versions' ] [ number ] , 'tags' > & { tags : string } > [ number ] > ( ` SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id,
v . candidate_name , v . candidate_status , v . created_at , r . round_number , r . status AS round_status , r . selected_version_number
FROM work_versions v JOIN review_rounds r ON r . id = v . review_round_id
WHERE v . note_id = ? ORDER BY r . round_number DESC , v . version_number ASC ` , [id]);
2026-07-21 15:28:55 +08:00
const result : NoteDetail = {
. . . note ,
images : [ ] as ImageWithAnnotations [ ] ,
text_annotations : await database . all < NoteDetail [ 'text_annotations' ] [ number ] > ( 'SELECT id, note_id, version_number, target, content, author_name, status, created_at 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 = ? ORDER BY id ASC' , [ id ] ) ,
2026-07-21 20:25:52 +08:00
versions : versionRows.map ( ( item ) = > ( { . . . item , version_number : Number ( item . version_number ) , review_round_id : Number ( item . review_round_id ) , round_number : Number ( item . round_number ) , selected_version_number : item.selected_version_number == null ? null : Number ( item . selected_version_number ) , tags : JSON.parse ( item . tags || '[]' ) as string [ ] } ) ) ,
2026-07-21 15:28:55 +08:00
review_events : await database . all < NoteDetail [ 'review_events' ] [ number ] > ( 'SELECT * FROM review_events WHERE note_id = ? ORDER BY id DESC' , [ id ] ) ,
project : { id : Number ( workContext . project_id ) , name : workContext.project_name , slug : workContext.slug } ,
2026-07-21 19:23:58 +08:00
collection : { id : Number ( workContext . collection_id ) , name : workContext.collection_name , status : workContext.collection_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-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-21 15:28:55 +08:00
const prepared = await prepareFiles ( files ) ;
const noteId = await withTransaction ( async ( tx ) = > {
2026-07-21 20:25:52 +08:00
const id = await tx . insertId ( "INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')" , [ externalId , title , description , collectionId , JSON . stringify ( tags ) ] ) ;
await createRoundInTransaction ( tx , id , collectionId , [ { candidate_name : '方案 A' , title , description , tags , images : prepared } ] , undefined , 'draft' ) ;
2026-07-21 15:28:55 +08:00
return id ;
} ) ;
return ( await notesRepository . findById ( noteId ) ) ! ;
} ,
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 > {
const noteId = await withTransaction ( async ( tx ) = > {
2026-07-21 20:25:52 +08:00
const id = await tx . insertId ( "INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')" , [ externalId , title , description , collectionId , JSON . stringify ( tags ) ] ) ;
await createRoundInTransaction ( tx , id , collectionId , [ { candidate_name : '方案 A' , title , description , tags , images : externalImages ( imageUrls ) } ] , undefined , 'draft' ) ;
2026-07-21 18:28:21 +08:00
return id ;
} ) ;
return ( await notesRepository . findById ( noteId ) ) ! ;
} ,
async findByExternalId ( collectionId : number , externalId : string ) : Promise < Note | null > {
return notesRepository . findByExternalId ( collectionId , externalId ) ;
} ,
2026-07-21 20:25:52 +08:00
async createReviewRound ( id : number , candidates : UploadCandidate [ ] , 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-21 20:25:52 +08:00
const prepared = await prepareUploadCandidates ( candidates ) ;
await withTransaction ( ( tx ) = > createRoundInTransaction ( tx , id , current . collection_id , prepared , createdBy , current . review_status ) ) ;
2026-07-21 15:28:55 +08:00
return ( await notesRepository . findById ( id ) ) ! ;
} ,
2026-07-21 20:25:52 +08:00
async createReviewRoundFromUrls ( id : number , candidates : UrlCandidate [ ] , 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-21 20:25:52 +08:00
const prepared = candidates . map ( ( candidate ) = > ( { . . . candidate , images : externalImages ( candidate . images ) } ) ) ;
await withTransaction ( ( tx ) = > createRoundInTransaction ( tx , 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 > {
return this . createReviewRound ( id , [ { candidate_name : '方案 A' , title , description , tags , files } ] , createdBy ) ;
} ,
async createVersionFromUrls ( id : number , title : string , description : string , imageUrls : string [ ] , tags : string [ ] , createdBy? : number ) : Promise < Note > {
return this . createReviewRoundFromUrls ( id , [ { candidate_name : '方案 A' , title , description , tags , images : imageUrls } ] , createdBy ) ;
} ,
2026-07-21 19:23:58 +08:00
async remove ( id : number ) {
return withTransaction ( async ( tx ) = > {
const note = await tx . one < { collection_id : number } > ( 'SELECT collection_id FROM notes WHERE id = ?' , [ id ] ) ;
if ( ! note ) return false ;
const removed = ( await tx . execute ( 'DELETE FROM notes WHERE id = ?' , [ id ] ) ) . changes > 0 ;
if ( removed ) await recalculateCollectionStatus ( Number ( note . collection_id ) , tx ) ;
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-21 20:25:52 +08:00
const note = await tx . one < { collection_id : number ; active_round_id : number | null } > ( 'SELECT collection_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 ) {
const candidateStatus = status === 'draft' ? 'draft' : 'pending' ;
await tx . execute ( "UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ? AND candidate_status NOT IN ('selected', 'not_selected')" , [ status , candidateStatus , note . active_round_id ] ) ;
await tx . execute ( 'UPDATE review_rounds SET status = ?, selected_version_number = NULL, completed_at = NULL WHERE id = ?' , [ status === 'draft' ? 'draft' : 'reviewing' , note . active_round_id ] ) ;
}
2026-07-21 19:23:58 +08:00
await recalculateCollectionStatus ( Number ( note . collection_id ) , tx ) ;
return true ;
} ) ;
} ,
2026-07-21 15:28:55 +08:00
} ;