Files
picpeak/backend/src/database/db.js
T
Luca 989b42c2f1 fix(restore-wizard): warn on backups without a database dump
Closes the loop on the original 2026-05-29 data-loss class: Ralf had
four "Run Backup Now" manifests sitting on disk with
database.backup_file = null because Stage A wasn't yet in place.
The restore wizard would have happily restored any of those four,
bringing back files (photos, PDFs) but leaving the database empty —
silently re-creating the exact data loss the rest of this branch
prevents going forward.

The /restore/list-backups endpoint now returns `database_included`
per row (parsed from the manifest at discovery time). The wizard
uses it to:

  - Per-row badge: red "No DB" pill next to any backup where
    database_included === false. Tooltip explains the consequence
    in plain English: "restoring this will NOT recover the database".
  - Selected-card callout: full red banner under the chosen row
    when database_included is false, restating the warning + giving
    the admin a clear path: "pick a different backup if you have
    one with a database dump, or proceed only if files-only is
    what you want."

The wizard does NOT block the restore — the admin may genuinely want
a files-only restore (e.g. recovering a deleted photo while keeping
current DB state). The warnings make sure that choice is informed.
2026-05-31 22:44:37 +02:00

676 lines
26 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const knex = require('knex');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
const { extractShareToken } = require('../utils/shareLinkUtils');
// Ensure SQLite directory exists when using file-based DB (native installs)
try {
const isPostgres = knexConfig && knexConfig.client === 'pg';
if (!isPostgres && knexConfig && knexConfig.connection) {
const filename = typeof knexConfig.connection === 'object'
? knexConfig.connection.filename
: (typeof knexConfig.connection === 'string' ? knexConfig.connection : null);
if (filename && typeof filename === 'string') {
const dir = path.dirname(filename);
if (dir && dir !== '.') {
fs.mkdirSync(dir, { recursive: true });
}
}
}
} catch (e) {
// Non-fatal: log and continue; SQLite will fail later if still missing
try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {}
}
// Create database connection with built-in retry logic.
//
// The underlying knex instance is held in `_db` and reachable through a
// Proxy `db` that forwards every call to the current instance. This
// indirection exists so `reinitPool()` below can swap the live pool
// without breaking the thousands of existing `const { db } = require(...)`
// imports — they capture the Proxy once, and every subsequent
// `db('table')` / `db.schema.hasTable(...)` lookup goes through the
// Proxy to whatever `_db` currently points at.
//
// Used by the restore service after DROP/CREATE DATABASE: the old pool
// is destroyed during the drop (to release PG connections so the DROP
// can succeed), then `reinitPool()` opens a fresh pool against the
// recreated DB. Without this, every query in the process after a
// restore failed with "Unable to acquire a connection" until the
// container was manually restarted — exactly the footgun Ralf hit
// repeatedly on 2026-05-30.
let _db = knex(knexConfig);
const db = new Proxy(function knexCall() {}, {
// db('tableName') — knex's query builder entry point
apply(_target, _thisArg, args) {
return _db(...args);
},
// db.schema, db.raw, db.migrate, etc.
get(_target, prop) {
const v = _db[prop];
return typeof v === 'function' ? v.bind(_db) : v;
},
// Defensive: future code that does `if ('schema' in db)` works.
has(_target, prop) { return prop in _db; },
});
/**
* Tear down the current knex pool and open a fresh one.
*
* Idempotent — calling it twice in a row just destroys + recreates
* twice, no error. Throws if the new pool can't establish a
* connection (which surfaces a clear error rather than letting the
* caller proceed with a half-broken pool).
*
* Used by restoreService after the DROP/CREATE DATABASE pair.
*/
async function reinitPool() {
const prev = _db;
try {
await prev.destroy();
} catch (err) {
logger.warn(`Old pool destroy failed (continuing with reinit): ${err.message}`);
}
_db = knex(knexConfig);
// Probe the new pool with a no-op query so we fail loudly here if
// the new pool can't connect — better than silently handing the
// caller a broken pool and surfacing the error on the next admin
// request.
try {
if (knexConfig.client === 'pg') {
await _db.raw('SELECT 1');
} else {
await _db.raw('SELECT 1');
}
} catch (probeErr) {
logger.error('New pool failed health-check after reinit', { error: probeErr.message });
throw probeErr;
}
logger.info('knex pool re-initialized successfully');
}
// Connection retry configuration
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
// Wrapper function to handle connection retries
async function withRetry(queryFn, retries = MAX_RETRIES) {
for (let i = 0; i < retries; i++) {
try {
return await queryFn();
} catch (error) {
const isConnectionError = error.message && (
error.message.includes('Connection terminated unexpectedly') ||
error.message.includes('Connection ended unexpectedly') ||
error.message.includes('ECONNREFUSED') ||
error.message.includes('ETIMEDOUT')
);
if (isConnectionError && i < retries - 1) {
logger.info(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
continue;
}
throw error;
}
}
}
async function initializeDatabase() {
// Events table
const hasEventsTable = await db.schema.hasTable('events');
if (!hasEventsTable) {
await db.schema.createTable('events', (table) => {
table.increments('id').primary();
table.string('slug').unique().notNullable();
table.string('event_type').notNullable();
table.string('event_name').notNullable();
table.date('event_date').notNullable();
table.string('customer_name');
table.string('customer_email');
table.string('host_email').notNullable();
table.string('host_name');
table.string('admin_email').notNullable();
table.string('password_hash').notNullable();
table.text('welcome_message');
table.text('color_theme');
table.string('share_link').unique().notNullable();
table.string('share_token').unique();
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('expires_at').notNullable();
table.boolean('is_active').defaultTo(true);
table.boolean('is_archived').defaultTo(false);
table.string('archive_path');
table.datetime('archived_at');
table.boolean('allow_user_uploads').defaultTo(false);
table.integer('upload_category_id');
table.boolean('allow_downloads').defaultTo(true);
table.boolean('disable_right_click').defaultTo(false);
table.boolean('watermark_downloads').defaultTo(false);
table.text('watermark_text');
// events.hero_photo_id → photos.id is a forward reference (the
// photos table is created later in this same function). Postgres
// rejects FK declarations that reference a non-existent table at
// CREATE TABLE time, so the constraint is added below as an
// ALTER TABLE *after* the photos table exists. SQLite previously
// tolerated the inline declaration because its FK enforcement is
// lazy — the inline form silently became a column with no FK
// metadata. Both backends now go through the same code path.
// (#484, MrGabri's reproduction.)
table.integer('hero_photo_id');
table.boolean('require_password').defaultTo(true);
});
} else {
// Check if color_theme needs to be updated to TEXT type
// This is needed for larger theme configurations
const isPostgres = knexConfig.client === 'pg';
if (!isPostgres) {
// SQLite-specific migration
try {
await db.raw(`
CREATE TABLE IF NOT EXISTS events_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
event_name TEXT NOT NULL,
event_date DATE NOT NULL,
customer_name TEXT,
customer_email TEXT,
host_name TEXT,
host_email TEXT NOT NULL,
admin_email TEXT NOT NULL,
password_hash TEXT NOT NULL,
welcome_message TEXT,
color_theme TEXT,
share_link TEXT UNIQUE NOT NULL,
share_token TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
is_active BOOLEAN DEFAULT 1,
is_archived BOOLEAN DEFAULT 0,
archive_path TEXT,
archived_at DATETIME,
allow_user_uploads BOOLEAN DEFAULT 0,
upload_category_id INTEGER,
allow_downloads BOOLEAN DEFAULT 1,
disable_right_click BOOLEAN DEFAULT 0,
watermark_downloads BOOLEAN DEFAULT 0,
watermark_text TEXT,
hero_photo_id INTEGER,
require_password BOOLEAN DEFAULT 1
)
`);
const pragmaRows = await db.raw("PRAGMA table_info('events')");
const existingColumns = pragmaRows.map(row => row.name);
const selectColumns = existingColumns.map((col) => {
switch (col) {
case 'allow_user_uploads':
return "COALESCE(allow_user_uploads, 0) as allow_user_uploads";
case 'upload_category_id':
return "upload_category_id";
case 'allow_downloads':
return "COALESCE(allow_downloads, 1) as allow_downloads";
case 'disable_right_click':
return "COALESCE(disable_right_click, 0) as disable_right_click";
case 'watermark_downloads':
return "COALESCE(watermark_downloads, 0) as watermark_downloads";
case 'watermark_text':
return 'watermark_text';
case 'hero_photo_id':
return 'hero_photo_id';
case 'require_password':
return 'COALESCE(require_password, 1) as require_password';
default:
return col;
}
});
await db.raw(`INSERT INTO events_new (${existingColumns.join(', ')}) SELECT ${selectColumns.join(', ')} FROM events`);
await db.raw('DROP TABLE events');
await db.raw('ALTER TABLE events_new RENAME TO events');
} catch (error) {
// If the migration fails, it might already have been applied
logger.debug('Color theme migration may have already been applied');
}
}
}
const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token');
if (!hasShareTokenColumn) {
await db.schema.table('events', (table) => {
table.string('share_token').unique();
});
}
const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name');
if (!hasHostNameColumn) {
await db.schema.table('events', (table) => {
table.string('host_name');
});
}
try {
const eventsWithoutToken = await db('events')
.whereNull('share_token')
.select('id', 'share_link');
for (const event of eventsWithoutToken) {
const token = extractShareToken(event.share_link);
if (token) {
await db('events')
.where({ id: event.id })
.update({ share_token: token });
}
}
} catch (error) {
logger.warn('Share token backfill skipped', { error: error.message });
}
// Photo metadata table
const hasPhotosTable = await db.schema.hasTable('photos');
if (!hasPhotosTable) {
await db.schema.createTable('photos', (table) => {
table.increments('id').primary();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.string('filename').notNullable();
table.string('path').notNullable();
table.string('thumbnail_path');
table.string('type').notNullable(); // 'collage' or 'individual'
table.integer('size_bytes');
table.string('uploaded_by').defaultTo('admin');
table.datetime('uploaded_at').defaultTo(db.fn.now());
table.integer('view_count').defaultTo(0);
table.integer('download_count').defaultTo(0);
});
// Deferred FK: events.hero_photo_id → photos.id. See the comment
// on the events createTable above for why this can't be inline.
// Wrapped in try/catch so a re-run path or an SQLite install that
// already accepted the inline (no-op) declaration doesn't fail
// boot when the constraint already exists in some shape.
try {
await db.schema.alterTable('events', (table) => {
table.foreign('hero_photo_id')
.references('id').inTable('photos')
.onDelete('SET NULL');
});
} catch (err) {
const msg = err?.message || '';
if (!/already exists|duplicate|exists/i.test(msg)) {
throw err;
}
// Constraint already in place — fine, carry on.
}
}
// Access logs table
const hasAccessLogsTable = await db.schema.hasTable('access_logs');
if (!hasAccessLogsTable) {
await db.schema.createTable('access_logs', (table) => {
table.increments('id').primary();
table.integer('event_id').references('id').inTable('events');
table.string('ip_address');
table.string('user_agent');
table.string('action'); // 'view', 'download', 'login_success', 'login_fail'
table.string('photo_id');
table.datetime('timestamp').defaultTo(db.fn.now());
});
}
// Email queue table
const hasEmailQueueTable = await db.schema.hasTable('email_queue');
if (!hasEmailQueueTable) {
await db.schema.createTable('email_queue', (table) => {
table.increments('id').primary();
table.integer('event_id').references('id').inTable('events');
table.string('recipient_email').notNullable();
table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete'
table.json('email_data');
table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed'
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('scheduled_at').defaultTo(db.fn.now());
table.datetime('sent_at');
table.text('error_message');
table.integer('retry_count').defaultTo(0);
});
} else {
const hasCreatedAt = await db.schema.hasColumn('email_queue', 'created_at');
if (!hasCreatedAt) {
await db.schema.alterTable('email_queue', (table) => {
table.datetime('created_at').defaultTo(db.fn.now());
});
try {
await db('email_queue').whereNull('created_at').update({ created_at: db.fn.now() });
} catch (updateError) {
logger.debug('Email queue created_at backfill skipped', { error: updateError.message });
}
}
}
// Admin users table
const hasAdminUsersTable = await db.schema.hasTable('admin_users');
if (!hasAdminUsersTable) {
await db.schema.createTable('admin_users', (table) => {
table.increments('id').primary();
table.string('username').unique().notNullable();
table.string('email').unique().notNullable();
table.string('password_hash').notNullable();
table.boolean('is_active').defaultTo(true);
table.boolean('must_change_password').defaultTo(false);
table.datetime('password_changed_at');
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('updated_at').defaultTo(db.fn.now());
table.datetime('last_login');
table.string('last_login_ip');
table.string('language', 2).defaultTo('en');
});
} else {
// Check if updated_at column exists
const hasUpdatedAt = await db.schema.hasColumn('admin_users', 'updated_at');
if (!hasUpdatedAt) {
await db.schema.table('admin_users', (table) => {
table.datetime('updated_at');
});
// Set default value for existing rows
await db('admin_users').update({ updated_at: new Date() });
}
// Check if must_change_password column exists
const hasMustChangePassword = await db.schema.hasColumn('admin_users', 'must_change_password');
if (!hasMustChangePassword) {
await db.schema.table('admin_users', (table) => {
table.boolean('must_change_password').defaultTo(false);
});
}
// Check if password_changed_at column exists
const hasPasswordChangedAt = await db.schema.hasColumn('admin_users', 'password_changed_at');
if (!hasPasswordChangedAt) {
await db.schema.table('admin_users', (table) => {
table.datetime('password_changed_at');
});
}
// Check if last_login_ip column exists
const hasLastLoginIp = await db.schema.hasColumn('admin_users', 'last_login_ip');
if (!hasLastLoginIp) {
await db.schema.table('admin_users', (table) => {
table.string('last_login_ip');
});
}
const hasLanguage = await db.schema.hasColumn('admin_users', 'language');
if (!hasLanguage) {
await db.schema.table('admin_users', (table) => {
table.string('language', 2).defaultTo('en');
});
}
}
// Token revocation tables
const hasRevokedTokensTable = await db.schema.hasTable('revoked_tokens');
if (!hasRevokedTokensTable) {
await db.schema.createTable('revoked_tokens', (table) => {
table.increments('id').primary();
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
table.integer('user_id').nullable(); // User who owned the token
table.string('token_type', 20); // admin, gallery, etc.
table.timestamp('revoked_at').defaultTo(db.fn.now());
table.timestamp('expires_at').notNullable(); // When token would have expired
table.string('reason', 100); // password_change, logout, compromised, etc.
table.text('metadata'); // Additional JSON data
// Indexes for performance
table.index('token_id');
table.index('user_id');
table.index('expires_at'); // For cleanup
});
}
const hasUserTokenRevocationsTable = await db.schema.hasTable('user_token_revocations');
if (!hasUserTokenRevocationsTable) {
await db.schema.createTable('user_token_revocations', (table) => {
table.integer('user_id').primary();
table.timestamp('revoked_at').notNullable();
table.string('reason', 100);
// Index for quick lookups
table.index('revoked_at');
});
}
// Email configuration table
const hasEmailConfigTable = await db.schema.hasTable('email_configs');
if (!hasEmailConfigTable) {
await db.schema.createTable('email_configs', (table) => {
table.increments('id').primary();
table.string('smtp_host').notNullable();
table.integer('smtp_port').notNullable();
table.boolean('smtp_secure').defaultTo(false);
table.string('smtp_user');
table.string('smtp_pass');
table.string('from_email').notNullable();
table.string('from_name');
table.datetime('updated_at').defaultTo(db.fn.now());
});
}
// Email templates table
const hasEmailTemplatesTable = await db.schema.hasTable('email_templates');
if (!hasEmailTemplatesTable) {
await db.schema.createTable('email_templates', (table) => {
table.increments('id').primary();
table.string('template_key').unique().notNullable(); // 'gallery_created', 'expiration_warning', etc.
table.string('subject').notNullable();
table.text('body_html').notNullable();
table.text('body_text');
table.json('variables'); // Available template variables
table.datetime('updated_at').defaultTo(db.fn.now());
});
}
// App settings table
const hasAppSettingsTable = await db.schema.hasTable('app_settings');
if (!hasAppSettingsTable) {
await db.schema.createTable('app_settings', (table) => {
table.increments('id').primary();
table.string('setting_key').unique().notNullable();
table.json('setting_value');
table.string('setting_type'); // 'branding', 'theme', 'general'
table.datetime('updated_at').defaultTo(db.fn.now());
});
}
const defaultLanguageSetting = await db('app_settings')
.where('setting_key', 'default_language')
.first();
if (!defaultLanguageSetting) {
await db('app_settings').insert({
setting_key: 'default_language',
setting_value: JSON.stringify('en'),
setting_type: 'general',
updated_at: new Date(),
});
}
// Activity logs table
const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
if (!hasActivityLogsTable) {
await db.schema.createTable('activity_logs', (table) => {
table.increments('id').primary();
table.string('activity_type').notNullable(); // 'event_created', 'photos_uploaded', etc.
table.string('actor_type'); // 'admin', 'system', 'guest'
table.integer('actor_id');
table.string('actor_name');
table.json('metadata'); // Additional data about the activity
table.integer('event_id').references('id').inTable('events');
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('read_at').nullable();
});
} else {
// Check if read_at column exists
const hasReadAt = await db.schema.hasColumn('activity_logs', 'read_at');
if (!hasReadAt) {
await db.schema.table('activity_logs', (table) => {
table.datetime('read_at').nullable();
});
}
}
await ensureGlobalCategories();
}
// Ensure photo categories exist for new deployments
async function ensureGlobalCategories() {
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
if (!hasPhotoCategoriesTable) {
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
table.string('slug', 100).notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(db.fn.now());
table.unique(['slug', 'event_id']);
});
}
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
if (!hasCategoryIdColumn) {
await db.schema.alterTable('photos', (table) => {
table.integer('category_id').references('id').inTable('photo_categories');
});
}
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
if (!hasCmsPagesTable) {
await db.schema.createTable('cms_pages', (table) => {
table.increments('id').primary();
table.string('slug', 100).unique().notNullable();
table.text('title_en');
table.text('title_de');
table.text('content_en');
table.text('content_de');
table.string('logo_url').nullable();
table.boolean('use_external_url').notNullable().defaultTo(false);
table.string('external_url').nullable();
table.timestamp('updated_at').defaultTo(db.fn.now());
});
} else {
if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) {
// Online migration for existing deployments — see issue #324, per-page
// logo override for admin-customisable error pages.
await db.schema.alterTable('cms_pages', (table) => {
table.string('logo_url').nullable();
});
}
if (!(await db.schema.hasColumn('cms_pages', 'use_external_url'))) {
// Per-page toggle to redirect visitors to an external imprint /
// privacy-policy URL instead of rendering the internal CMS content.
await db.schema.alterTable('cms_pages', (table) => {
table.boolean('use_external_url').notNullable().defaultTo(false);
});
}
if (!(await db.schema.hasColumn('cms_pages', 'external_url'))) {
await db.schema.alterTable('cms_pages', (table) => {
table.string('external_url').nullable();
});
}
}
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0;
if (categoryCount === 0) {
const defaultCategories = [
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
{ name: 'Reception', slug: 'reception', is_global: true },
{ name: 'Portraits', slug: 'portraits', is_global: true },
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
{ name: 'Details', slug: 'details', is_global: true },
{ name: 'Party', slug: 'party', is_global: true },
];
await db('photo_categories').insert(defaultCategories);
}
const cmsPages = await db('cms_pages').select('slug');
const existingSlugs = cmsPages.map((page) => page.slug);
const defaultPages = [
{
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
{
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
// Customisable error pages — issue #324. Generic copy by default;
// admins can edit text + logo per page in the CMS Pages tab.
{
slug: 'not-found',
title_en: 'Page Not Found',
title_de: 'Seite nicht gefunden',
content_en: '<h2>Page Not Found</h2><p>The page you are looking for does not exist or has been moved.</p>',
content_de: '<h2>Seite nicht gefunden</h2><p>Die gesuchte Seite existiert nicht oder wurde verschoben.</p>',
updated_at: new Date(),
},
{
slug: 'gallery-not-found',
title_en: 'Gallery Not Found',
title_de: 'Galerie nicht gefunden',
content_en: '<h2>Gallery Not Found</h2><p>This gallery could not be found. The link may be incorrect, or the gallery may have expired or been archived. Please contact the organiser if you believe this is a mistake.</p>',
content_de: '<h2>Galerie nicht gefunden</h2><p>Diese Galerie konnte nicht gefunden werden. Der Link ist möglicherweise nicht korrekt, oder die Galerie ist abgelaufen oder wurde archiviert. Bitte kontaktieren Sie den Veranstalter, falls Sie glauben, dass dies ein Fehler ist.</p>',
updated_at: new Date(),
},
];
for (const page of defaultPages) {
if (!existingSlugs.includes(page.slug)) {
await db('cms_pages').insert(page);
}
}
}
// Helper function to log activities
async function logActivity(activityType, metadata = {}, eventId = null, actor = null) {
try {
// actor_id is integer-typed; some legacy callers pass a hex-string
// identifier (e.g. a 16-char guest fingerprint) which makes Postgres
// throw "invalid input syntax for type integer" and drop the entire
// log entry. Coerce anything non-integer to null and surface the
// string in actor_name so we don't lose the audit trail. Customer/
// admin actors are unaffected — their ids are already numeric.
const rawId = actor?.id;
const actorIdInt = Number.isInteger(rawId) ? rawId
: (typeof rawId === 'string' && /^\d+$/.test(rawId) ? Number(rawId) : null);
const actorName = actor?.name
|| (actorIdInt === null && rawId !== undefined && rawId !== null ? String(rawId) : null);
await db('activity_logs').insert({
activity_type: activityType,
actor_type: actor?.type || 'system',
actor_id: actorIdInt,
actor_name: actorName,
metadata: JSON.stringify(metadata),
event_id: eventId
});
} catch (error) {
logger.error('Failed to log activity:', { error: error.message });
}
}
module.exports = { db, initializeDatabase, logActivity, withRetry, reinitPool };