2026-07-21 15:28:55 +08:00
import sharp from 'sharp' ;
import type { Note , NoteDetail , ImageWithAnnotations , ReviewStatus } from '../../shared/types.js' ;
import { notesRepository } from '../repositories/notesRepository.js' ;
import { imagesRepository } from '../repositories/imagesRepository.js' ;
import { annotationsRepository } from '../repositories/annotationsRepository.js' ;
import { database , withTransaction } from '../database.js' ;
import { storeUploadedFile } from '../storage.js' ;
export interface UploadedFile { filename : string ; originalname? : string ; mimetype? : string ; path : string }
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 } ; }
}
async function prepareFiles ( files : UploadedFile [ ] ) {
return Promise . all ( files . map ( async ( file ) = > ( { . . . ( await readImageSize ( file . path ) ) , . . . ( await storeUploadedFile ( file ) ) } ) ) ) ;
}
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 ) ;
const workContext = await database . one < { project_id : number ; project_name : string ; slug : string ; collection_id : number ; collection_name : string } > ( ` SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name 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 ] ) ;
if ( ! workContext ) return null ;
const versionRows = await database . all < Array < Omit < NoteDetail [ 'versions' ] [ number ] , 'tags' > & { tags : string } > [ number ] > ( 'SELECT version_number, title, description, tags, review_status, created_at FROM work_versions WHERE note_id = ? ORDER BY version_number DESC' , [ id ] ) ;
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 ] ) ,
versions : versionRows.map ( ( item ) = > ( { . . . item , version_number : Number ( item . version_number ) , tags : JSON.parse ( item . tags || '[]' ) as string [ ] } ) ) ,
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 } ,
collection : { id : Number ( workContext . collection_id ) , name : workContext.collection_name } ,
} ;
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 18:28:21 +08:00
const id = await tx . insertId ( 'INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)' , [ externalId , title , description , collectionId , JSON . stringify ( tags ) , 'pending' ] ) ;
2026-07-21 15:28:55 +08:00
await imagesRepository . createMany ( id , prepared , 1 , tx ) ;
await tx . execute ( "INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')" , [ id , title , description , JSON . stringify ( tags ) ] ) ;
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 ) = > {
const id = await tx . insertId ( 'INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)' , [ externalId , title , description , collectionId , JSON . stringify ( tags ) , 'pending' ] ) ;
await imagesRepository . createMany ( id , imageUrls . map ( ( url ) = > ( { url , width : 0 , height : 0 , storageProvider : 'external' as const , storageKey : '' } ) ) , 1 , tx ) ;
await tx . execute ( "INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')" , [ id , title , description , JSON . stringify ( tags ) ] ) ;
return id ;
} ) ;
return ( await notesRepository . findById ( noteId ) ) ! ;
} ,
async findByExternalId ( collectionId : number , externalId : string ) : Promise < Note | null > {
return notesRepository . findByExternalId ( collectionId , externalId ) ;
} ,
2026-07-21 15:28:55 +08:00
async createVersion ( id : number , title : string , description : string , files : UploadedFile [ ] , tags : string [ ] , createdBy? : number ) : Promise < Note > {
const current = await notesRepository . findById ( id ) ;
if ( ! current ) throw new Error ( '作品不存在' ) ;
const nextVersion = current . version_number + 1 ;
const prepared = await prepareFiles ( files ) ;
await withTransaction ( async ( tx ) = > {
await tx . execute ( "UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, review_status = 'pending' WHERE id = ?" , [ title , description , JSON . stringify ( tags ) , nextVersion , id ] ) ;
await tx . execute ( "INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)" , [ id , nextVersion , title , description , JSON . stringify ( tags ) , createdBy ? ? null ] ) ;
await imagesRepository . createMany ( id , prepared , nextVersion , 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')" , [ id , nextVersion , current . review_status , '工作台' ] ) ;
} ) ;
return ( await notesRepository . findById ( id ) ) ! ;
} ,
2026-07-21 18:28:21 +08:00
async createVersionFromUrls ( id : number , title : string , description : string , imageUrls : string [ ] , tags : string [ ] , createdBy? : number ) : Promise < Note > {
const current = await notesRepository . findById ( id ) ;
if ( ! current ) throw new Error ( '作品不存在' ) ;
const nextVersion = current . version_number + 1 ;
await withTransaction ( async ( tx ) = > {
await tx . execute ( "UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, review_status = 'pending' WHERE id = ?" , [ title , description , JSON . stringify ( tags ) , nextVersion , id ] ) ;
await tx . execute ( "INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)" , [ id , nextVersion , title , description , JSON . stringify ( tags ) , createdBy ? ? null ] ) ;
await imagesRepository . createMany ( id , imageUrls . map ( ( url ) = > ( { url , width : 0 , height : 0 , storageProvider : 'external' as const , storageKey : '' } ) ) , nextVersion , 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')" , [ id , nextVersion , current . review_status , '工作台' ] ) ;
} ) ;
return ( await notesRepository . findById ( id ) ) ! ;
} ,
2026-07-21 15:28:55 +08:00
async remove ( id : number ) { return notesRepository . remove ( id ) ; } ,
async setStatus ( id : number , status : ReviewStatus ) { return notesRepository . setStatus ( id , status ) ; } ,
} ;