Files
delivery-desk/api/db.ts

445 lines
23 KiB
TypeScript

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',
review_status TEXT NOT NULL DEFAULT 'draft',
review_completed_at TEXT,
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 '',
status TEXT NOT NULL DEFAULT 'draft',
completed_at TEXT,
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,
project_id INTEGER,
external_id TEXT,
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,
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TEXT,
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,
version_number INTEGER NOT NULL DEFAULT 1,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
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 text_annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
target TEXT NOT NULL,
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 '',
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
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,
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
);
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,
completion_reason TEXT NOT NULL DEFAULT '',
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
);
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");
addColumn('notes', 'project_id INTEGER');
addColumn('notes', 'external_id TEXT');
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');
addColumn('notes', 'active_round_id INTEGER');
addColumn('notes', 'approved_version_number INTEGER');
addColumn('annotations', "author_name TEXT NOT NULL DEFAULT '客户'");
addColumn('annotations', "status TEXT NOT NULL DEFAULT 'open'");
addColumn('annotations', "author_role TEXT NOT NULL DEFAULT 'client'");
addColumn('annotations', 'withdrawn_at TEXT');
addColumn('annotations', "closure_reason TEXT NOT NULL DEFAULT ''");
addColumn('projects', 'group_id INTEGER');
addColumn('projects', "review_status TEXT NOT NULL DEFAULT 'draft'");
addColumn('projects', 'review_completed_at TEXT');
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');
addColumn('collections', 'completed_at TEXT');
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 ''");
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'");
addColumn('review_rounds', "completion_reason TEXT NOT NULL DEFAULT ''");
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 != ''");
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);
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();
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();
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;
`);
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;
`);
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';
`);
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);
CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id);
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);
CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies(note_id, version_number, feedback_type, feedback_id);
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);
CREATE INDEX IF NOT EXISTS idx_review_rounds_note_id ON review_rounds(note_id, round_number);
CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events(note_id, version_number);
`);
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');
export default db;