2026-07-21 15:28:55 +08:00
import Database from 'better-sqlite3' ;
import path from 'path' ;
import fs from 'fs' ;
import { fileURLToPath } from 'url' ;
import { randomBytes , scryptSync } from 'crypto' ;
const __dirname = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const dataDir = path . resolve ( __dirname , '..' , 'data' ) ;
fs . mkdirSync ( dataDir , { recursive : true } ) ;
export const db = new Database ( path . join ( dataDir , 'app.db' ) ) ;
db . pragma ( 'journal_mode = WAL' ) ;
db . pragma ( 'foreign_keys = ON' ) ;
db . exec ( `
CREATE TABLE IF NOT EXISTS operation_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
name TEXT NOT NULL UNIQUE ,
status TEXT NOT NULL DEFAULT 'active' ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
group_id INTEGER ,
username TEXT NOT NULL UNIQUE ,
display_name TEXT NOT NULL ,
password_hash TEXT NOT NULL ,
role TEXT NOT NULL ,
status TEXT NOT NULL DEFAULT 'active' ,
must_change_password INTEGER NOT NULL DEFAULT 1 ,
last_login_at TEXT ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( group_id ) REFERENCES operation_groups ( id ) ON DELETE RESTRICT
) ;
CREATE UNIQUE INDEX IF NOT EXISTS one_group_admin_per_group ON users ( group_id ) WHERE role = 'group_admin' ;
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user_id INTEGER NOT NULL ,
token_hash TEXT NOT NULL UNIQUE ,
expires_at TEXT NOT NULL ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( user_id ) REFERENCES users ( id ) ON DELETE CASCADE
) ;
CREATE TABLE IF NOT EXISTS customer_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
project_id INTEGER NOT NULL ,
reviewer_name TEXT NOT NULL ,
token_hash TEXT NOT NULL UNIQUE ,
expires_at TEXT NOT NULL ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( project_id ) REFERENCES projects ( id ) ON DELETE CASCADE
) ;
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
group_id INTEGER ,
user_id INTEGER ,
action TEXT NOT NULL ,
entity_type TEXT NOT NULL ,
entity_id INTEGER ,
detail TEXT NOT NULL DEFAULT '{}' ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
group_id INTEGER ,
project_id INTEGER ,
name TEXT NOT NULL ,
key_prefix TEXT NOT NULL UNIQUE ,
key_hash TEXT NOT NULL UNIQUE ,
scope TEXT NOT NULL ,
status TEXT NOT NULL DEFAULT 'active' ,
created_by INTEGER NOT NULL ,
last_used_at TEXT ,
revoked_at TEXT ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( group_id ) REFERENCES operation_groups ( id ) ON DELETE RESTRICT ,
FOREIGN KEY ( project_id ) REFERENCES projects ( id ) ON DELETE CASCADE ,
FOREIGN KEY ( created_by ) REFERENCES users ( id ) ON DELETE RESTRICT
) ;
CREATE TABLE IF NOT EXISTS storage_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
provider TEXT NOT NULL DEFAULT 'tencent_cos' ,
region TEXT NOT NULL ,
bucket TEXT NOT NULL ,
public_base_url TEXT NOT NULL DEFAULT '' ,
cdn_domain TEXT NOT NULL DEFAULT '' ,
path_prefix TEXT NOT NULL DEFAULT 'delivery-desk' ,
secret_id_encrypted TEXT NOT NULL ,
secret_key_encrypted TEXT NOT NULL ,
status TEXT NOT NULL DEFAULT 'draft' ,
test_status TEXT NOT NULL DEFAULT 'untested' ,
test_message TEXT NOT NULL DEFAULT '' ,
last_tested_at TEXT ,
created_by INTEGER NOT NULL ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
activated_at TEXT ,
FOREIGN KEY ( created_by ) REFERENCES users ( id ) ON DELETE RESTRICT
) ;
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
name TEXT NOT NULL ,
slug TEXT NOT NULL UNIQUE ,
client_description TEXT NOT NULL DEFAULT '' ,
status TEXT NOT NULL DEFAULT 'active' ,
2026-07-22 16:41:03 +08:00
review_status TEXT NOT NULL DEFAULT 'draft' ,
review_completed_at TEXT ,
2026-07-21 15:28:55 +08:00
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
project_id INTEGER NOT NULL ,
name TEXT NOT NULL ,
client_description TEXT NOT NULL DEFAULT '' ,
2026-07-21 19:23:58 +08:00
status TEXT NOT NULL DEFAULT 'draft' ,
completed_at TEXT ,
2026-07-21 15:28:55 +08:00
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( project_id ) REFERENCES projects ( id ) ON DELETE CASCADE
) ;
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
2026-07-22 16:41:03 +08:00
project_id INTEGER ,
2026-07-21 18:28:21 +08:00
external_id TEXT ,
2026-07-21 15:28:55 +08:00
title TEXT NOT NULL ,
description TEXT NOT NULL DEFAULT '' ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
note_id INTEGER NOT NULL ,
url TEXT NOT NULL ,
width INTEGER NOT NULL DEFAULT 0 ,
height INTEGER NOT NULL DEFAULT 0 ,
order_index INTEGER NOT NULL DEFAULT 0 ,
FOREIGN KEY ( note_id ) REFERENCES notes ( id ) ON DELETE CASCADE
) ;
CREATE TABLE IF NOT EXISTS annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
image_id INTEGER NOT NULL ,
x REAL NOT NULL ,
y REAL NOT NULL ,
content TEXT NOT NULL ,
2026-07-22 16:41:03 +08:00
author_role TEXT NOT NULL DEFAULT 'client' ,
status TEXT NOT NULL DEFAULT 'open' ,
closure_reason TEXT NOT NULL DEFAULT '' ,
withdrawn_at TEXT ,
2026-07-21 15:28:55 +08:00
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( image_id ) REFERENCES images ( id ) ON DELETE CASCADE
) ;
CREATE TABLE IF NOT EXISTS work_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
note_id INTEGER NOT NULL ,
2026-07-22 16:41:03 +08:00
version_number INTEGER NOT NULL DEFAULT 1 ,
2026-07-21 15:28:55 +08:00
content TEXT NOT NULL ,
author_name TEXT NOT NULL DEFAULT '客户' ,
author_role TEXT NOT NULL DEFAULT 'client' ,
status TEXT NOT NULL DEFAULT 'open' ,
2026-07-22 16:41:03 +08:00
closure_reason TEXT NOT NULL DEFAULT '' ,
withdrawn_at TEXT ,
2026-07-21 15:28:55 +08:00
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( note_id ) REFERENCES notes ( id ) ON DELETE CASCADE
) ;
CREATE TABLE IF NOT EXISTS text_annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
note_id INTEGER NOT NULL ,
version_number INTEGER NOT NULL ,
target TEXT NOT NULL ,
2026-07-22 16:41:03 +08:00
start_offset INTEGER NOT NULL DEFAULT 0 ,
end_offset INTEGER NOT NULL DEFAULT 0 ,
selected_text TEXT NOT NULL DEFAULT '' ,
prefix_text TEXT NOT NULL DEFAULT '' ,
suffix_text TEXT NOT NULL DEFAULT '' ,
2026-07-21 15:28:55 +08:00
content TEXT NOT NULL ,
author_name TEXT NOT NULL DEFAULT '客户' ,
2026-07-22 16:41:03 +08:00
author_role TEXT NOT NULL DEFAULT 'client' ,
2026-07-21 15:28:55 +08:00
status TEXT NOT NULL DEFAULT 'open' ,
2026-07-22 16:41:03 +08:00
closure_reason TEXT NOT NULL DEFAULT '' ,
withdrawn_at TEXT ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( note_id ) REFERENCES notes ( id ) ON DELETE CASCADE
) ;
CREATE TABLE IF NOT EXISTS feedback_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
note_id INTEGER NOT NULL ,
version_number INTEGER NOT NULL ,
feedback_type TEXT NOT NULL ,
feedback_id INTEGER NOT NULL ,
content TEXT NOT NULL ,
author_name TEXT NOT NULL ,
author_role TEXT NOT NULL ,
withdrawn_at TEXT ,
2026-07-21 15:28:55 +08:00
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( note_id ) REFERENCES notes ( id ) ON DELETE CASCADE
) ;
CREATE TABLE IF NOT EXISTS work_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
note_id INTEGER NOT NULL ,
version_number INTEGER NOT NULL ,
title TEXT NOT NULL ,
description TEXT NOT NULL DEFAULT '' ,
tags TEXT NOT NULL DEFAULT '[]' ,
review_status TEXT NOT NULL DEFAULT 'pending' ,
created_by INTEGER ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( note_id , version_number ) ,
FOREIGN KEY ( note_id ) REFERENCES notes ( id ) ON DELETE CASCADE ,
FOREIGN KEY ( created_by ) REFERENCES users ( id ) ON DELETE SET NULL
) ;
2026-07-21 20:25:52 +08:00
CREATE TABLE IF NOT EXISTS review_rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
note_id INTEGER NOT NULL ,
round_number INTEGER NOT NULL ,
status TEXT NOT NULL DEFAULT 'reviewing' ,
selected_version_number INTEGER ,
created_by INTEGER ,
completed_at TEXT ,
2026-07-22 16:41:03 +08:00
completion_reason TEXT NOT NULL DEFAULT '' ,
2026-07-21 20:25:52 +08:00
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( note_id , round_number ) ,
FOREIGN KEY ( note_id ) REFERENCES notes ( id ) ON DELETE CASCADE ,
FOREIGN KEY ( created_by ) REFERENCES users ( id ) ON DELETE SET NULL
) ;
2026-07-21 15:28:55 +08:00
CREATE TABLE IF NOT EXISTS review_events (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
note_id INTEGER NOT NULL ,
version_number INTEGER NOT NULL ,
event_type TEXT NOT NULL ,
from_status TEXT ,
to_status TEXT NOT NULL ,
reason TEXT NOT NULL DEFAULT '' ,
actor_name TEXT NOT NULL ,
actor_role TEXT NOT NULL ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
FOREIGN KEY ( note_id ) REFERENCES notes ( id ) ON DELETE CASCADE
) ;
` );
function addColumn ( table : string , definition : string ) {
const name = definition . split ( /\s+/ ) [ 0 ] ;
const columns = db . prepare ( ` PRAGMA table_info( ${ table } ) ` ) . all ( ) as { name : string } [ ] ;
if ( ! columns . some ( ( column ) = > column . name === name ) ) {
db . exec ( ` ALTER TABLE ${ table } ADD COLUMN ${ definition } ` ) ;
}
}
addColumn ( 'notes' , "collection_id INTEGER" ) ;
2026-07-22 16:41:03 +08:00
addColumn ( 'notes' , 'project_id INTEGER' ) ;
2026-07-21 18:28:21 +08:00
addColumn ( 'notes' , 'external_id TEXT' ) ;
2026-07-21 15:28:55 +08:00
addColumn ( 'notes' , "tags TEXT NOT NULL DEFAULT '[]'" ) ;
addColumn ( 'notes' , "review_status TEXT NOT NULL DEFAULT 'pending'" ) ;
addColumn ( 'notes' , 'version_number INTEGER NOT NULL DEFAULT 1' ) ;
2026-07-21 20:25:52 +08:00
addColumn ( 'notes' , 'active_round_id INTEGER' ) ;
addColumn ( 'notes' , 'approved_version_number INTEGER' ) ;
2026-07-21 15:28:55 +08:00
addColumn ( 'annotations' , "author_name TEXT NOT NULL DEFAULT '客户'" ) ;
addColumn ( 'annotations' , "status TEXT NOT NULL DEFAULT 'open'" ) ;
2026-07-22 16:41:03 +08:00
addColumn ( 'annotations' , "author_role TEXT NOT NULL DEFAULT 'client'" ) ;
addColumn ( 'annotations' , 'withdrawn_at TEXT' ) ;
addColumn ( 'annotations' , "closure_reason TEXT NOT NULL DEFAULT ''" ) ;
2026-07-21 15:28:55 +08:00
addColumn ( 'projects' , 'group_id INTEGER' ) ;
2026-07-22 16:41:03 +08:00
addColumn ( 'projects' , "review_status TEXT NOT NULL DEFAULT 'draft'" ) ;
addColumn ( 'projects' , 'review_completed_at TEXT' ) ;
2026-07-21 15:28:55 +08:00
addColumn ( 'projects' , "access_password_hash TEXT NOT NULL DEFAULT ''" ) ;
addColumn ( 'projects' , 'customer_access_enabled INTEGER NOT NULL DEFAULT 0' ) ;
addColumn ( 'projects' , 'access_expires_at TEXT' ) ;
addColumn ( 'images' , "storage_provider TEXT NOT NULL DEFAULT 'local'" ) ;
addColumn ( 'images' , "storage_key TEXT NOT NULL DEFAULT ''" ) ;
addColumn ( 'images' , 'version_number INTEGER NOT NULL DEFAULT 1' ) ;
addColumn ( 'users' , 'last_login_at TEXT' ) ;
2026-07-21 19:23:58 +08:00
addColumn ( 'collections' , 'completed_at TEXT' ) ;
2026-07-22 16:41:03 +08:00
addColumn ( 'work_comments' , 'version_number INTEGER NOT NULL DEFAULT 1' ) ;
addColumn ( 'work_comments' , 'withdrawn_at TEXT' ) ;
addColumn ( 'work_comments' , "closure_reason TEXT NOT NULL DEFAULT ''" ) ;
addColumn ( 'text_annotations' , 'start_offset INTEGER NOT NULL DEFAULT 0' ) ;
addColumn ( 'text_annotations' , 'end_offset INTEGER NOT NULL DEFAULT 0' ) ;
addColumn ( 'text_annotations' , "selected_text TEXT NOT NULL DEFAULT ''" ) ;
addColumn ( 'text_annotations' , "prefix_text TEXT NOT NULL DEFAULT ''" ) ;
addColumn ( 'text_annotations' , "suffix_text TEXT NOT NULL DEFAULT ''" ) ;
addColumn ( 'text_annotations' , "author_role TEXT NOT NULL DEFAULT 'client'" ) ;
addColumn ( 'text_annotations' , 'withdrawn_at TEXT' ) ;
addColumn ( 'text_annotations' , "closure_reason TEXT NOT NULL DEFAULT ''" ) ;
2026-07-21 20:25:52 +08:00
addColumn ( 'work_versions' , 'review_round_id INTEGER' ) ;
addColumn ( 'work_versions' , "candidate_name TEXT NOT NULL DEFAULT '方案 A'" ) ;
addColumn ( 'work_versions' , "candidate_status TEXT NOT NULL DEFAULT 'pending'" ) ;
2026-07-22 16:41:03 +08:00
addColumn ( 'review_rounds' , "completion_reason TEXT NOT NULL DEFAULT ''" ) ;
2026-07-21 15:28:55 +08:00
2026-07-21 18:28:21 +08:00
db . exec ( "CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''" ) ;
2026-07-21 15:28:55 +08:00
const seedGroup = db . prepare ( 'SELECT id FROM operation_groups ORDER BY id LIMIT 1' ) . get ( ) as { id : number } | undefined ;
let groupId = seedGroup ? . id ;
if ( ! groupId ) {
groupId = Number ( db . prepare ( 'INSERT INTO operation_groups (name) VALUES (?)' ) . run ( '默认运营组' ) . lastInsertRowid ) ;
}
function passwordHash ( password : string ) : string {
const salt = randomBytes ( 16 ) . toString ( 'hex' ) ;
return ` ${ salt } : ${ scryptSync ( password , salt , 64 ) . toString ( 'hex' ) } ` ;
}
const userCount = ( db . prepare ( 'SELECT COUNT(*) AS count FROM users' ) . get ( ) as { count : number } ) . count ;
if ( userCount === 0 ) {
const insert = db . prepare ( 'INSERT INTO users (group_id, username, display_name, password_hash, role) VALUES (?, ?, ?, ?, ?)' ) ;
insert . run ( null , 'admin' , '平台管理员' , passwordHash ( process . env . INITIAL_ADMIN_PASSWORD || 'ChangeMe123!' ) , 'platform_admin' ) ;
insert . run ( groupId , 'manager' , '组管理员' , passwordHash ( process . env . INITIAL_MANAGER_PASSWORD || 'Manager123!' ) , 'group_admin' ) ;
insert . run ( groupId , 'operator' , '光影叙事' , passwordHash ( process . env . INITIAL_OPERATOR_PASSWORD || 'Operator123!' ) , 'operator' ) ;
}
db . prepare ( "UPDATE operation_groups SET name = '默认运营组' WHERE name IN ('默认内容团队', '默认创作空间')" ) . run ( ) ;
db . prepare ( "UPDATE users SET display_name = '组管理员' WHERE username = 'manager' AND display_name IN ('团队管理员', '空间管理员')" ) . run ( ) ;
db . prepare ( "UPDATE users SET display_name = '光影叙事' WHERE username = 'operator' AND display_name IN ('内容运营', '内容专员', '内容编辑')" ) . run ( ) ;
const seedProject = db . prepare ( 'SELECT id FROM projects ORDER BY id LIMIT 1' ) . get ( ) as { id : number } | undefined ;
let projectId = seedProject ? . id ;
if ( ! projectId ) {
projectId = Number ( db . prepare ( ` INSERT INTO projects (name, slug, client_description) VALUES (?, ?, ?) ` )
. run ( '光影内容计划' , 'light-notes' , '阶段作品、反馈与验收记录' ) . lastInsertRowid ) ;
}
const seedCollection = db . prepare ( 'SELECT id FROM collections WHERE project_id = ? ORDER BY id LIMIT 1' ) . get ( projectId ) as { id : number } | undefined ;
let collectionId = seedCollection ? . id ;
if ( ! collectionId ) {
collectionId = Number ( db . prepare ( ` INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?) ` )
. run ( projectId , '2026 年 7 月任务' , '本月内容作品交付集' ) . lastInsertRowid ) ;
}
db . prepare ( 'UPDATE notes SET collection_id = ? WHERE collection_id IS NULL' ) . run ( collectionId ) ;
db . prepare ( 'UPDATE projects SET group_id = ? WHERE group_id IS NULL' ) . run ( groupId ) ;
2026-07-22 16:41:03 +08:00
db . prepare ( 'UPDATE notes SET project_id = (SELECT c.project_id FROM collections c WHERE c.id = notes.collection_id) WHERE project_id IS NULL' ) . run ( ) ;
db . prepare ( 'UPDATE work_comments SET version_number = (SELECT n.version_number FROM notes n WHERE n.id = work_comments.note_id) WHERE version_number IS NULL OR version_number < 1' ) . run ( ) ;
2026-07-21 15:28:55 +08:00
db . prepare ( ` UPDATE collections SET name = '2026 年 7 月任务', client_description = '本月内容作品交付集'
WHERE project_id = ( SELECT id FROM projects WHERE slug = 'light-notes' ) AND ( name LIKE '%?%' OR client_description LIKE '%?%' ) ` ).run();
db . prepare ( ` INSERT OR IGNORE INTO work_versions (note_id, version_number, title, description, tags, review_status)
SELECT id , version_number , title , description , tags , review_status FROM notes ` ).run();
2026-07-21 20:25:52 +08:00
db . exec ( `
INSERT OR IGNORE INTO review_rounds ( note_id , round_number , status , selected_version_number , completed_at , created_at )
SELECT v . note_id , v . version_number ,
CASE WHEN v . version_number = n . version_number AND n . review_status != 'approved' THEN 'reviewing' ELSE 'completed' END ,
CASE WHEN v . review_status = 'approved' THEN v . version_number ELSE NULL END ,
CASE WHEN v . version_number = n . version_number AND n . review_status != 'approved' THEN NULL ELSE v . created_at END ,
v . created_at
FROM work_versions v JOIN notes n ON n . id = v . note_id ;
UPDATE work_versions
SET review_round_id = ( SELECT r . id FROM review_rounds r WHERE r . note_id = work_versions . note_id AND r . round_number = work_versions . version_number ) ,
candidate_name = COALESCE ( NULLIF ( candidate_name , '' ) , '方案 A' ) ,
candidate_status = CASE
WHEN review_status = 'approved' THEN 'selected'
WHEN version_number = ( SELECT version_number FROM notes WHERE id = work_versions . note_id ) AND review_status = 'changes_requested' THEN 'changes_requested'
WHEN version_number = ( SELECT version_number FROM notes WHERE id = work_versions . note_id ) AND review_status = 'pending' THEN 'pending'
WHEN version_number = ( SELECT version_number FROM notes WHERE id = work_versions . note_id ) AND review_status = 'draft' THEN 'draft'
ELSE 'not_selected'
END
WHERE review_round_id IS NULL ;
UPDATE notes
SET active_round_id = ( SELECT r . id FROM review_rounds r WHERE r . note_id = notes . id AND r . round_number = notes . version_number ) ,
approved_version_number = ( SELECT MAX ( v . version_number ) FROM work_versions v WHERE v . note_id = notes . id AND v . review_status = 'approved' )
WHERE active_round_id IS NULL ;
` );
2026-07-22 16:41:03 +08:00
db . exec ( `
UPDATE projects
SET review_status = CASE
WHEN status = 'archived' THEN 'archived'
WHEN NOT EXISTS ( SELECT 1 FROM notes n WHERE n . project_id = projects . id AND n . review_status != 'draft' ) THEN 'draft'
WHEN NOT EXISTS ( SELECT 1 FROM notes n WHERE n . project_id = projects . id AND n . review_status != 'draft' AND n . review_status != 'approved' ) THEN 'completed'
ELSE 'reviewing'
END ,
review_completed_at = CASE
WHEN status != 'archived'
AND EXISTS ( SELECT 1 FROM notes n WHERE n . project_id = projects . id AND n . review_status != 'draft' )
AND NOT EXISTS ( SELECT 1 FROM notes n WHERE n . project_id = projects . id AND n . review_status != 'draft' AND n . review_status != 'approved' )
THEN COALESCE ( review_completed_at , datetime ( 'now' ) )
ELSE NULL
END ;
` );
2026-07-21 19:23:58 +08:00
db . exec ( `
UPDATE collections
SET status = CASE
WHEN NOT EXISTS ( SELECT 1 FROM notes n WHERE n . collection_id = collections . id AND n . review_status != 'draft' ) THEN 'draft'
WHEN NOT EXISTS ( SELECT 1 FROM notes n WHERE n . collection_id = collections . id AND n . review_status != 'draft' AND n . review_status != 'approved' ) THEN 'completed'
ELSE 'reviewing'
END ,
completed_at = CASE
WHEN EXISTS ( SELECT 1 FROM notes n WHERE n . collection_id = collections . id AND n . review_status != 'draft' )
AND NOT EXISTS ( SELECT 1 FROM notes n WHERE n . collection_id = collections . id AND n . review_status != 'draft' AND n . review_status != 'approved' )
THEN COALESCE ( completed_at , datetime ( 'now' ) )
ELSE NULL
END
WHERE status != 'archived' ;
` );
2026-07-21 15:28:55 +08:00
db . exec ( `
CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections ( project_id ) ;
CREATE INDEX IF NOT EXISTS idx_notes_collection_id ON notes ( collection_id ) ;
2026-07-22 16:41:03 +08:00
CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes ( project_id ) ;
2026-07-21 15:28:55 +08:00
CREATE INDEX IF NOT EXISTS idx_images_note_id ON images ( note_id ) ;
CREATE INDEX IF NOT EXISTS idx_annotations_image_id ON annotations ( image_id ) ;
CREATE INDEX IF NOT EXISTS idx_comments_note_id ON work_comments ( note_id ) ;
CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations ( note_id , version_number ) ;
2026-07-22 16:41:03 +08:00
CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies ( note_id , version_number , feedback_type , feedback_id ) ;
2026-07-21 15:28:55 +08:00
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions ( token_hash ) ;
CREATE INDEX IF NOT EXISTS idx_customer_sessions_token_hash ON customer_sessions ( token_hash ) ;
CREATE INDEX IF NOT EXISTS idx_customer_sessions_project_id ON customer_sessions ( project_id ) ;
CREATE INDEX IF NOT EXISTS idx_audit_group_id ON audit_logs ( group_id ) ;
CREATE INDEX IF NOT EXISTS idx_api_keys_group_id ON api_keys ( group_id ) ;
CREATE INDEX IF NOT EXISTS idx_api_keys_project_id ON api_keys ( project_id ) ;
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys ( key_hash ) ;
CREATE INDEX IF NOT EXISTS idx_storage_configs_status ON storage_configs ( status ) ;
CREATE INDEX IF NOT EXISTS idx_work_versions_note_id ON work_versions ( note_id , version_number ) ;
2026-07-21 20:25:52 +08:00
CREATE INDEX IF NOT EXISTS idx_review_rounds_note_id ON review_rounds ( note_id , round_number ) ;
2026-07-21 15:28:55 +08:00
CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events ( note_id , version_number ) ;
` );
2026-07-22 16:41:03 +08:00
export function repairMultiSchemeRounds() {
db . transaction ( ( ) = > {
const duplicates = db . prepare ( ` SELECT review_round_id, note_id FROM work_versions WHERE review_round_id IS NOT NULL GROUP BY review_round_id, note_id HAVING COUNT(*) > 1 ` ) . all ( ) as Array < { review_round_id : number ; note_id : number } > ;
for ( const duplicate of duplicates ) {
const round = db . prepare ( 'SELECT * FROM review_rounds WHERE id=?' ) . get ( duplicate . review_round_id ) as Record < string , unknown > | undefined ;
const note = db . prepare ( 'SELECT active_round_id,version_number FROM notes WHERE id=?' ) . get ( duplicate . note_id ) as { active_round_id : number | null ; version_number : number } | undefined ;
const versions = db . prepare ( 'SELECT version_number FROM work_versions WHERE review_round_id=? ORDER BY version_number' ) . all ( duplicate . review_round_id ) as Array < { version_number : number } > ;
if ( ! round || ! note || versions . length < 2 ) continue ;
const keeper = versions . some ( ( item ) = > Number ( item . version_number ) === Number ( note . version_number ) ) ? Number ( note . version_number ) : Number ( versions [ 0 ] . version_number ) ;
for ( const version of versions . filter ( ( item ) = > Number ( item . version_number ) !== keeper ) ) {
const nextRound = Number ( ( db . prepare ( 'SELECT COALESCE(MAX(round_number),0)+1 AS value FROM review_rounds WHERE note_id=?' ) . get ( duplicate . note_id ) as { value : number } ) . value ) ;
const remainsActive = Number ( note . active_round_id ) === Number ( duplicate . review_round_id ) && Number ( note . version_number ) === Number ( version . version_number ) ;
const result = db . prepare ( ` INSERT INTO review_rounds (note_id,round_number,status,selected_version_number,completed_at,created_by,created_at,completion_reason)
VALUES ( ? , ? , ? , ? , ? , ? , ? , ? ) ` ).run(
duplicate . note_id ,
nextRound ,
remainsActive ? round . status : 'completed' ,
Number ( round . selected_version_number ) === Number ( version . version_number ) ? version.version_number : null ,
remainsActive ? round . completed_at : ( round . completed_at || new Date ( ) . toISOString ( ) ) ,
round . created_by ? ? null ,
round . created_at ,
remainsActive ? round . completion_reason : ( round . completion_reason || 'migrated_single_scheme' ) ,
) ;
const newRoundId = Number ( result . lastInsertRowid ) ;
db . prepare ( 'UPDATE work_versions SET review_round_id=? WHERE note_id=? AND version_number=?' ) . run ( newRoundId , duplicate . note_id , version . version_number ) ;
if ( remainsActive ) db . prepare ( 'UPDATE notes SET active_round_id=? WHERE id=?' ) . run ( newRoundId , duplicate . note_id ) ;
}
if ( Number ( round . selected_version_number ) !== keeper ) db . prepare ( 'UPDATE review_rounds SET selected_version_number=NULL WHERE id=?' ) . run ( duplicate . review_round_id ) ;
}
} ) ( ) ;
}
repairMultiSchemeRounds ( ) ;
db . exec ( "CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_external_id ON notes(project_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''" ) ;
db . exec ( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL' ) ;
2026-07-21 15:28:55 +08:00
export default db ;