Files
TREK/server/src/services/userCleanupService.ts
T
2026-07-16 11:49:44 +03:00

66 lines
3.6 KiB
TypeScript

import fs from 'node:fs';
import { db } from '../db/database';
import { pluginsDataRoot } from '../nest/plugins/paths';
import { removeUserFromBudgetItems } from './budgetService';
/**
* Erase a user's PLUGIN-held data on account deletion. Two parts:
* 1. Host-side per-user plugin tables (encrypted config values, OAuth access/refresh
* tokens, in-flight OAuth state) — deleted directly; these live in trek.db, not in
* a plugin's own db, so nothing else ever removes them.
* 2. A durable erasure row per plugin that holds `hook:user-data`, so its OWN db is
* purged of the user (drained to the plugin when it is next active).
*
* Runs in the core deletion path (NOT via the plugin runtime), so it works even when
* TREK_PLUGINS_ENABLED=false or before the runtime has booted — otherwise a deletion in
* those windows would leave the user's plugin data behind forever. Best-effort per table
* so a slimmed-down schema (some tests) can't fail the user deletion itself.
*/
export function erasePluginUserData(userId: number): void {
for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) {
try { db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(userId); } catch { /* table absent (slim schema) */ }
}
try {
const rows = db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
const installed = new Set(rows.map((r) => r.id));
const insert = db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
for (const r of rows) {
let perms: unknown;
try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; }
if (Array.isArray(perms) && perms.includes('hook:user-data')) insert.run(r.id, userId);
}
// Also enqueue for plugins UNINSTALLED with their data retained (deleteData=false):
// their data dir still holds the user's rows and a same-id reinstall would re-adopt
// them. No permissions record survives uninstall, so we can't check hook:user-data —
// enqueue for every orphan data dir; the row sits inert (no FK) and drains only if
// that id is reinstalled + active (erasure delivery is a duty, not grant-gated).
try {
for (const entry of fs.readdirSync(pluginsDataRoot(), { withFileTypes: true })) {
if (entry.isDirectory() && !installed.has(entry.name)) insert.run(entry.name, userId);
}
} catch { /* no plugin data root yet */ }
} catch { /* plugins / queue table absent (slim schema) */ }
}
function cleanupUserReferences(userId: number): void {
db.prepare('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?').run(userId);
db.prepare('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?').run(userId);
removeUserFromBudgetItems(userId);
db.prepare('DELETE FROM share_tokens WHERE created_by = ?').run(userId);
db.prepare('DELETE FROM journey_share_tokens WHERE created_by = ?').run(userId);
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
db.prepare('DELETE FROM journeys WHERE user_id = ?').run(userId);
// Entries authored on other users' journeys (not covered by the cascade above)
db.prepare('DELETE FROM journey_entries WHERE author_id = ?').run(userId);
db.prepare('DELETE FROM journey_contributors WHERE user_id = ?').run(userId);
}
export function deleteUserCompletely(userId: number): void {
const tx = db.transaction((id: number) => {
cleanupUserReferences(id);
erasePluginUserData(id);
db.prepare('DELETE FROM users WHERE id = ?').run(id);
});
tx(userId);
}