const fs = require('fs') const path = require('path') const crypto = require('crypto') const { getDB } = require('./weekly_db') const { enc, dec, hashPassword, encryptBuffer, sealDatabase } = require('./weekly_crypto') const dbPath = path.join(process.cwd(), 'data', 'weekly.db') const sealPath = path.join(process.cwd(), 'data', 'weekly.enc') let idx = { reports: [], events: [], reviews: [] } let sealTimer = null const rebuildIndex = () => { try { const db = getDB() if (db && db.open === false) return const rs = db.prepare(`SELECT * FROM reports ORDER BY id ASC`).all() const es = db.prepare(`SELECT * FROM events ORDER BY id ASC`).all() const rv = db.prepare(`SELECT * FROM event_reviews ORDER BY id ASC`).all() idx.reports = rs.map(r => ({ id:r.id, week_id:r.week_id, title:dec(r.title_enc,r.title_iv,r.title_tag), summary:dec(r.summary_enc,r.summary_iv,r.summary_tag), content:dec(r.content_enc,r.content_iv,r.content_tag) })) idx.events = es.map(e => ({ id:e.id, report_id:e.report_id, title:dec(e.title_enc,e.title_iv,e.title_tag), content:dec(e.content_enc,e.content_iv,e.content_tag), status_code:e.status_code })) idx.reviews = rv.map(v => ({ id:v.id, event_id:v.event_id, review:dec(v.review_enc,v.review_iv,v.review_tag) })) } catch (e) { const msg = String(e && (e.message || e) || '') if (msg.includes('Database not initialized')) return if (msg.includes('database connection is not open')) return console.error('rebuildIndex failed', e) } } const scheduleMaintain = () => { try { setTimeout(() => { try { rebuildIndex() } catch {} }, 0) } catch {} try { if (sealTimer) clearTimeout(sealTimer) sealTimer = setTimeout(() => { sealTimer = null try { sealDatabase(dbPath, sealPath) } catch {} }, 1500) } catch {} } const search = (q) => { q = String(q||'').trim() const terms = q ? q.split(/[\s,]+/).filter(Boolean) : [] const hit = (text) => { let s = String(text||'') let score = 0 for (const t of terms) { const re = new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'gi'); const m = s.match(re); if (m) score += m.length } return score } const results = [] for (const r of idx.reports) { const score = hit(r.title)+hit(r.summary)+hit(r.content); if (score>0) results.push({ type:'report', id:r.id, week_id:r.week_id, score, title:r.title }) } for (const e of idx.events) { const score = hit(e.title)+hit(e.content); if (score>0) results.push({ type:'event', id:e.id, report_id:e.report_id, score, title:e.title }) } for (const v of idx.reviews) { const score = hit(v.review); if (score>0) results.push({ type:'review', id:v.id, event_id:v.event_id, score, title:v.review.slice(0,50) }) } results.sort((a,b)=>b.score-a.score) return results } const searchAdvanced = (criteria) => { const range = criteria.range||{} const status = Array.isArray(criteria.status) ? criteria.status : [] const mime = Array.isArray(criteria.mime) ? criteria.mime : [] let pool = idx.events.map(e => ({...e})) if (status.length) pool = pool.filter(e => status.includes(e.status_code)) if (range.start || range.end) { const ridToWeek = Object.fromEntries(idx.reports.map(r => [r.id, r.week_id])) pool = pool.filter(e => { const w = ridToWeek[e.report_id]||'' const y = w if (range.start && y < range.start) return false if (range.end && y > range.end) return false return true }) } if (mime.length) { const db = getDB() const rows = db.prepare(`SELECT DISTINCT event_id FROM attachments`).all() const hasAtt = new Set(rows.map(r=>r.event_id)) pool = pool.filter(e => hasAtt.has(e.id)) } return pool } // Helper: Week ID Calculation const weekIdForDate = (dateStr) => { try { const d = new Date(dateStr) const y = d.getUTCFullYear() const day = d.getUTCDay() const diffToMon = (day + 6) % 7 const mon = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - diffToMon)) const firstDay = new Date(Date.UTC(mon.getUTCFullYear(), 0, 1)) const firstDayWeekday = (firstDay.getUTCDay() + 6) % 7 const days = Math.floor((mon - firstDay) / 86400000) + firstDayWeekday + 1 const week = Math.ceil(days / 7) return `${mon.getUTCFullYear()}-W${String(week).padStart(2,'0')}` } catch { return `${new Date().getUTCFullYear()}-W01` } } const beijingToLocalISO = (dateStr, hour=23, minute=59) => { try { const ymd = String(dateStr).slice(0,10) const HH = String(hour).padStart(2,'0') const MM = String(minute).padStart(2,'0') if (/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return `${ymd} ${HH}:${MM}` return '' } catch { return '' } } const weeksForYear = (year) => { const y = parseInt(year, 10) const tz = 'Asia/Shanghai' const dateInTZ = (d) => new Date(d) const firstThursday = new Date(Date.UTC(y, 0, 4)) const day = firstThursday.getUTCDay() const diffToMon = (day + 6) % 7 let mon = new Date(Date.UTC(y, 0, 4 - diffToMon)) const out = [] let weekNum = 1 while (weekNum <= 53) { const thu = new Date(Date.UTC(mon.getUTCFullYear(), mon.getUTCMonth(), mon.getUTCDate() + 3)) const isoYear = thu.getUTCFullYear() if (isoYear !== y) break const sun = new Date(Date.UTC(mon.getUTCFullYear(), mon.getUTCMonth(), mon.getUTCDate() + 6)) const weekLabel = `${y}-W${String(weekNum).padStart(2,'0')}` const startStr = new Intl.DateTimeFormat('zh-CN', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(dateInTZ(mon)).replace(/\//g,'-') const endStr = new Intl.DateTimeFormat('zh-CN', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(dateInTZ(sun)).replace(/\//g,'-') out.push({ week_id: weekLabel, start: startStr, end: endStr }) mon = new Date(Date.UTC(mon.getUTCFullYear(), mon.getUTCMonth(), mon.getUTCDate() + 7)) weekNum++ } // Optimize: Batch check for report and events existence try { const db = getDB() const reports = db.prepare(`SELECT id, week_id FROM reports WHERE week_id LIKE ?`).all(`${y}-%`) const reportMap = new Map() reports.forEach(r => reportMap.set(r.week_id, r.id)) if (reports.length > 0) { const placeholders = reports.map(() => '?').join(',') const reportIds = reports.map(r => r.id) const eventRows = db.prepare(`SELECT DISTINCT report_id FROM events WHERE report_id IN (${placeholders})`).all(...reportIds) const eventSet = new Set(eventRows.map(r => r.report_id)) out.forEach(w => { const rid = reportMap.get(w.week_id) w.has_report = !!rid w.has_events = rid ? eventSet.has(rid) : false }) } else { out.forEach(w => { w.has_report = false; w.has_events = false }) } } catch (e) { console.error('weeksForYear DB error', e) } return out } const logJSON = (action, details, source='weekly') => { try { getDB().prepare(`INSERT INTO logs (action, details, source) VALUES (?, ?, ?)`).run(action, JSON.stringify(details), source) } catch (e) { console.error('Log failed', e) } } const saveVersion = (type, id, data) => { try { let s = '' try { s = JSON.stringify(data) } catch { s = '' } const e = enc(s) getDB().prepare(`INSERT INTO versions (subject_type, subject_id, snapshot_enc, snapshot_iv, snapshot_tag) VALUES (?, ?, ?, ?, ?)`).run(type, id, e.ct, e.iv, e.tag) } catch (e) {} } const ensureReportForWeek = (week_id) => { const db = getDB() const existing = db.prepare(`SELECT id FROM reports WHERE week_id=? ORDER BY id DESC LIMIT 1`).get(week_id) if (existing && existing.id) return existing.id let titleAuto = `${week_id} 周报` try { const m = String(week_id||'').match(/^(\d{4})-W(\d{2})$/) if (m) titleAuto = `${m[1]}-第${parseInt(m[2],10)}周(自动新增)` } catch {} const et = enc(titleAuto) const es = enc('') const ec = enc('这是自动新增的周报。你可以修改。') const info = db.prepare(`INSERT INTO reports (week_id, title_enc, title_iv, title_tag, summary_enc, summary_iv, summary_tag, content_enc, content_iv, content_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(week_id, et.ct, et.iv, et.tag, es.ct, es.iv, es.tag, ec.ct, ec.iv, ec.tag) const id = info.lastInsertRowid saveVersion('report', id, { week_id, title: titleAuto, summary: '', content: '这是自动新增的周报。你可以修改。' }) scheduleMaintain() return id } const createReport = (week_id, title, summary, content) => { const db = getDB() const existing = db.prepare(`SELECT id FROM reports WHERE week_id=?`).get(week_id) if (existing) return existing.id const et = enc(title||'') const es = enc(summary||'') const ec = enc(content||'') const info = db.prepare(`INSERT INTO reports (week_id, title_enc, title_iv, title_tag, summary_enc, summary_iv, summary_tag, content_enc, content_iv, content_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(week_id, et.ct, et.iv, et.tag, es.ct, es.iv, es.tag, ec.ct, ec.iv, ec.tag) const id = info.lastInsertRowid saveVersion('report', id, { week_id, title, summary, content }) logJSON('weekly.report.create', { id, week_id }, 'weekly') scheduleMaintain() return id } const updateReport = (payload) => { const id = parseInt(payload.id, 10) const old = getDB().prepare(`SELECT * FROM reports WHERE id=?`).get(id) if (!old) return const oldTitle = dec(old.title_enc, old.title_iv, old.title_tag) const oldSummary = dec(old.summary_enc, old.summary_iv, old.summary_tag) const oldContent = dec(old.content_enc, old.content_iv, old.content_tag) const title = payload.title !== undefined ? payload.title : oldTitle const summary = payload.summary !== undefined ? payload.summary : oldSummary const content = payload.content !== undefined ? payload.content : oldContent const et = enc(title||'') const es = enc(summary||'') const ec = enc(content||'') getDB().prepare(`UPDATE reports SET title_enc=?, title_iv=?, title_tag=?, summary_enc=?, summary_iv=?, summary_tag=?, content_enc=?, content_iv=?, content_tag=?, updated_at=datetime('now') WHERE id=?`).run(et.ct, et.iv, et.tag, es.ct, es.iv, es.tag, ec.ct, ec.iv, ec.tag, id) saveVersion('report', id, { week_id: old.week_id, title, summary, content }) logJSON('weekly.report.update', { id }, 'weekly') scheduleMaintain() } const getReportByWeek = (week_id) => { const db = getDB() const row = db.prepare(`SELECT * FROM reports WHERE week_id=? ORDER BY id DESC LIMIT 1`).get(week_id) if (!row) return null return { id: row.id, week_id: row.week_id, title: dec(row.title_enc, row.title_iv, row.title_tag), summary: dec(row.summary_enc, row.summary_iv, row.summary_tag), content: dec(row.content_enc, row.content_iv, row.content_tag), created_at: row.created_at, updated_at: row.updated_at } } const createEvent = (payload) => { const db = getDB() const et = enc(payload.title||'') const ec = enc(payload.content||'') const es = enc(payload.status||'') const info = db.prepare(`INSERT INTO events (report_id, title_enc, title_iv, title_tag, content_enc, content_iv, content_tag, status_code, status_enc, status_iv, status_tag, deadline, parent_event_id, completed_at, push_required) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( parseInt(payload.report_id,10), et.ct, et.iv, et.tag, ec.ct, ec.iv, ec.tag, String(payload.status_code||''), es.ct, es.iv, es.tag, payload.deadline||null, payload.parent_event_id ? parseInt(payload.parent_event_id,10) : null, null, payload.push_required ? 1 : 0 ) const id = info.lastInsertRowid saveVersion('event', id, { title: payload.title, content: payload.content, status_code: payload.status_code, status: payload.status, deadline: payload.deadline, parent_event_id: payload.parent_event_id||null, push_required: payload.push_required ? 1 : 0 }) logJSON('weekly.event.create', { id }, 'weekly') scheduleMaintain() return id } const updateEvent = (payload) => { const id = parseInt(payload.id,10) const old = getEventFull(id) if (!old) return const title = payload.title !== undefined ? payload.title : old.title const content = payload.content !== undefined ? payload.content : old.content const status = payload.status !== undefined ? payload.status : old.status const statusCode = payload.status_code !== undefined ? payload.status_code : old.status_code const deadline = payload.deadline !== undefined ? payload.deadline : old.deadline const parentId = payload.parent_event_id !== undefined ? payload.parent_event_id : old.parent_event_id const pushRequired = payload.push_required !== undefined ? (payload.push_required ? 1 : 0) : (old.push_required ? 1 : 0) const eTitle = enc(title||'') const eContent = enc(content||'') const eStatus = enc(status||'') getDB().prepare(`UPDATE events SET title_enc=?, title_iv=?, title_tag=?, content_enc=?, content_iv=?, content_tag=?, status_code=?, status_enc=?, status_iv=?, status_tag=?, deadline=?, parent_event_id=?, push_required=?, updated_at=datetime('now') WHERE id=?`).run( eTitle.ct, eTitle.iv, eTitle.tag, eContent.ct, eContent.iv, eContent.tag, String(statusCode||''), eStatus.ct, eStatus.iv, eStatus.tag, deadline||null, parentId ? parseInt(parentId,10) : null, pushRequired, id ) saveVersion('event', id, { title, content, status_code: statusCode, status, deadline, parent_event_id: parentId, push_required: pushRequired }) logJSON('weekly.event.update', { id }, 'weekly') scheduleMaintain() } const deleteEvent = (id) => { const eid = parseInt(id,10) const db = getDB() db.prepare(`DELETE FROM event_reviews WHERE event_id=?`).run(eid) db.prepare(`DELETE FROM attachments WHERE event_id=?`).run(eid) try { db.prepare(`DELETE FROM event_external WHERE event_id=?`).run(eid) } catch (e) {} db.prepare(`DELETE FROM events WHERE id=?`).run(eid) logJSON('weekly.event.delete', { id:eid }, 'weekly') scheduleMaintain() } const listEvents = (report_id) => { const rows = getDB().prepare(`SELECT id, report_id, title_enc, title_iv, title_tag, content_enc, content_iv, content_tag, status_code, deadline, parent_event_id, completed_at, push_required, created_at, updated_at FROM events WHERE report_id=? ORDER BY id ASC`).all(parseInt(report_id,10)) return rows.map(r => ({ id: r.id, report_id: r.report_id, title: dec(r.title_enc, r.title_iv, r.title_tag), content: dec(r.content_enc, r.content_iv, r.content_tag), status_code: r.status_code, status: r.status_code, deadline: r.deadline, parent_event_id: r.parent_event_id, completed_at: r.completed_at, push_required: !!r.push_required, created_at: r.created_at, updated_at: r.updated_at })) } const getEventFull = (id) => { const row = getDB().prepare(`SELECT e.*, r.week_id FROM events e JOIN reports r ON e.report_id=r.id WHERE e.id=?`).get(parseInt(id,10)) if (!row) return null return { id: row.id, week_id: row.week_id, title: dec(row.title_enc, row.title_iv, row.title_tag), content: dec(row.content_enc, row.content_iv, row.content_tag), status_code: row.status_code, deadline: row.deadline, push_required: !!row.push_required } } const addReview = (event_id, reviewText) => { const e = enc(reviewText||'') const info = getDB().prepare(`INSERT INTO event_reviews (event_id, review_enc, review_iv, review_tag) VALUES (?, ?, ?, ?)`).run(parseInt(event_id,10), e.ct, e.iv, e.tag) const id = info.lastInsertRowid saveVersion('event_review', id, { event_id, review: reviewText }) logJSON('weekly.review.create', { id }, 'weekly') scheduleMaintain() return id } const listReviews = (event_id) => { const rows = getDB().prepare(`SELECT * FROM event_reviews WHERE event_id=? ORDER BY id ASC`).all(parseInt(event_id,10)) return rows.map(r => ({ id:r.id, event_id:r.event_id, review: dec(r.review_enc, r.review_iv, r.review_tag), created_at:r.created_at })) } const listReviewsByEventIds = (eventIds) => { const ids = Array.isArray(eventIds) ? eventIds.map(v => parseInt(v, 10)).filter(Number.isFinite) : [] if (!ids.length) return {} const placeholders = ids.map(() => '?').join(',') const rows = getDB().prepare(`SELECT * FROM event_reviews WHERE event_id IN (${placeholders}) ORDER BY event_id ASC, id ASC`).all(...ids) const out = {} ids.forEach(id => { out[id] = [] }) rows.forEach(r => { const key = r.event_id if (!out[key]) out[key] = [] out[key].push({ id: r.id, event_id: r.event_id, review: dec(r.review_enc, r.review_iv, r.review_tag), created_at: r.created_at }) }) return out } const attachmentsDir = path.join(process.cwd(), 'uploads', 'weekly', 'attachments') if (!fs.existsSync(attachmentsDir)) fs.mkdirSync(attachmentsDir, { recursive: true }) const saveAttachmentChunk = (event_id, filename, mime, buffer) => { const originalName = String(filename||'file.bin') const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g,'_') const efn = enc(originalName) const emime = enc(String(mime||'application/octet-stream')) const { ct, iv, tag } = encryptBuffer(buffer) const outName = `${Date.now()}-${Math.random().toString(36).slice(2)}-${safeName}` const full = path.join(attachmentsDir, outName) fs.writeFileSync(full, Buffer.concat([iv, tag, ct])) const estore = enc(full) const info = getDB().prepare(`INSERT INTO attachments (event_id, filename_enc, filename_iv, filename_tag, mime_enc, mime_iv, mime_tag, size, chunks, store_enc, store_iv, store_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( parseInt(event_id,10), efn.ct, efn.iv, efn.tag, emime.ct, emime.iv, emime.tag, buffer.length, 1, estore.ct, estore.iv, estore.tag ) const id = info.lastInsertRowid logJSON('weekly.attachment.save', { id, event_id, originalName, safeName, mime, size: buffer.length, path: full }, 'weekly') return id } const listAttachments = (event_id) => { const rows = getDB().prepare(`SELECT * FROM attachments WHERE event_id=? ORDER BY id ASC`).all(parseInt(event_id,10)) return rows.map(r => ({ id:r.id, event_id:r.event_id, filename: dec(r.filename_enc, r.filename_iv, r.filename_tag), mime: dec(r.mime_enc, r.mime_iv, r.mime_tag), size: r.size, created_at: r.created_at })) } const listAttachmentsByEventIds = (eventIds) => { const ids = Array.isArray(eventIds) ? eventIds.map(v => parseInt(v, 10)).filter(Number.isFinite) : [] if (!ids.length) return {} const placeholders = ids.map(() => '?').join(',') const rows = getDB().prepare(`SELECT * FROM attachments WHERE event_id IN (${placeholders}) ORDER BY event_id ASC, id ASC`).all(...ids) const out = {} ids.forEach(id => { out[id] = [] }) rows.forEach(r => { const key = r.event_id if (!out[key]) out[key] = [] out[key].push({ id: r.id, event_id: r.event_id, filename: dec(r.filename_enc, r.filename_iv, r.filename_tag), mime: dec(r.mime_enc, r.mime_iv, r.mime_tag), size: r.size, created_at: r.created_at }) }) return out } const ensureDefaultUser = () => { const db = getDB() const c = db.prepare(`SELECT COUNT(1) AS c FROM users`).get().c if (c === 0) { const salt = crypto.randomBytes(16).toString('hex') const defaultPass = process.env.WEEKLY_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || '' const hash = hashPassword(defaultPass, salt) db.prepare(`INSERT INTO users (username, pass_salt, pass_hash) VALUES (?, ?, ?)`).run('admin_wy', salt, hash) logJSON('weekly.user.init', { username: 'admin_wy' }, 'weekly') } } const login = (username, password) => { const row = getDB().prepare(`SELECT * FROM users WHERE username=?`).get(String(username||'')) if (!row) return { ok:false, error:'用户不存在' } const hash = hashPassword(password, row.pass_salt) if (hash !== row.pass_hash) return { ok:false, error:'密码错误' } return { ok:true, user:{ id: row.id, username: row.username } } } const changePassword = (username, oldPass, newPass) => { const db = getDB() const row = db.prepare(`SELECT * FROM users WHERE username=?`).get(String(username||'')) if (!row) return { ok:false, error:'用户不存在' } const oldHash = hashPassword(oldPass, row.pass_salt) if (oldHash !== row.pass_hash) return { ok:false, error:'旧密码错误' } const salt = crypto.randomBytes(16).toString('hex') const hash = hashPassword(newPass, salt) db.prepare(`UPDATE users SET pass_salt=?, pass_hash=?, updated_at=datetime('now') WHERE id=?`).run(salt, hash, row.id) logJSON('weekly.pass.change', { username }, 'weekly') return { ok:true } } const decryptAttachmentBuffer = (row, rawBuffer) => { if (!row) return null const filename = dec(row.filename_enc, row.filename_iv, row.filename_tag) const mime = dec(row.mime_enc, row.mime_iv, row.mime_tag) const buf = Buffer.isBuffer(rawBuffer) ? rawBuffer : Buffer.alloc(0) if (buf.length < 28) return null const iv = buf.subarray(0, 12) const tag = buf.subarray(12, 28) const ct = buf.subarray(28) const decipher = crypto.createDecipheriv('aes-256-gcm', require('./weekly_crypto').getKey(), iv) decipher.setAuthTag(tag) const p1 = decipher.update(ct) const p2 = decipher.final() const data = Buffer.concat([p1, p2]) return { filename, mime, data } } const getAttachmentData = (id) => { const row = getDB().prepare(`SELECT * FROM attachments WHERE id=?`).get(parseInt(id,10)) if (!row) return null const storePath = dec(row.store_enc, row.store_iv, row.store_tag) if (!fs.existsSync(storePath)) return null const buf = fs.readFileSync(storePath) return decryptAttachmentBuffer(row, buf) } const getAttachmentDataByStoredPath = (storedPath, rawBuffer) => { const expected = path.resolve(String(storedPath || '')) if (!expected) return null const rows = getDB().prepare(`SELECT * FROM attachments ORDER BY id DESC`).all() for (const row of rows) { const current = path.resolve(dec(row.store_enc, row.store_iv, row.store_tag) || '') if (!current || current !== expected) continue const fileBuf = Buffer.isBuffer(rawBuffer) ? rawBuffer : (fs.existsSync(current) ? fs.readFileSync(current) : null) if (!fileBuf) return null return decryptAttachmentBuffer(row, fileBuf) } return null } const deleteAttachment = (id) => { const row = getDB().prepare(`SELECT * FROM attachments WHERE id=?`).get(parseInt(id,10)) if (row) { const storePath = dec(row.store_enc, row.store_iv, row.store_tag) try { if (fs.existsSync(storePath)) fs.unlinkSync(storePath) } catch {} getDB().prepare(`DELETE FROM attachments WHERE id=?`).run(row.id) logJSON('weekly.attachment.delete', { id: row.id, event_id: row.event_id }, 'weekly') } } const getRelations = (event_id) => { const rows = getDB().prepare(`SELECT * FROM event_relations WHERE event_id=? OR target_event_id=?`).all(parseInt(event_id,10), parseInt(event_id,10)) return rows.map(r => ({ id: r.id, event_id: r.event_id, target_event_id: r.target_event_id, relation_type: r.relation_type, created_at: r.created_at })) } const addRelation = (event_id, target_event_id, type) => { const info = getDB().prepare(`INSERT INTO event_relations (event_id, target_event_id, relation_type) VALUES (?, ?, ?)`).run(parseInt(event_id,10), parseInt(target_event_id,10), String(type||'related')) return info.lastInsertRowid } const deleteRelation = (id) => { getDB().prepare(`DELETE FROM event_relations WHERE id=?`).run(parseInt(id,10)) } const getVersions = (type, id) => { const rows = getDB().prepare(`SELECT id, created_at FROM versions WHERE subject_type=? AND subject_id=? ORDER BY id DESC`).all(type, parseInt(id,10)) return rows } const getVersionDiff = (type, id, v1, v2) => { const db = getDB() const r1 = db.prepare(`SELECT snapshot_enc, snapshot_iv, snapshot_tag FROM versions WHERE id=?`).get(parseInt(v1,10)) const r2 = db.prepare(`SELECT snapshot_enc, snapshot_iv, snapshot_tag FROM versions WHERE id=?`).get(parseInt(v2,10)) if (!r1 || !r2) return null const d1 = JSON.parse(dec(r1.snapshot_enc, r1.snapshot_iv, r1.snapshot_tag) || '{}') const d2 = JSON.parse(dec(r2.snapshot_enc, r2.snapshot_iv, r2.snapshot_tag) || '{}') // Simple diff const diff = {} const allKeys = new Set([...Object.keys(d1), ...Object.keys(d2)]) for (const k of allKeys) { if (JSON.stringify(d1[k]) !== JSON.stringify(d2[k])) { diff[k] = { old: d1[k], new: d2[k] } } } return diff } const getReminders = () => { const now = new Date() const db = getDB() // Upcoming deadlines in 7 days const upcoming = [] const events = db.prepare(`SELECT * FROM events WHERE deadline IS NOT NULL AND status_code!='DONE'`).all() for (const e of events) { const d = new Date(e.deadline) if (isNaN(d.getTime())) continue const diff = (d - now) / (1000 * 3600 * 24) if (diff >= -1 && diff <= 7) { upcoming.push({ id: e.id, report_id: e.report_id, title: dec(e.title_enc, e.title_iv, e.title_tag), deadline: e.deadline, days: Math.ceil(diff) }) } } return upcoming.sort((a,b) => a.days - b.days) } module.exports = { weekIdForDate, beijingToLocalISO, weeksForYear, logJSON, saveVersion, ensureReportForWeek, createReport, updateReport, getReportByWeek, createEvent, updateEvent, deleteEvent, listEvents, getEventFull, addReview, listReviews, listReviewsByEventIds, listAttachments, listAttachmentsByEventIds, saveAttachmentChunk, getAttachmentData, getAttachmentDataByStoredPath, deleteAttachment, ensureDefaultUser, login, changePassword, rebuildIndex, scheduleMaintain, search, searchAdvanced, getRelations, addRelation, deleteRelation, getVersions, getVersionDiff, getReminders }