Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69fee5faba |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.79.1-beta.0"
|
||||
".": "3.80.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,22 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.80.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.1-beta.0...v3.80.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backup:** fold .picpeak restore into the Restore wizard's Upload source ([86324e7](https://github.com/PicPeak/picpeak/commit/86324e7da75069e61686b1b77495f02c33b12e1a))
|
||||
* first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip ([e513e83](https://github.com/PicPeak/picpeak/commit/e513e8345b73e37ebedc9c9ec09665ffc5773e23))
|
||||
* **setup:** add restore-from-backup branch to the first-run wizard ([a95ee47](https://github.com/PicPeak/picpeak/commit/a95ee473ae7fb2bf3c94c610724476701145d113))
|
||||
* **setup:** per-feature config step after feature selection ([07b450a](https://github.com/PicPeak/picpeak/commit/07b450a954a53781d23a71749552e4101c637777))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** address .picpeak review — table filter, superuser guard, tests ([fa7665c](https://github.com/PicPeak/picpeak/commit/fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2))
|
||||
* **setup:** keep the first-run wizard light regardless of dark mode ([d4b143f](https://github.com/PicPeak/picpeak/commit/d4b143f313d00f2a30abcbc1a880c140d7455e7b))
|
||||
|
||||
## [3.79.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.0-beta.0...v3.79.1-beta.0) (2026-07-02)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,16 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
|
||||
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
|
||||
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
|
||||
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
|
||||
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
|
||||
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
|
||||
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
# Generate with: openssl rand -base64 32
|
||||
#MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Smoke tests for backupService's config resolution + file-collection
|
||||
* and manifest validation paths — safety net ahead of the god-file
|
||||
* decomposition.
|
||||
*
|
||||
* Uses the same real-SQLite harness as
|
||||
* backupService.configurableWalker.test.js (bootCrmDb + a temp
|
||||
* STORAGE_PATH) rather than the broken deep-mock approach in
|
||||
* backupService.enhanced.test.js.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let backupManifest;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
backupManifest = require('../../src/services/backupManifest');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
// Reset the storage tree so each test starts from a pristine walk.
|
||||
await fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return abs;
|
||||
}
|
||||
|
||||
async function insertBackupSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: value,
|
||||
setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
|
||||
describe('getBackupConfig', () => {
|
||||
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
|
||||
await insertBackupSetting('backup_enabled', 'true');
|
||||
await insertBackupSetting('backup_include_archived', 'false');
|
||||
await insertBackupSetting('backup_retention_days', '30');
|
||||
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
|
||||
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
|
||||
// Non-backup settings must not leak into the backup config.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'general_site_name',
|
||||
setting_value: 'PicPeak',
|
||||
setting_type: 'general',
|
||||
});
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config.backup_enabled).toBe(true);
|
||||
expect(config.backup_include_archived).toBe(false);
|
||||
expect(config.backup_retention_days).toBe(30);
|
||||
expect(config.backup_destination_path).toBe('/backups/picpeak');
|
||||
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
|
||||
expect(config).not.toHaveProperty('general_site_name');
|
||||
// Raw (unparsed) values are preserved on the non-enumerable __raw.
|
||||
expect(String(config.__raw.backup_retention_days)).toBe('30');
|
||||
});
|
||||
|
||||
it('returns an empty config object (not null) when nothing is configured', async () => {
|
||||
const config = await backupService.getBackupConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(Object.keys(config)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilesToBackup', () => {
|
||||
it('returns an empty list on a pristine storage tree', async () => {
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
|
||||
const content = 'not really a jpeg';
|
||||
const abs = seedFile('events/active/E9/pic.jpg', content);
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.path).toBe(abs);
|
||||
expect(entry.size).toBe(Buffer.byteLength(content));
|
||||
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
|
||||
// realm under Jest and fails the cross-realm instanceof check.
|
||||
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackupManifest', () => {
|
||||
it('round-trips a generated manifest as valid', async () => {
|
||||
seedFile('events/active/E1/a.jpg', 'aaa');
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
|
||||
const manifest = await backupManifest.generateManifest({
|
||||
backupType: 'full',
|
||||
backupPath: '/backup/run-1',
|
||||
files,
|
||||
});
|
||||
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
|
||||
await backupManifest.saveManifest(manifest, manifestPath, 'json');
|
||||
|
||||
const result = await backupService.validateBackupManifest(manifestPath);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest.backup.type).toBe('full');
|
||||
expect(result.manifest.files.count).toBe(files.length);
|
||||
expect(result.manifest.verification.total_checksum).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags a manifest missing required sections as invalid', async () => {
|
||||
const badPath = path.join(storagePath, 'manifest-broken.json');
|
||||
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
|
||||
|
||||
const result = await backupService.validateBackupManifest(badPath);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toMatch(/Missing required section/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
// Validates the engine-neutral .picpeak export: it must produce a real zip with
|
||||
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
|
||||
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
|
||||
// bootCrmDb MUST run before requiring the service (which transitively requires
|
||||
// db.js) so the export reads this test's DB, not the default path.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
async function readZip(filePath) {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const entries = Object.keys(await zip.entries());
|
||||
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
|
||||
await zip.close();
|
||||
return { entries, manifest };
|
||||
}
|
||||
|
||||
describe('picpeak export (.picpeak logical export)', () => {
|
||||
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
expect(filePath.endsWith('.picpeak')).toBe(true);
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
|
||||
expect(manifest.format).toBe(1);
|
||||
expect(manifest.kind).toBe('picpeak-backup');
|
||||
expect(manifest.database.engine).toBe('sqlite');
|
||||
expect(manifest.options.includePhotos).toBe(false);
|
||||
expect(manifest.contains_secrets).toBe(true);
|
||||
// Migrations seed real tables (e.g. app_settings) — expect several.
|
||||
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(manifest.tables)).toContain('app_settings');
|
||||
|
||||
const { entries, manifest: zipped } = await readZip(filePath);
|
||||
expect(entries).toContain('manifest.json');
|
||||
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
|
||||
expect(entries).toContain('data/app_settings.ndjson');
|
||||
// Manifest inside the zip matches the returned one.
|
||||
expect(zipped.tables).toEqual(manifest.tables);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never exports knex bookkeeping tables', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const names = Object.keys(manifest.tables);
|
||||
expect(names).not.toContain('knex_migrations');
|
||||
expect(names).not.toContain('knex_migrations_lock');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('row counts in the manifest match the NDJSON line counts', async () => {
|
||||
// Insert a couple of settings so at least one table is non-empty.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const buf = await zip.entryData('data/app_settings.ndjson');
|
||||
await zip.close();
|
||||
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
|
||||
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
|
||||
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
'use strict';
|
||||
|
||||
// Full .picpeak roundtrip on a temp SQLite DB:
|
||||
// 1. seed a "backup" instance (admin A + a marker setting)
|
||||
// 2. export → .picpeak
|
||||
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
|
||||
// 4. import the backup with currentAdminId = B
|
||||
// 5. assert the backup data is restored AND the current account (B) survives,
|
||||
// while the backup's admin (A) is also present (different email → added).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
let importFromPicpeak;
|
||||
let validateManifest;
|
||||
let superAdminRoleId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
superAdminRoleId = role.id;
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
const adminRow = (email, hash) => ({
|
||||
username: email,
|
||||
email,
|
||||
password_hash: hash,
|
||||
role_id: superAdminRoleId,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
async function setMarker(value) {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
}
|
||||
async function getMarker() {
|
||||
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
|
||||
return row ? JSON.parse(row.setting_value) : null;
|
||||
}
|
||||
|
||||
describe('.picpeak roundtrip (export → import)', () => {
|
||||
it('restores backup data and preserves the current account', async () => {
|
||||
// 1. Seed the "source" instance.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
|
||||
await setMarker('from_backup');
|
||||
|
||||
// 2. Export.
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// 3. Simulate a reinstall: fresh current admin B, mutated data.
|
||||
await db('admin_users').del();
|
||||
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
|
||||
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
|
||||
await setMarker('mutated_after_backup');
|
||||
|
||||
// 4. Import, preserving the current admin.
|
||||
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
|
||||
expect(result.restored).toBe(true);
|
||||
expect(result.tables).toBeGreaterThan(0);
|
||||
|
||||
// 5a. Backup data restored (marker reverted to the backup value).
|
||||
expect(await getMarker()).toBe('from_backup');
|
||||
|
||||
// 5b. The backup's admin is present (different email → added).
|
||||
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
|
||||
expect(a).toBeTruthy();
|
||||
expect(a.password_hash).toBe('HASH_A');
|
||||
|
||||
// 5c. The current account SURVIVES the override, with its own credentials.
|
||||
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
|
||||
expect(b).toBeTruthy();
|
||||
expect(b.password_hash).toBe('HASH_B');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('overwrites a backup admin that collides with the current account email', async () => {
|
||||
// Source has an admin at the SAME email the current operator will use.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
|
||||
await setMarker('collision_case');
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// Reinstall: current admin uses the same email but a NEW password.
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
|
||||
// Exactly one admin at that email, and it keeps the CURRENT password.
|
||||
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].password_hash).toBe('NEW_HASH');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('restores files/ and reports filesRestored', async () => {
|
||||
// A business-doc that lives in storage → travels in the backup.
|
||||
const docDir = path.join(tmpDir, 'business-docs');
|
||||
const marker = path.join(docDir, 'roundtrip-doc.txt');
|
||||
fs.mkdirSync(docDir, { recursive: true });
|
||||
fs.writeFileSync(marker, 'hello');
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
fs.rmSync(marker); // delete on disk so the restore must bring it back
|
||||
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
|
||||
expect(fs.existsSync(marker)).toBe(true);
|
||||
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
fs.rmSync(docDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('.picpeak manifest validation', () => {
|
||||
it('rejects a database-engine mismatch', async () => {
|
||||
// Harness runs on SQLite, so a pg manifest must be refused.
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a backup from a newer schema (forward-only)', async () => {
|
||||
// validateManifest reads knex_migrations for the target's latest migration;
|
||||
// the harness has none, so create it with an older migration than the backup.
|
||||
await db.schema.createTable('knex_migrations', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name');
|
||||
t.integer('batch');
|
||||
t.timestamp('migration_time');
|
||||
});
|
||||
try {
|
||||
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1,
|
||||
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
|
||||
tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
|
||||
} finally {
|
||||
await db.schema.dropTableIfExists('knex_migrations');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a file that is not a PicPeak backup', async () => {
|
||||
const blockers = await validateManifest({ some: 'random-json' });
|
||||
expect(blockers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
|
||||
*
|
||||
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
|
||||
* the script in a child process (--email <addr> --yes) pointed at the same
|
||||
* DB file, and asserts the four MFA columns are zeroed. The script runs in
|
||||
* its own process with its own knex connection; the parent connection is
|
||||
* idle during the spawn so the SQLite write lock isn't contended.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
|
||||
|
||||
async function seedEnrolledAdmin(email) {
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: email.split('@')[0],
|
||||
email,
|
||||
password_hash: 'x',
|
||||
is_active: true,
|
||||
two_factor_enabled: true,
|
||||
two_factor_secret: 'iv.tag.ct',
|
||||
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
|
||||
two_factor_enrolled_at: new Date(),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('zeroes the four MFA columns for the targeted admin', async () => {
|
||||
const email = 'reset-me@example.com';
|
||||
const id = await seedEnrolledAdmin(email);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const row = await db('admin_users').where({ id }).first();
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
expect(row.two_factor_enrolled_at).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a different admin untouched', async () => {
|
||||
const targetEmail = 'target@example.com';
|
||||
const bystanderEmail = 'bystander@example.com';
|
||||
const targetId = await seedEnrolledAdmin(targetEmail);
|
||||
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
|
||||
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const target = await db('admin_users').where({ id: targetId }).first();
|
||||
const bystander = await db('admin_users').where({ id: bystanderId }).first();
|
||||
expect(Number(target.two_factor_enabled)).toBe(0);
|
||||
expect(Number(bystander.two_factor_enabled)).toBe(1);
|
||||
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* HTTP smoke tests for the core admin event CRUD endpoints:
|
||||
* POST /api/admin/events (create)
|
||||
* GET /api/admin/events (list + pagination)
|
||||
* GET /api/admin/events/:id (detail + stats)
|
||||
* PUT /api/admin/events/:id (update)
|
||||
* DELETE /api/admin/events/:id (cascade delete)
|
||||
*
|
||||
* Safety net ahead of the adminEvents.js god-file decomposition —
|
||||
* pins the request/response contracts of the main CRUD paths using
|
||||
* the same real-SQLite harness as slideshowAdmin.test.js.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-smoke-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, adminId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Wedding',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('admin events CRUD endpoints (smoke)', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
// bootCrmDb's full migration run intermittently exceeds Jest's default
|
||||
// 5s beforeAll timeout on slower CI runners; raise it.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('email_queue').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
it('401s without an admin token', async () => {
|
||||
const res = await request(app).get('/api/admin/events');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('creates an event, mints slug + share link and persists the row', async () => {
|
||||
const res = await auth(request(app).post('/api/admin/events')).send({
|
||||
event_type: 'wedding',
|
||||
event_name: 'Smoke Wedding',
|
||||
event_date: '2026-09-01',
|
||||
// Field requirements default to ON (getEventFieldRequirements)
|
||||
// so customer + admin contact data must be supplied.
|
||||
customer_name: 'Client Person',
|
||||
customer_email: 'client@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
require_password: false,
|
||||
is_draft: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.slug).toContain('wedding-smoke-wedding');
|
||||
expect(typeof res.body.share_link).toBe('string');
|
||||
expect(res.body.is_draft).toBe(true);
|
||||
|
||||
const row = await db('events').where({ id: res.body.id }).first();
|
||||
expect(row).toBeDefined();
|
||||
expect(row.event_name).toBe('Smoke Wedding');
|
||||
expect(row.created_by).toBe(adminId);
|
||||
|
||||
// Folder structure is created under STORAGE_PATH/events/active/<slug>.
|
||||
const eventDir = path.join(process.env.STORAGE_PATH, 'events/active', res.body.slug);
|
||||
expect(fs.existsSync(path.join(eventDir, 'collages'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(eventDir, 'individual'))).toBe(true);
|
||||
|
||||
// Draft creates must NOT queue the gallery_created email.
|
||||
const queued = await db('email_queue').where({ event_id: res.body.id });
|
||||
expect(queued).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('400s on an invalid event type', async () => {
|
||||
const res = await auth(request(app).post('/api/admin/events')).send({
|
||||
event_type: 'not-a-real-type',
|
||||
event_name: 'Broken',
|
||||
require_password: false,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(Array.isArray(res.body.errors)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('lists events with pagination metadata and photo counts', async () => {
|
||||
await insertEvent(db, adminId, { event_name: 'Alpha' });
|
||||
await insertEvent(db, adminId, { event_name: 'Beta' });
|
||||
|
||||
const res = await auth(request(app).get('/api/admin/events'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.events).toHaveLength(2);
|
||||
expect(res.body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 });
|
||||
for (const ev of res.body.events) {
|
||||
expect(ev.photo_count).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
it('returns the event with photo/view stats', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Detail Event' });
|
||||
const res = await auth(request(app).get(`/api/admin/events/${id}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_name).toBe('Detail Event');
|
||||
expect(res.body.photo_count).toBe(0);
|
||||
expect(res.body.total_views).toBe(0);
|
||||
expect(res.body.total_downloads).toBe(0);
|
||||
expect(Array.isArray(res.body.recent_photos)).toBe(true);
|
||||
});
|
||||
|
||||
it('404s for an unknown event id', async () => {
|
||||
const res = await auth(request(app).get('/api/admin/events/999999'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('updates mutable fields and persists them', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Before' });
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
|
||||
event_name: 'After',
|
||||
welcome_message: 'Hello guests',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.event_name).toBe('After');
|
||||
expect(row.welcome_message).toBe('Hello guests');
|
||||
});
|
||||
|
||||
it('404s when updating a missing event', async () => {
|
||||
const res = await auth(request(app).put('/api/admin/events/999999')).send({
|
||||
event_name: 'Ghost',
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('cascade-deletes the event row', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).delete(`/api/admin/events/${id}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.message).toMatch(/deleted/i);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s when deleting a missing event', async () => {
|
||||
const res = await auth(request(app).delete('/api/admin/events/999999'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* HTTP-level tests for the admin TOTP MFA feature (#738).
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
|
||||
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
|
||||
* /api/admin/auth (src/routes/adminAuth.js).
|
||||
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
|
||||
* (src/routes/auth.js, mounted /api/auth).
|
||||
*
|
||||
* Uses the same real-SQLite harness as the CRM route tests
|
||||
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
|
||||
* generated in-test via otplib's authenticator against the secret the
|
||||
* /setup endpoint returns in plaintext.
|
||||
*
|
||||
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
|
||||
* first require of db.js — mirror adminCrmAuth.test.js exactly.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
|
||||
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
|
||||
// tests don't need a token. Be explicit so a leaked env can't flip it on.
|
||||
delete process.env.RECAPTCHA_SECRET_KEY;
|
||||
|
||||
const request = require('supertest');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminApp; // /api/admin/auth (enrollment)
|
||||
let authApp; // /api/auth (login challenge)
|
||||
|
||||
/**
|
||||
* Seed a bare admin (password known) and return its id + login creds.
|
||||
* seedMinimal always creates username 'tester'; we need distinct rows per
|
||||
* scenario, so insert directly with a unique username/email.
|
||||
*/
|
||||
async function seedAdmin({ username, superAdmin = false } = {}) {
|
||||
const password = 'correct-horse';
|
||||
const passwordHash = await bcrypt.hash(password, 4);
|
||||
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const row = {
|
||||
username: uname,
|
||||
email: `${uname}@example.com`,
|
||||
password_hash: passwordHash,
|
||||
must_change_password: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
};
|
||||
if (superAdmin) {
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
if (!role) throw new Error('super_admin role not seeded');
|
||||
row.role_id = role.id;
|
||||
}
|
||||
const inserted = await db('admin_users').insert(row).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
return { id, username: uname, password };
|
||||
}
|
||||
|
||||
/** Run the full setup→enable enrollment against the live app. Returns
|
||||
* the plaintext TOTP secret (for later login codes) and recovery codes. */
|
||||
async function enroll(adminId) {
|
||||
const token = mintAdminToken(adminId);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(setup.status).toBe(200);
|
||||
const secret = setup.body.secret;
|
||||
|
||||
const enable = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(enable.status).toBe(200);
|
||||
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
|
||||
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
|
||||
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.secret).toEqual(expect.any(String));
|
||||
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
|
||||
|
||||
// Not yet enabled: status must still report disabled.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
|
||||
// And the row stores an encrypted secret (not the plaintext one).
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeTruthy();
|
||||
expect(row.two_factor_secret).not.toBe(res.body.secret);
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
});
|
||||
|
||||
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
|
||||
expect(Array.isArray(recoveryCodes)).toBe(true);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(10);
|
||||
expect(status.body.enrolledAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
const valid = authenticator.generate(setup.body.secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enable before setup is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '123456' });
|
||||
// No provisional secret → ValidationError (400).
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
|
||||
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
|
||||
expect(noToken.status).toBe(401);
|
||||
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
|
||||
expect(setup.status).toBe(401);
|
||||
});
|
||||
|
||||
// Regression guard for #735: super_admin used to be blocked from enrolling.
|
||||
// Enrollment operates on req.admin.id and is role-agnostic — assert a
|
||||
// super_admin can complete the full setup→enable flow.
|
||||
it('#735 regression — a super_admin can enroll in MFA', async () => {
|
||||
const admin = await seedAdmin({ superAdmin: true });
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
it('requires a valid code; a wrong code is rejected and state persists', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { token } = await enroll(admin.id);
|
||||
|
||||
const bad = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '000000' });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const stillOn = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(stillOn.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a valid TOTP disables MFA and clears the stored secret', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(0);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBe(true);
|
||||
expect(res.body.mfaToken).toEqual(expect.any(String));
|
||||
expect(res.body.user).toBeUndefined(); // no completed session
|
||||
// No admin auth cookie should have been set on the challenge response.
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.join(';')).not.toMatch(/adminToken/i);
|
||||
});
|
||||
|
||||
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.username).toBe(admin.username);
|
||||
});
|
||||
|
||||
it('login/mfa with a valid TOTP completes the session', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const { mfaToken } = challenge.body;
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken, code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('MFA_INVALID');
|
||||
expect(res.body.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a recovery code logs in and is then single-use (second use fails)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes } = await enroll(admin.id);
|
||||
const recovery = recoveryCodes[0];
|
||||
|
||||
// First challenge + recovery-code exchange succeeds.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code: recovery });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// recoveryCodesRemaining dropped by one.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(9);
|
||||
|
||||
// Second use of the SAME recovery code must fail.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const second = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code: recovery });
|
||||
expect(second.status).toBe(401);
|
||||
expect(second.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -72,16 +72,9 @@ jest.mock('../../src/services/businessProfileService', () => ({
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/documentSequences', () => {
|
||||
const claimNextSequence = jest.fn(async () => 42);
|
||||
// Delegates to the claimNextSequence mock so call-count assertions
|
||||
// below keep observing sequence claims.
|
||||
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||||
const seq = await claimNextSequence(kind, 2026, trx);
|
||||
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||||
});
|
||||
return { claimNextSequence, nextDocumentNumber };
|
||||
});
|
||||
jest.mock('../../src/utils/documentSequences', () => ({
|
||||
claimNextSequence: jest.fn(async () => 42),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* Smoke tests for invoiceService's primary flows ahead of the god-file
|
||||
* decomposition — createInvoice happy path (incl. the line-item
|
||||
* totals/VAT math), list/get reads, and the status-transition guards
|
||||
* on cancelInvoice / releaseForDelivery.
|
||||
*
|
||||
* Uses the same deep-mocked db pattern as
|
||||
* invoiceService.installmentPlan.test.js — chains are queued per table
|
||||
* and assertions probe insert/update call shapes rather than SQL.
|
||||
*/
|
||||
|
||||
const chains = [];
|
||||
function makeChain() {
|
||||
const c = {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: jest.fn(function () { return this; }),
|
||||
whereNotNull: jest.fn(function () { return this; }),
|
||||
andWhere: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
limit: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return this; }),
|
||||
sum: jest.fn(function () { return this; }),
|
||||
count: jest.fn(function () { return this; }),
|
||||
clone: jest.fn(function () { return this; }),
|
||||
clearSelect: jest.fn(function () { return this; }),
|
||||
clearOrder: jest.fn(function () { return this; }),
|
||||
offset: jest.fn(function () { return this; }),
|
||||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||||
insert: jest.fn(function () { return this; }),
|
||||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||||
onConflict: jest.fn(function () { return this; }),
|
||||
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
||||
merge: jest.fn(function () { return Promise.resolve(1); }),
|
||||
increment: jest.fn(function () { return this; }),
|
||||
forUpdate: jest.fn(function () { return this; }),
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
};
|
||||
chains.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
const tableChains = {};
|
||||
function pickChainFor(name) {
|
||||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||||
return tableChains[name];
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/documentSequences', () => {
|
||||
const claimNextSequence = jest.fn(async () => 42);
|
||||
// Delegates to the claimNextSequence mock so call-count assertions
|
||||
// below keep observing sequence claims.
|
||||
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||||
const seq = await claimNextSequence(kind, 2026, trx);
|
||||
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||||
});
|
||||
return { claimNextSequence, nextDocumentNumber };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
jest.clearAllMocks();
|
||||
}
|
||||
|
||||
const activeCustomer = {
|
||||
id: 5, is_active: 1, feature_bills: 1,
|
||||
billing_cadence: 'per_event', preferred_language: 'de',
|
||||
};
|
||||
|
||||
describe('createInvoice — happy path + totals', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||
pickChainFor('invoices')._insertResult = [{ id: 777 }];
|
||||
|
||||
const result = await invoiceService.createInvoice({
|
||||
customerAccountId: 5,
|
||||
vatRate: 8.1,
|
||||
lineItems: [
|
||||
// 2 × 100.00 = 200.00
|
||||
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
|
||||
// 50.00 with 10% discount = 45.00
|
||||
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
|
||||
// Parent header — total auto-resolves from priced sub-items (350.00)
|
||||
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
|
||||
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
|
||||
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
|
||||
],
|
||||
}, 1);
|
||||
|
||||
expect(result.invoiceIds).toEqual([777]);
|
||||
|
||||
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
|
||||
// double-count. VAT = round(59500 × 8.1%) = 4820.
|
||||
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
invoice_number: 'R-2026-0042',
|
||||
customer_account_id: 5,
|
||||
currency: 'CHF',
|
||||
status: 'scheduled',
|
||||
net_amount_minor: 59500,
|
||||
vat_rate: 8.1,
|
||||
vat_amount_minor: 4820,
|
||||
shipping_amount_minor: 0,
|
||||
total_amount_minor: 64320,
|
||||
installment_total: 1,
|
||||
}));
|
||||
// Exactly one sequence number claimed for a single-row create.
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
||||
// Line items landed in invoice_line_items.
|
||||
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('409s on a deactivated customer before touching the sequence', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: 5, vatRate: 0, lineItems: [],
|
||||
}, 1)).rejects.toMatchObject({ statusCode: 409 });
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: 5,
|
||||
vatRate: 7.7,
|
||||
lineItems: [
|
||||
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
|
||||
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
|
||||
],
|
||||
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('lists invoices with total + pagination echo', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, invoice_number: 'R-2026-0001' },
|
||||
{ id: 2, invoice_number: 'R-2026-0002' },
|
||||
];
|
||||
pickChainFor('invoices')._firstValue = { total: 7 };
|
||||
|
||||
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
|
||||
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.total).toBe(7);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.pageSize).toBe(10);
|
||||
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
|
||||
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
|
||||
pickChainFor('invoice_line_items as li')._selectResult = [
|
||||
{ id: 30, position: 1, description: 'Shoot' },
|
||||
];
|
||||
pickChainFor('invoice_payment_log')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.getInvoiceById(3);
|
||||
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.payments).toEqual([]);
|
||||
});
|
||||
|
||||
it('getInvoiceById returns null for an unknown id', async () => {
|
||||
pickChainFor('invoices')._firstValue = undefined;
|
||||
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
|
||||
};
|
||||
const result = await invoiceService.cancelInvoice(9, 1);
|
||||
expect(result).toEqual({ cancelled: true, stornoId: null });
|
||||
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'cancelled' })
|
||||
);
|
||||
});
|
||||
|
||||
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 9, status: 'cancelled', kind: 'invoice',
|
||||
};
|
||||
await expect(invoiceService.cancelInvoice(9, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 10, status: 'sent', kind: 'storno',
|
||||
};
|
||||
await expect(invoiceService.cancelInvoice(10, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 11, status: 'sent', kind: 'invoice',
|
||||
};
|
||||
await expect(invoiceService.releaseForDelivery(11, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Unit tests for mfaService — admin TOTP MFA (#738).
|
||||
*
|
||||
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
|
||||
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
|
||||
* derivation has key material (the service derives the AES key from
|
||||
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
|
||||
*/
|
||||
|
||||
// Must be set BEFORE the service is required — the key is derived lazily per
|
||||
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
|
||||
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
describe('mfaService — secret encryption (AES-256-GCM)', () => {
|
||||
it('round-trips encrypt → decrypt to the original secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const blob = mfaService.encryptSecret(secret);
|
||||
expect(blob).toEqual(expect.any(String));
|
||||
expect(blob).not.toContain(secret); // stored form is not plaintext
|
||||
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
|
||||
expect(mfaService.decryptSecret(blob)).toBe(secret);
|
||||
});
|
||||
|
||||
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const a = mfaService.encryptSecret(secret);
|
||||
const b = mfaService.encryptSecret(secret);
|
||||
expect(a).not.toBe(b);
|
||||
expect(mfaService.decryptSecret(a)).toBe(secret);
|
||||
expect(mfaService.decryptSecret(b)).toBe(secret);
|
||||
});
|
||||
|
||||
it('throws when decrypting a malformed blob (wrong segment count)', () => {
|
||||
expect(() => mfaService.decryptSecret('garbage')).toThrow();
|
||||
expect(() => mfaService.decryptSecret('only.two')).toThrow();
|
||||
});
|
||||
|
||||
it('throws when the auth tag / ciphertext is tampered with', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
|
||||
// Flip a character in the ciphertext → GCM auth check must fail.
|
||||
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
|
||||
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — TOTP verification', () => {
|
||||
it('accepts a freshly generated code for the plaintext secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates whitespace in the submitted code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty inputs rather than throwing', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
expect(mfaService.verifyTotp('', secret)).toBe(false);
|
||||
expect(mfaService.verifyTotp('123456', '')).toBe(false);
|
||||
expect(mfaService.verifyTotp(null, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
|
||||
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
|
||||
expect(uri).toContain(`secret=${secret}`);
|
||||
});
|
||||
|
||||
it('builds a PNG data-URL QR for the URI', async () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
const qr = await mfaService.buildQrDataUrl(uri);
|
||||
expect(qr).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — recovery codes', () => {
|
||||
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(new Set(plain).size).toBe(10);
|
||||
expect(new Set(hashed).size).toBe(10);
|
||||
// Hashes are bcrypt, not the plaintext.
|
||||
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
|
||||
plain.forEach((p) => expect(hashed).not.toContain(p));
|
||||
});
|
||||
|
||||
it('formats a raw code into 4-char groups', () => {
|
||||
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
|
||||
});
|
||||
|
||||
it('consumes a valid recovery code once and removes it (single-use)', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const target = plain[3];
|
||||
|
||||
const first = await mfaService.consumeRecoveryCode(target, hashed);
|
||||
expect(first.matched).toBe(true);
|
||||
expect(first.remainingHashes).toHaveLength(9);
|
||||
|
||||
// Reusing the same code against the reduced set must now fail.
|
||||
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
|
||||
expect(reuse.matched).toBe(false);
|
||||
expect(reuse.remainingHashes).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('matches case-insensitively and trims whitespace', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
|
||||
expect(res.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code and leaves the hash set unchanged', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('handles empty / missing input safely', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toBe(hashed);
|
||||
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
|
||||
expect(noHashes.matched).toBe(false);
|
||||
expect(noHashes.remainingHashes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — parseRecoveryCodes', () => {
|
||||
it('parses a JSON string array', () => {
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
|
||||
});
|
||||
it('passes an already-array through', () => {
|
||||
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
it('returns [] for null / garbage / non-array JSON', () => {
|
||||
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — isEnrolled coercion', () => {
|
||||
it('treats true / 1 / "1" as enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
|
||||
});
|
||||
it('treats false / 0 / null / missing as not enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
|
||||
expect(mfaService.isEnrolled({})).toBe(false);
|
||||
expect(mfaService.isEnrolled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Migration 151: admin MFA (TOTP) enrollment support — issue #738.
|
||||
*
|
||||
* The `admin_users.two_factor_enabled` / `two_factor_secret` columns already
|
||||
* exist from the legacy migration 016 but were never wired to any code. This
|
||||
* migration adds the two columns the real TOTP flow needs on top of them:
|
||||
*
|
||||
* - two_factor_recovery_codes: JSON array of one-time backup codes, stored
|
||||
* HASHED (never plaintext), so a locked-out admin can log in without the
|
||||
* authenticator. Consumed on use.
|
||||
* - two_factor_enrolled_at: when the admin completed enrollment (audit /
|
||||
* display only).
|
||||
*
|
||||
* The TOTP secret itself continues to live in the existing `two_factor_secret`
|
||||
* column, but is now stored ENCRYPTED at rest (AES-256-GCM) by mfaService —
|
||||
* the column type is unchanged (the encrypted blob is short).
|
||||
*
|
||||
* Additive and idempotent: only adds columns, guarded by hasColumn, so it is
|
||||
* safe to re-run and touches no existing data.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
const hasEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled');
|
||||
const hasSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Backfill the legacy columns too, in case an install somehow lacks them
|
||||
// (016 is a legacy migration; guard defensively).
|
||||
if (!hasEnabled) {
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
}
|
||||
if (!hasSecret) {
|
||||
t.string('two_factor_secret').nullable();
|
||||
}
|
||||
if (!hasRecovery) {
|
||||
t.text('two_factor_recovery_codes').nullable();
|
||||
}
|
||||
if (!hasEnrolledAt) {
|
||||
t.timestamp('two_factor_enrolled_at').nullable();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Only drop what THIS migration added; leave the legacy 016 columns.
|
||||
if (hasRecovery) {
|
||||
t.dropColumn('two_factor_recovery_codes');
|
||||
}
|
||||
if (hasEnrolledAt) {
|
||||
t.dropColumn('two_factor_enrolled_at');
|
||||
}
|
||||
});
|
||||
};
|
||||
Generated
+72
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.74.0-beta.0",
|
||||
"version": "3.80.0-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.74.0-beta.0",
|
||||
"version": "3.80.0-beta.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -40,6 +40,7 @@
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
@@ -2703,6 +2704,56 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-thirty-two": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-default": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-v11": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
@@ -9371,6 +9422,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@otplib/preset-v11": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -11668,6 +11730,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thirty-two": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||
"engines": {
|
||||
"node": ">=0.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.79.1-beta.0",
|
||||
"version": "3.80.0-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -46,9 +46,11 @@
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "8.5.10",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -57,11 +59,10 @@
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"swissqrbill": "^4.3.0",
|
||||
"tar": ">=7.5.16",
|
||||
"uuid": "^11.1.1",
|
||||
"winston": "^3.8.2",
|
||||
"zxcvbn": "^4.4.2",
|
||||
"postcss": "8.5.10",
|
||||
"tar": ">=7.5.16"
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* reset-admin-mfa.js — disable two-factor auth for a locked-out admin (#738).
|
||||
*
|
||||
* Break-glass recovery for when an admin loses their authenticator AND their
|
||||
* recovery codes. Clears the MFA state so the admin can log in with just their
|
||||
* password and re-enroll from Settings.
|
||||
*
|
||||
* Usage (inside the running backend container):
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --email admin@example.com
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
*
|
||||
* Flags:
|
||||
* --email <addr> target a single admin by email (or --username <name>)
|
||||
* --all reset MFA for EVERY admin (full lockout / break-glass)
|
||||
* --yes non-interactive (skip the confirmation prompt)
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const { db, logActivity } = require('../src/database/db');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (f) => args.includes(f);
|
||||
const getOption = (name) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
|
||||
};
|
||||
|
||||
const force = hasFlag('--yes') || hasFlag('--force') || hasFlag('--non-interactive');
|
||||
const all = hasFlag('--all');
|
||||
const email = getOption('email');
|
||||
const username = getOption('username');
|
||||
|
||||
const MFA_CLEAR = {
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
function ask(prompt) {
|
||||
if (force) return Promise.resolve('yes');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a); }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n========================================');
|
||||
console.log('PicPeak Admin MFA Reset Tool');
|
||||
console.log('========================================\n');
|
||||
|
||||
if (!all && !email && !username) {
|
||||
console.error('❌ Specify a target: --email <addr>, --username <name>, or --all');
|
||||
console.log(' e.g. node scripts/reset-admin-mfa.js --email admin@example.com');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve target admins.
|
||||
let targets;
|
||||
if (all) {
|
||||
targets = await db('admin_users').select('id', 'username', 'email', 'two_factor_enabled');
|
||||
} else {
|
||||
const q = db('admin_users');
|
||||
if (email) q.where({ email });
|
||||
if (username) q.where({ username });
|
||||
targets = await q.select('id', 'username', 'email', 'two_factor_enabled');
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.error('❌ No matching admin user found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enrolled = targets.filter((t) => t.two_factor_enabled === true || t.two_factor_enabled === 1);
|
||||
console.log(`Matched ${targets.length} admin(s); ${enrolled.length} currently have MFA enabled:`);
|
||||
for (const t of targets) {
|
||||
const flag = (t.two_factor_enabled === true || t.two_factor_enabled === 1) ? 'MFA ON' : 'mfa off';
|
||||
console.log(` - ${t.username} <${t.email}> [${flag}]`);
|
||||
}
|
||||
|
||||
const confirm = await ask('\nDisable MFA for the above? (yes/no): ');
|
||||
const normalized = String(confirm).trim().toLowerCase();
|
||||
if (normalized !== 'yes' && normalized !== 'y') {
|
||||
console.log('\n❌ Cancelled. No changes made.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ids = targets.map((t) => t.id);
|
||||
const updated = await db('admin_users').whereIn('id', ids).update(MFA_CLEAR);
|
||||
|
||||
for (const t of targets) {
|
||||
try {
|
||||
await logActivity('admin_mfa_reset_cli',
|
||||
{ admin_id: t.id, via: 'cli' },
|
||||
null,
|
||||
{ type: 'system', id: 0, name: 'reset-admin-mfa.js' }
|
||||
);
|
||||
} catch (_) { /* activity log is best-effort */ }
|
||||
}
|
||||
|
||||
console.log(`\n✅ MFA disabled for ${updated} admin(s). They can now log in with just their password and re-enroll from Settings → Security.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('❌ Failed to reset MFA:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,7 +9,6 @@ const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Get the storage path from environment or default
|
||||
@@ -221,14 +220,14 @@ const createCustomUploader = (config) => {
|
||||
const uploadTimeoutMiddleware = (timeout = 300000) => {
|
||||
return (req, res, next) => {
|
||||
req.setTimeout(timeout, () => {
|
||||
logger.error('Upload request timed out');
|
||||
console.error('Upload request timed out');
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({ error: 'Upload request timed out' });
|
||||
}
|
||||
});
|
||||
|
||||
res.setTimeout(timeout, () => {
|
||||
logger.error('Upload response timed out');
|
||||
console.error('Upload response timed out');
|
||||
});
|
||||
|
||||
next();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Cache maintenance mode status to avoid DB queries on every request
|
||||
let maintenanceMode = false;
|
||||
@@ -27,7 +26,7 @@ async function queryWithRetry(queryFn, retries = MAX_RETRIES) {
|
||||
error.code === 'ECONNRESET';
|
||||
|
||||
if (isConnectionError) {
|
||||
logger.warn(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||
console.warn(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
|
||||
} else {
|
||||
throw error; // Don't retry non-connection errors
|
||||
@@ -57,7 +56,7 @@ async function checkMaintenanceMode() {
|
||||
|
||||
return maintenanceMode;
|
||||
} catch (error) {
|
||||
logger.error('Error checking maintenance mode after retries:', error.message);
|
||||
console.error('Error checking maintenance mode after retries:', error.message);
|
||||
// Return cached value or false if no cache
|
||||
return maintenanceMode;
|
||||
}
|
||||
@@ -103,7 +102,7 @@ async function maintenanceMiddleware(req, res, next) {
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't check maintenance mode, allow the request to proceed
|
||||
logger.error('Failed to check maintenance mode, allowing request:', error.message);
|
||||
console.error('Failed to check maintenance mode, allowing request:', error.message);
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -265,7 +265,7 @@ class SecureImageMiddleware {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Content-Security-Policy': 'default-src \'none\'; img-src \'self\'',
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self'",
|
||||
|
||||
// Custom security headers
|
||||
'X-Protected-Content': 'true',
|
||||
@@ -333,7 +333,7 @@ class SecureImageMiddleware {
|
||||
await db('security_logs').insert(logData).catch(console.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error logging security event:', error);
|
||||
console.error('Error logging security event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ class SecureImageMiddleware {
|
||||
perHour: config.perHour || 500
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error getting rate limit settings:', error);
|
||||
console.error('Error getting rate limit settings:', error);
|
||||
return { perMinute: 30, per5Minutes: 100, perHour: 500 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const { safePathJoin, isPathSafe } = require('../utils/fileSecurityUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Create a secure static file serving middleware that prevents path traversal attacks
|
||||
@@ -18,7 +17,7 @@ function secureStatic(basePath, options = {}) {
|
||||
|
||||
// Validate the path doesn't contain dangerous patterns
|
||||
if (!isPathSafe(requestedPath)) {
|
||||
logger.warn(`Potential path traversal attempt blocked: ${requestedPath}`);
|
||||
console.warn(`Potential path traversal attempt blocked: ${requestedPath}`);
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
@@ -44,7 +43,7 @@ function secureStatic(basePath, options = {}) {
|
||||
// `default-src 'none'` already implies script-src 'none';
|
||||
// style-src + img-src(data:) keep normal SVG rendering working.
|
||||
if (/\.svg$/i.test(filePath)) {
|
||||
resp.setHeader('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'; img-src \'self\' data:');
|
||||
resp.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
|
||||
resp.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
}
|
||||
@@ -53,7 +52,7 @@ function secureStatic(basePath, options = {}) {
|
||||
return staticMiddleware(req, res, next);
|
||||
} catch (error) {
|
||||
// Path traversal detected
|
||||
logger.error(`Path traversal blocked: ${requestedPath}`, error.message);
|
||||
console.error(`Path traversal blocked: ${requestedPath}`, error.message);
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// In-memory session tracking (in production, use Redis)
|
||||
const sessions = new Map();
|
||||
@@ -70,7 +69,7 @@ async function getSessionTimeout() {
|
||||
} catch (error) {
|
||||
// Only log if it's not a connection error (to avoid spam)
|
||||
if (error.code !== 'ECONNRESET' && !error.message?.includes('Connection terminated')) {
|
||||
logger.error('Error getting session timeout:', error.message);
|
||||
console.error('Error getting session timeout:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all archived events
|
||||
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||
try {
|
||||
const { page, limit, offset } = getPagination(req);
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Get total count
|
||||
const totalCount = await db('events')
|
||||
@@ -48,7 +48,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveFileSize = stats.size;
|
||||
} catch (error) {
|
||||
logger.error(`Archive file not found: ${archive.archive_path}`);
|
||||
console.error(`Archive file not found: ${archive.archive_path}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Archives list error:', error);
|
||||
console.error('Archives list error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch archives' });
|
||||
}
|
||||
});
|
||||
@@ -113,7 +113,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOw
|
||||
path: archive.archive_path
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Archive file not found:', error);
|
||||
console.error('Archive file not found:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOw
|
||||
archiveFile: archiveFileInfo
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Archive details error:', error);
|
||||
console.error('Archive details error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch archive details' });
|
||||
}
|
||||
});
|
||||
@@ -179,9 +179,9 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
await fs.mkdir(eventDir, { recursive: true });
|
||||
|
||||
// Log ZIP contents for debugging
|
||||
logger.info(`Extracting archive to: ${eventDir}`);
|
||||
console.log(`Extracting archive to: ${eventDir}`);
|
||||
const entries = Object.values(await zip.entries());
|
||||
logger.info(`Archive contains ${entries.length} entries`);
|
||||
console.log(`Archive contains ${entries.length} entries`);
|
||||
|
||||
// Stream-extract everything to disk
|
||||
await zip.extract(null, eventDir);
|
||||
@@ -203,12 +203,12 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
if (m && m.filename) manifestByFilename.set(m.filename, m);
|
||||
}
|
||||
}
|
||||
logger.info(`Loaded photos manifest: ${manifestByFilename.size} entries`);
|
||||
console.log(`Loaded photos manifest: ${manifestByFilename.size} entries`);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
logger.warn('Photos manifest present but unreadable; falling back to filenames', e.message);
|
||||
console.warn('Photos manifest present but unreadable; falling back to filenames', e.message);
|
||||
} else {
|
||||
logger.info('No photos manifest in archive (older archive); original_filename falls back to filename');
|
||||
console.log('No photos manifest in archive (older archive); original_filename falls back to filename');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,9 +286,9 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
});
|
||||
}
|
||||
} catch (statError) {
|
||||
logger.error(`Failed to stat file: ${actualFilePath}`);
|
||||
logger.error(`Entry name was: ${entry.name}`);
|
||||
logger.error('Error:', statError.message);
|
||||
console.error(`Failed to stat file: ${actualFilePath}`);
|
||||
console.error(`Entry name was: ${entry.name}`);
|
||||
console.error('Error:', statError.message);
|
||||
// Skip this file if we can't stat it
|
||||
continue;
|
||||
}
|
||||
@@ -301,7 +301,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
}
|
||||
|
||||
} catch (extractError) {
|
||||
logger.error('Archive extraction error:', extractError);
|
||||
console.error('Archive extraction error:', extractError);
|
||||
return res.status(500).json({ error: 'Failed to extract archive: ' + extractError.message });
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
|
||||
res.json({ message: 'Archive restored successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Archive restore error:', error);
|
||||
console.error('Archive restore error:', error);
|
||||
res.status(500).json({ error: 'Failed to restore archive' });
|
||||
}
|
||||
});
|
||||
@@ -380,7 +380,7 @@ router.get('/:id/download', adminAuth, requirePermission('archives.download'), r
|
||||
metadata: JSON.stringify({ event_name: archive.event_name })
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Archive download error:', error);
|
||||
console.error('Archive download error:', error);
|
||||
res.status(500).json({ error: 'Failed to download archive' });
|
||||
}
|
||||
});
|
||||
@@ -404,7 +404,7 @@ router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEv
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
await fs.unlink(fullArchivePath);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete archive file:', error);
|
||||
console.error('Failed to delete archive file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEv
|
||||
|
||||
res.json({ message: 'Archive deleted permanently' });
|
||||
} catch (error) {
|
||||
logger.error('Archive delete error:', error);
|
||||
console.error('Archive delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete archive' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get admin profile
|
||||
@@ -184,4 +185,175 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||
successResponse(res, { message: 'Logged out successfully' });
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-factor authentication (TOTP) — issue #738.
|
||||
//
|
||||
// All endpoints operate on the AUTHENTICATED admin's own account
|
||||
// (req.admin.id) — enrollment is per-user and works for every role,
|
||||
// super_admin included (closes #735). The TOTP secret is stored encrypted
|
||||
// at rest and recovery codes are hashed; see services/mfaService.js.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isMfaEnabled = mfaService.isEnrolled;
|
||||
|
||||
// Current MFA state for the logged-in admin.
|
||||
router.get('/mfa/status', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
const enabled = isMfaEnabled(admin);
|
||||
res.json({
|
||||
enabled,
|
||||
enrolledAt: enabled ? admin.two_factor_enrolled_at || null : null,
|
||||
recoveryCodesRemaining: enabled
|
||||
? mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes).length
|
||||
: 0
|
||||
});
|
||||
}));
|
||||
|
||||
// Begin enrollment: mint a provisional secret, store it encrypted (NOT yet
|
||||
// enabled), and return the otpauth URI + QR for the authenticator app. Calling
|
||||
// this again before /enable simply regenerates the provisional secret.
|
||||
router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
|
||||
const secret = mfaService.generateSecret();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_secret: mfaService.encryptSecret(secret),
|
||||
two_factor_enabled: false,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
const accountName = admin.email || admin.username;
|
||||
const otpauthUri = mfaService.buildOtpauthUri(accountName, secret);
|
||||
const qr = await mfaService.buildQrDataUrl(otpauthUri);
|
||||
|
||||
res.json({
|
||||
// `secret` is returned for manual entry when a QR can't be scanned.
|
||||
secret,
|
||||
otpauthUri,
|
||||
qr,
|
||||
issuer: mfaService.ISSUER,
|
||||
account: accountName
|
||||
});
|
||||
}));
|
||||
|
||||
// Complete enrollment: verify a code against the provisional secret, enable
|
||||
// MFA, and return one-time recovery codes (shown exactly once).
|
||||
router.post('/mfa/enable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('Verification code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
if (!admin.two_factor_secret) {
|
||||
throw new ValidationError('Start setup before enabling two-factor authentication');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: true,
|
||||
two_factor_enrolled_at: new Date(),
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_enabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Two-factor authentication enabled',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
// Disable MFA. Requires a fresh TOTP or recovery code so a hijacked session
|
||||
// can't silently strip the second factor.
|
||||
router.post('/mfa/disable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current code is required to disable 2FA')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret);
|
||||
let recoveryOk = false;
|
||||
if (!totpOk) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
recoveryOk = (await mfaService.consumeRecoveryCode(req.body.code, stored)).matched;
|
||||
}
|
||||
if (!totpOk && !recoveryOk) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_disabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Two-factor authentication disabled' });
|
||||
}));
|
||||
|
||||
// Regenerate recovery codes (invalidates the old set). Requires a fresh TOTP
|
||||
// code. Returns the new codes once.
|
||||
router.post('/mfa/recovery-codes', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current authenticator code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_recovery_regenerated',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Recovery codes regenerated',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+280
-191
@@ -4,8 +4,6 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse, getPagination } = require('../utils/routeHelpers');
|
||||
const { formatBytes } = require('../utils/formatBytes');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
@@ -32,7 +30,8 @@ router.get('/config', adminAuth, requirePermission('backup.view'), async (req, r
|
||||
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to get backup configuration');
|
||||
logger.error('Failed to get backup configuration:', error);
|
||||
res.status(500).json({ error: 'Failed to get backup configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -44,22 +43,22 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
|
||||
// Validate required fields based on destination type
|
||||
if (updates.backup_destination_type) {
|
||||
switch (updates.backup_destination_type) {
|
||||
case 'local':
|
||||
if (!updates.backup_destination_path) {
|
||||
return res.status(400).json({ error: 'Local backup requires destination path' });
|
||||
}
|
||||
break;
|
||||
case 'rsync':
|
||||
if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
|
||||
return res.status(400).json({ error: 'Rsync backup requires host and path' });
|
||||
}
|
||||
break;
|
||||
case 's3':
|
||||
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
|
||||
case 'local':
|
||||
if (!updates.backup_destination_path) {
|
||||
return res.status(400).json({ error: 'Local backup requires destination path' });
|
||||
}
|
||||
break;
|
||||
case 'rsync':
|
||||
if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
|
||||
return res.status(400).json({ error: 'Rsync backup requires host and path' });
|
||||
}
|
||||
break;
|
||||
case 's3':
|
||||
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
|
||||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
|
||||
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
|
||||
}
|
||||
break;
|
||||
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,19 +92,21 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
|
||||
|
||||
res.json({ success: true, message: 'Backup configuration updated' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update backup configuration');
|
||||
logger.error('Failed to update backup configuration:', error);
|
||||
res.status(500).json({ error: 'Failed to update backup configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get backup status and history
|
||||
router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { limit } = getPagination(req, { limit: 10 });
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const status = await getBackupStatus(limit);
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to get backup status');
|
||||
logger.error('Failed to get backup status:', error);
|
||||
res.status(500).json({ error: 'Failed to get backup status' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -125,7 +126,73 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
|
||||
|
||||
res.json({ success: true, message: 'Backup started' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to trigger backup');
|
||||
logger.error('Failed to trigger manual backup:', error);
|
||||
res.status(500).json({ error: 'Failed to trigger backup' });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate + download a portable ".picpeak" export — an engine-neutral logical
|
||||
// snapshot (DB rows as NDJSON + PDFs/business-docs) that can be re-uploaded to
|
||||
// another instance via the web UI. `?includePhotos=true` also bundles original
|
||||
// gallery photos (larger); otherwise the admin re-uploads them per gallery.
|
||||
//
|
||||
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
|
||||
// hashes, API keys). The download UI must warn before offering it. We surface
|
||||
// the flag as a response header too so the client can double-confirm.
|
||||
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
const fsSync = require('fs');
|
||||
try {
|
||||
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
|
||||
const { createPicpeak } = require('../services/picpeakExportService');
|
||||
const { filePath } = await createPicpeak({ includePhotos });
|
||||
const filename = path.basename(filePath);
|
||||
res.setHeader('X-Picpeak-Contains-Secrets', 'true');
|
||||
res.download(filePath, filename, (err) => {
|
||||
// Best-effort cleanup of the temp .picpeak (and its temp dir) after send.
|
||||
fsSync.rm(path.dirname(filePath), { recursive: true, force: true }, () => {});
|
||||
if (err) logger.error('[picpeak-export] download failed', { error: err.message });
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[picpeak-export] failed to create export', { error: error.message });
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Failed to create .picpeak export' });
|
||||
}
|
||||
});
|
||||
|
||||
// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER
|
||||
// auth so an unauthenticated request can't push a large file to disk.
|
||||
const os = require('os');
|
||||
const multer = require('multer');
|
||||
const picpeakUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, os.tmpdir()),
|
||||
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
|
||||
});
|
||||
|
||||
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
|
||||
// all data except the current logged-in account (the client shows an explicit
|
||||
// confirmation before calling this). Returns `usesExternalMedia` so the UI can
|
||||
// prompt the admin to reconfigure the external-media mount afterwards.
|
||||
router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => {
|
||||
const fsSync = require('fs');
|
||||
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
|
||||
const picpeakPath = req.file.path;
|
||||
try {
|
||||
const { importFromPicpeak } = require('../services/picpeakImportService');
|
||||
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id });
|
||||
res.json({
|
||||
success: true,
|
||||
tables: result.tables,
|
||||
filesRestored: result.filesRestored,
|
||||
usesExternalMedia: result.usesExternalMedia,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error.statusCode || 500;
|
||||
logger.error('[picpeak-import] restore failed', { error: error.message });
|
||||
res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation });
|
||||
} finally {
|
||||
fsSync.unlink(picpeakPath, () => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -153,7 +220,8 @@ router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req,
|
||||
|
||||
res.json(run);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to get backup run details');
|
||||
logger.error('Failed to get backup run details:', error);
|
||||
res.status(500).json({ error: 'Failed to get backup run details' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -187,7 +255,8 @@ router.get('/files', adminAuth, requirePermission('backup.view'), async (req, re
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to get file states');
|
||||
logger.error('Failed to get backup file states:', error);
|
||||
res.status(500).json({ error: 'Failed to get file states' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -200,7 +269,8 @@ router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (
|
||||
|
||||
res.json({ success: true, message: `Cleaned up backup runs older than ${days} days` });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to cleanup backup runs');
|
||||
logger.error('Failed to cleanup old backup runs:', error);
|
||||
res.status(500).json({ error: 'Failed to cleanup backup runs' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -210,128 +280,129 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
|
||||
const { destination_type, ...config } = req.body;
|
||||
|
||||
switch (destination_type) {
|
||||
case 'local':
|
||||
// Test local path access
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(config.path, fs.constants.W_OK);
|
||||
res.json({ success: true, message: 'Local path is writable' });
|
||||
} catch (error) {
|
||||
logger.warn('Local backup path not writable', {
|
||||
path: config.path,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
case 'local':
|
||||
// Test local path access
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(config.path, fs.constants.W_OK);
|
||||
res.json({ success: true, message: 'Local path is writable' });
|
||||
} catch (error) {
|
||||
logger.warn('Local backup path not writable', {
|
||||
path: config.path,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rsync':
|
||||
// Test rsync connection using spawn with argument arrays to prevent command injection
|
||||
const { spawn } = require('child_process');
|
||||
case 'rsync':
|
||||
// Test rsync connection using spawn with argument arrays to prevent command injection
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Validate and sanitize inputs to prevent command injection
|
||||
const sanitizeInput = (input) => {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
// Remove any shell metacharacters and limit length
|
||||
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
|
||||
};
|
||||
// Validate and sanitize inputs to prevent command injection
|
||||
const sanitizeInput = (input) => {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
// Remove any shell metacharacters and limit length
|
||||
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
|
||||
};
|
||||
|
||||
const host = sanitizeInput(config.host);
|
||||
const user = sanitizeInput(config.user);
|
||||
const sshKeyPath = sanitizeInput(config.ssh_key);
|
||||
const host = sanitizeInput(config.host);
|
||||
const user = sanitizeInput(config.user);
|
||||
const sshKeyPath = sanitizeInput(config.ssh_key);
|
||||
|
||||
if (!host) {
|
||||
res.json({ success: false, message: 'Invalid host specified' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate host format (hostname or IP only)
|
||||
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!hostRegex.test(host) && !ipRegex.test(host)) {
|
||||
res.json({ success: false, message: 'Invalid host format' });
|
||||
break;
|
||||
}
|
||||
|
||||
// SSRF protection: block connections to private/internal addresses
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(host)) {
|
||||
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate username format if provided
|
||||
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
res.json({ success: false, message: 'Invalid username format' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Build SSH arguments as array (safe from injection)
|
||||
const sshArgs = [];
|
||||
if (sshKeyPath) {
|
||||
// Validate SSH key path exists and is a file
|
||||
const fsSync = require('fs');
|
||||
if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) {
|
||||
res.json({ success: false, message: 'SSH key file not found' });
|
||||
if (!host) {
|
||||
res.json({ success: false, message: 'Invalid host specified' });
|
||||
break;
|
||||
}
|
||||
sshArgs.push('-i', sshKeyPath);
|
||||
}
|
||||
sshArgs.push('-o', 'StrictHostKeyChecking=no');
|
||||
sshArgs.push('-o', 'ConnectTimeout=10');
|
||||
sshArgs.push('-o', 'BatchMode=yes');
|
||||
|
||||
// Add target (user@host or just host)
|
||||
const target = user ? `${user}@${host}` : host;
|
||||
sshArgs.push(target);
|
||||
sshArgs.push('echo', 'Connection successful');
|
||||
// Validate host format (hostname or IP only)
|
||||
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!hostRegex.test(host) && !ipRegex.test(host)) {
|
||||
res.json({ success: false, message: 'Invalid host format' });
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const sshProcess = spawn('ssh', sshArgs, {
|
||||
timeout: 15000,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
// SSRF protection: block connections to private/internal addresses
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(host)) {
|
||||
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate username format if provided
|
||||
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
res.json({ success: false, message: 'Invalid username format' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Build SSH arguments as array (safe from injection)
|
||||
const sshArgs = [];
|
||||
if (sshKeyPath) {
|
||||
// Validate SSH key path exists and is a file
|
||||
const fsSync = require('fs');
|
||||
if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) {
|
||||
res.json({ success: false, message: 'SSH key file not found' });
|
||||
break;
|
||||
}
|
||||
sshArgs.push('-i', sshKeyPath);
|
||||
}
|
||||
sshArgs.push('-o', 'StrictHostKeyChecking=no');
|
||||
sshArgs.push('-o', 'ConnectTimeout=10');
|
||||
sshArgs.push('-o', 'BatchMode=yes');
|
||||
|
||||
// Add target (user@host or just host)
|
||||
const target = user ? `${user}@${host}` : host;
|
||||
sshArgs.push(target);
|
||||
sshArgs.push('echo', 'Connection successful');
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const sshProcess = spawn('ssh', sshArgs, {
|
||||
timeout: 15000,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
sshProcess.stdout.on('data', (data) => { stdout += data; });
|
||||
sshProcess.stderr.on('data', (data) => { stderr += data; });
|
||||
|
||||
sshProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true, stdout });
|
||||
} else {
|
||||
reject(new Error(stderr || `SSH exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
sshProcess.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
sshProcess.stdout.on('data', (data) => { stdout += data; });
|
||||
sshProcess.stderr.on('data', (data) => { stderr += data; });
|
||||
|
||||
sshProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true, stdout });
|
||||
} else {
|
||||
reject(new Error(stderr || `SSH exited with code ${code}`));
|
||||
}
|
||||
res.json({ success: true, message: 'Rsync connection successful' });
|
||||
} catch (error) {
|
||||
logger.warn('Rsync connection test failed', {
|
||||
destination: host,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
sshProcess.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Rsync connection successful' });
|
||||
} catch (error) {
|
||||
logger.warn('Rsync connection test failed', {
|
||||
destination: host,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
|
||||
case 's3':
|
||||
// Test S3 connection (would need AWS SDK)
|
||||
res.json({ success: false, message: 'S3 testing not implemented yet' });
|
||||
break;
|
||||
case 's3':
|
||||
// Test S3 connection (would need AWS SDK)
|
||||
res.json({ success: false, message: 'S3 testing not implemented yet' });
|
||||
break;
|
||||
|
||||
default:
|
||||
res.status(400).json({ error: 'Invalid destination type' });
|
||||
default:
|
||||
res.status(400).json({ error: 'Invalid destination type' });
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to test connection');
|
||||
logger.error('Failed to test backup connection:', error);
|
||||
res.status(500).json({ error: 'Failed to test connection' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -374,7 +445,8 @@ router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), a
|
||||
manifestPath
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to validate manifest');
|
||||
logger.error('Failed to validate manifest:', error);
|
||||
res.status(500).json({ error: 'Failed to validate manifest' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -486,7 +558,8 @@ router.post('/manifests/validate', adminAuth, requirePermission('backup.view'),
|
||||
manifestPath
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to validate manifest');
|
||||
logger.error('Failed to validate manifest:', error);
|
||||
res.status(500).json({ error: 'Failed to validate manifest' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -517,7 +590,8 @@ router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (re
|
||||
owner: result.Owner || null
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to list S3 buckets');
|
||||
logger.error('Failed to list S3 buckets:', error);
|
||||
res.status(500).json({ error: 'Failed to list S3 buckets' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -553,7 +627,8 @@ router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req,
|
||||
prefix: prefix
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to list S3 files');
|
||||
logger.error('Failed to list S3 files:', error);
|
||||
res.status(500).json({ error: 'Failed to list S3 files' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -614,7 +689,8 @@ router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), asyn
|
||||
message: `Cleaned up ${deletedCount} S3 backup files older than ${retentionDays} days`
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to cleanup S3 backups');
|
||||
logger.error('Failed to cleanup S3 backups:', error);
|
||||
res.status(500).json({ error: 'Failed to cleanup S3 backups' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -665,7 +741,8 @@ router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), as
|
||||
message: 'S3 upload test completed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'S3 upload test failed');
|
||||
logger.error('S3 upload test failed:', error);
|
||||
res.status(500).json({ error: 'S3 upload test failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -691,68 +768,69 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a
|
||||
|
||||
// Handle different backup types
|
||||
switch (config.backup_destination_type) {
|
||||
case 'local':
|
||||
// Stream local backup as zip
|
||||
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
case 'local':
|
||||
// Stream local backup as zip
|
||||
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
res.attachment(`picpeak-backup-${backupRun.id}.zip`);
|
||||
archive.pipe(res);
|
||||
res.attachment(`picpeak-backup-${backupRun.id}.zip`);
|
||||
archive.pipe(res);
|
||||
|
||||
// Add backup directory contents
|
||||
archive.directory(backupPath, false);
|
||||
// Add backup directory contents
|
||||
archive.directory(backupPath, false);
|
||||
|
||||
// Add manifest if exists
|
||||
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
|
||||
archive.file(backupRun.manifest_path, { name: 'manifest.json' });
|
||||
}
|
||||
// Add manifest if exists
|
||||
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
|
||||
archive.file(backupRun.manifest_path, { name: 'manifest.json' });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
break;
|
||||
await archive.finalize();
|
||||
break;
|
||||
|
||||
case 's3':
|
||||
// For S3, provide pre-signed URLs or stream files
|
||||
const s3Adapter = new S3StorageAdapter({
|
||||
endpoint: config.backup_s3_endpoint,
|
||||
bucket: config.backup_s3_bucket,
|
||||
accessKeyId: config.backup_s3_access_key,
|
||||
secretAccessKey: config.backup_s3_secret_key,
|
||||
region: config.backup_s3_region || 'us-east-1',
|
||||
forcePathStyle: config.backup_s3_force_path_style || false
|
||||
});
|
||||
|
||||
// List all files for this backup
|
||||
const prefix = `backups/${backupRun.id}/`;
|
||||
const files = await s3Adapter.list(prefix, { maxKeys: 1000 });
|
||||
|
||||
// Generate pre-signed URLs
|
||||
const urls = [];
|
||||
for (const file of files.objects || []) {
|
||||
const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour
|
||||
urls.push({
|
||||
key: file.key,
|
||||
size: file.size,
|
||||
url: url
|
||||
case 's3':
|
||||
// For S3, provide pre-signed URLs or stream files
|
||||
const s3Adapter = new S3StorageAdapter({
|
||||
endpoint: config.backup_s3_endpoint,
|
||||
bucket: config.backup_s3_bucket,
|
||||
accessKeyId: config.backup_s3_access_key,
|
||||
secretAccessKey: config.backup_s3_secret_key,
|
||||
region: config.backup_s3_region || 'us-east-1',
|
||||
forcePathStyle: config.backup_s3_force_path_style || false
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
backupId: backupRun.id,
|
||||
type: 's3',
|
||||
files: urls,
|
||||
expiresIn: 3600,
|
||||
message: 'Use the provided URLs to download individual files'
|
||||
});
|
||||
break;
|
||||
// List all files for this backup
|
||||
const prefix = `backups/${backupRun.id}/`;
|
||||
const files = await s3Adapter.list(prefix, { maxKeys: 1000 });
|
||||
|
||||
case 'rsync':
|
||||
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
|
||||
// Generate pre-signed URLs
|
||||
const urls = [];
|
||||
for (const file of files.objects || []) {
|
||||
const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour
|
||||
urls.push({
|
||||
key: file.key,
|
||||
size: file.size,
|
||||
url: url
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return res.status(400).json({ error: 'Unknown backup type' });
|
||||
res.json({
|
||||
backupId: backupRun.id,
|
||||
type: 's3',
|
||||
files: urls,
|
||||
expiresIn: 3600,
|
||||
message: 'Use the provided URLs to download individual files'
|
||||
});
|
||||
break;
|
||||
|
||||
case 'rsync':
|
||||
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
|
||||
|
||||
default:
|
||||
return res.status(400).json({ error: 'Unknown backup type' });
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download backup');
|
||||
logger.error('Failed to download backup:', error);
|
||||
res.status(500).json({ error: 'Failed to download backup' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -824,7 +902,8 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
|
||||
path: targetPath || '/'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to get file checksums');
|
||||
logger.error('Failed to get file checksums:', error);
|
||||
res.status(500).json({ error: 'Failed to get file checksums' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -926,11 +1005,21 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req
|
||||
warnings: totalSize > 10 * 1024 * 1024 * 1024 ? ['Backup size exceeds 10GB, may take significant time'] : []
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to estimate backup size');
|
||||
logger.error('Failed to estimate backup size:', error);
|
||||
res.status(500).json({ error: 'Failed to estimate backup size' });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to format bytes
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Helper function to get backup configuration
|
||||
async function getBackupConfig() {
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,6 @@ const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -44,7 +43,7 @@ router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res)
|
||||
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
|
||||
res.json(pages);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching CMS pages:', error);
|
||||
console.error('Error fetching CMS pages:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch pages' });
|
||||
}
|
||||
});
|
||||
@@ -61,7 +60,7 @@ router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req,
|
||||
|
||||
res.json(page);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching CMS page:', error);
|
||||
console.error('Error fetching CMS page:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch page' });
|
||||
}
|
||||
});
|
||||
@@ -145,7 +144,7 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
logger.error('Error updating CMS page:', error);
|
||||
console.error('Error updating CMS page:', error);
|
||||
res.status(500).json({ error: 'Failed to update page' });
|
||||
}
|
||||
});
|
||||
@@ -185,7 +184,7 @@ router.post(
|
||||
|
||||
res.json({ logo_url: logoUrl });
|
||||
} catch (error) {
|
||||
logger.error('Error uploading CMS page logo:', error);
|
||||
console.error('Error uploading CMS page logo:', error);
|
||||
res.status(500).json({ error: 'Failed to upload logo' });
|
||||
}
|
||||
}
|
||||
@@ -209,7 +208,7 @@ router.delete(
|
||||
|
||||
res.json({ logo_url: null });
|
||||
} catch (error) {
|
||||
logger.error('Error clearing CMS page logo:', error);
|
||||
console.error('Error clearing CMS page logo:', error);
|
||||
res.status(500).json({ error: 'Failed to clear logo' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all global categories
|
||||
@@ -16,7 +15,7 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching categories:', error);
|
||||
console.error('Error fetching categories:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch categories' });
|
||||
}
|
||||
});
|
||||
@@ -36,7 +35,7 @@ router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), asy
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event categories:', error);
|
||||
console.error('Error fetching event categories:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch categories' });
|
||||
}
|
||||
});
|
||||
@@ -102,7 +101,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
res.json(category);
|
||||
} catch (error) {
|
||||
logger.error('Error creating category:', error);
|
||||
console.error('Error creating category:', error);
|
||||
res.status(500).json({ error: 'Failed to create category' });
|
||||
}
|
||||
});
|
||||
@@ -166,7 +165,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
logger.error('Error updating category:', error);
|
||||
console.error('Error updating category:', error);
|
||||
res.status(500).json({ error: 'Failed to update category' });
|
||||
}
|
||||
});
|
||||
@@ -215,7 +214,7 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
logger.error('Error updating category hero:', error);
|
||||
console.error('Error updating category hero:', error);
|
||||
res.status(500).json({ error: 'Failed to update category hero' });
|
||||
}
|
||||
});
|
||||
@@ -249,7 +248,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
|
||||
res.json({ message: 'Category deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting category:', error);
|
||||
console.error('Error deleting category:', error);
|
||||
res.status(500).json({ error: 'Failed to delete category' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
|
||||
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* GET /admin/css-templates
|
||||
@@ -24,7 +23,7 @@ router.get('/', adminAuth, requirePermission('branding.view'), async (req, res)
|
||||
);
|
||||
res.json({ success: true, templates });
|
||||
} catch (error) {
|
||||
logger.error('Get CSS templates error:', error);
|
||||
console.error('Get CSS templates error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch templates' });
|
||||
}
|
||||
});
|
||||
@@ -43,7 +42,7 @@ router.get('/enabled', adminAuth, requirePermission('branding.view'), async (req
|
||||
);
|
||||
res.json({ success: true, templates });
|
||||
} catch (error) {
|
||||
logger.error('Get enabled templates error:', error);
|
||||
console.error('Get enabled templates error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch templates' });
|
||||
}
|
||||
});
|
||||
@@ -74,7 +73,7 @@ router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
|
||||
|
||||
res.json({ success: true, template });
|
||||
} catch (error) {
|
||||
logger.error('Get template error:', error);
|
||||
console.error('Get template error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch template' });
|
||||
}
|
||||
});
|
||||
@@ -151,7 +150,7 @@ router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
|
||||
sanitization_warnings: warnings
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Update template error:', error);
|
||||
console.error('Update template error:', error);
|
||||
res.status(500).json({ error: 'Failed to update template' });
|
||||
}
|
||||
});
|
||||
@@ -188,7 +187,7 @@ router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'),
|
||||
|
||||
res.json({ success: true, template });
|
||||
} catch (error) {
|
||||
logger.error('Reset template error:', error);
|
||||
console.error('Reset template error:', error);
|
||||
res.status(500).json({ error: 'Failed to reset template' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { resolveAdapter } = require('../services/trackers');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse, getPagination } = require('../utils/routeHelpers');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
@@ -127,15 +126,16 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
totalEvents: totalEvents.count || 0
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch dashboard statistics');
|
||||
console.error('Dashboard stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent activity
|
||||
router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
const { limit } = getPagination(req, { limit: 10 });
|
||||
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
|
||||
const activities = await db('activity_logs')
|
||||
.select('activity_logs.*', 'events.event_name')
|
||||
.leftJoin('events', 'activity_logs.event_id', 'events.id')
|
||||
@@ -155,7 +155,7 @@ router.get('/activity', adminAuth, requirePermission('analytics.view'), async (r
|
||||
if (typeof activity.metadata === 'object') return activity.metadata;
|
||||
return JSON.parse(activity.metadata);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse metadata for activity:', activity.id, e.message);
|
||||
console.warn('Failed to parse metadata for activity:', activity.id, e.message);
|
||||
return {};
|
||||
}
|
||||
})(),
|
||||
@@ -164,7 +164,8 @@ router.get('/activity', adminAuth, requirePermission('analytics.view'), async (r
|
||||
|
||||
res.json(formattedActivities);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch activity log');
|
||||
console.error('Activity log error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch activity log' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -232,7 +233,7 @@ router.get('/health', adminAuth, requirePermission('settings.view'), async (req,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Health check error:', error);
|
||||
console.error('Health check error:', error);
|
||||
res.status(500).json({
|
||||
overall: 'error',
|
||||
error: 'Failed to check system health'
|
||||
@@ -399,7 +400,8 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch analytics data');
|
||||
console.error('Analytics error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch analytics data' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -584,7 +586,8 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load CRM stats');
|
||||
require('../utils/logger').error('CRM stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to load CRM stats' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const { databaseBackupService } = require('../services/databaseBackup');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
|
||||
// All routes require admin authentication
|
||||
router.use(adminAuth);
|
||||
@@ -155,8 +154,10 @@ router.get('/progress', requirePermission('backup.view'), async (req, res) => {
|
||||
*/
|
||||
router.get('/history', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { page, limit, offset } = getPagination(req);
|
||||
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [backups, totalCount] = await Promise.all([
|
||||
db('database_backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
|
||||
@@ -5,8 +5,6 @@ const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get email configuration
|
||||
@@ -33,7 +31,8 @@ router.get('/config', adminAuth, requirePermission('email.view'), async (req, re
|
||||
smtp_pass: config.smtp_pass ? '********' : ''
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch email configuration');
|
||||
console.error('Email config fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,7 +113,8 @@ router.post('/config', [
|
||||
|
||||
res.json({ message: 'Email configuration updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update email configuration');
|
||||
console.error('Email config update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update email configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -131,7 +131,8 @@ router.get('/incoming-config', adminAuth, requirePermission('email.view'), async
|
||||
imap_folder: c?.imap_folder || 'INBOX',
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch incoming mail configuration');
|
||||
console.error('Incoming mail config fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch incoming mail configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -167,7 +168,8 @@ router.post('/incoming-config', [
|
||||
await logActivity('incoming_mail_config_updated', { imap_host }, null, { type: 'admin', id: req.admin.id, name: req.admin.username });
|
||||
res.json({ message: 'Incoming mail configuration updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update incoming mail configuration');
|
||||
console.error('Incoming mail config update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update incoming mail configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,7 +192,7 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
|
||||
);
|
||||
res.json({ folders });
|
||||
} catch (error) {
|
||||
logger.error('IMAP folder detection error:', error);
|
||||
console.error('IMAP folder detection error:', error);
|
||||
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993) and credentials.` });
|
||||
}
|
||||
});
|
||||
@@ -215,7 +217,7 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('IMAP connection test error:', error);
|
||||
console.error('IMAP connection test error:', error);
|
||||
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993), credentials and folder.` });
|
||||
}
|
||||
});
|
||||
@@ -237,7 +239,7 @@ router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.se
|
||||
return res.status(result.reason === 'not_received' ? 504 : 400)
|
||||
.json({ error: map[result.reason] || 'Round-trip test failed.', sent: !!result.sent, recipient: result.recipient });
|
||||
} catch (error) {
|
||||
logger.error('Round-trip test error:', error);
|
||||
console.error('Round-trip test error:', error);
|
||||
res.status(422).json({ error: `Round-trip test failed (${error.message}) — check both SMTP and IMAP settings.` });
|
||||
}
|
||||
});
|
||||
@@ -251,7 +253,7 @@ router.post('/incoming-config/poll', adminAuth, requirePermission('email.view'),
|
||||
const result = await emailIntakeService.pollOnce();
|
||||
res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' }
|
||||
} catch (error) {
|
||||
logger.error('Manual poll error:', error);
|
||||
console.error('Manual poll error:', error);
|
||||
res.status(422).json({ error: `Mailbox poll failed (${error.message}).` });
|
||||
}
|
||||
});
|
||||
@@ -266,7 +268,8 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req,
|
||||
const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
|
||||
res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch received emails');
|
||||
console.error('Received emails fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch received emails' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -319,7 +322,7 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
debug: process.env.NODE_ENV === 'development'
|
||||
};
|
||||
|
||||
logger.info('Creating email transporter with config:', {
|
||||
console.log('Creating email transporter with config:', {
|
||||
host: transportConfig.host,
|
||||
port: transportConfig.port,
|
||||
secure: transportConfig.secure,
|
||||
@@ -353,8 +356,8 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
|
||||
res.json({ message: 'Test email sent successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Test email error:', error);
|
||||
logger.error('Error stack:', error.stack);
|
||||
console.error('Test email error:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
|
||||
// Provide more specific error messages with translation keys
|
||||
let errorMessage = 'Error sending email';
|
||||
@@ -425,7 +428,7 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r
|
||||
} catch (_) { /* activity logging is best-effort */ }
|
||||
res.json({ message: 'Email queue flushed', ...summary });
|
||||
} catch (error) {
|
||||
logger.error('Flush email queue error:', error);
|
||||
console.error('Flush email queue error:', error);
|
||||
res.status(500).json({ error: 'Failed to flush email queue', details: error.message });
|
||||
}
|
||||
});
|
||||
@@ -513,7 +516,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('List email queue error:', error);
|
||||
console.error('List email queue error:', error);
|
||||
res.status(500).json({ error: 'Failed to load email queue', details: error.message });
|
||||
}
|
||||
});
|
||||
@@ -525,7 +528,7 @@ function parseVariables(template) {
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -608,7 +611,8 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
|
||||
|
||||
res.json(formattedTemplates);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch email templates');
|
||||
console.error('Email templates fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email templates' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -637,7 +641,8 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch email template');
|
||||
console.error('Email template fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email template' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -725,7 +730,8 @@ router.put('/templates/:key', [
|
||||
|
||||
res.json({ message: 'Email template updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update email template');
|
||||
console.error('Email template update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update email template' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -812,7 +818,8 @@ router.post('/templates', [
|
||||
|
||||
return res.status(201).json({ template_key: templateKey, id: templateId });
|
||||
} catch (error) {
|
||||
return errorResponse(res, error, 500, 'Failed to create email template');
|
||||
console.error('Email template create error:', error);
|
||||
return res.status(500).json({ error: 'Failed to create email template' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -890,7 +897,8 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to preview email template');
|
||||
console.error('Email template preview error:', error);
|
||||
res.status(500).json({ error: 'Failed to preview email template' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ const { body, validationResult } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const eventRenameService = require('../services/eventRenameService');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
@@ -51,7 +50,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||
data: result.data
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error renaming event:', error);
|
||||
console.error('Error renaming event:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to rename event' });
|
||||
}
|
||||
});
|
||||
@@ -82,7 +81,7 @@ router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.ed
|
||||
|
||||
res.json(validation);
|
||||
} catch (error) {
|
||||
logger.error('Error validating rename:', error);
|
||||
console.error('Error validating rename:', error);
|
||||
res.status(500).json({ valid: false, error: 'Validation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// This is a partial file showing the enhanced event creation with password validation
|
||||
// Only the relevant parts are shown - merge with existing adminEvents.js
|
||||
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
|
||||
// Enhanced event creation with password validation
|
||||
// Note: This is a partial/reference file - dynamic event type validation should be implemented
|
||||
// similar to adminEvents.js using eventTypeService.isValidEventType()
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('admin_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('password').notEmpty(), // Remove the weak isLength validation
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||
body('welcome_message').optional().trim(),
|
||||
body('color_theme').optional().trim(),
|
||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
body('customer_name').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.error('Validation errors:', errors.array());
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
customer_name,
|
||||
customer_email,
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
color_theme = null,
|
||||
expiration_days = 30,
|
||||
allow_user_uploads = false,
|
||||
upload_category_id = null,
|
||||
photo_cap = null
|
||||
} = req.body;
|
||||
|
||||
// Validate password strength for gallery
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link based on configured style
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventPath = path.join(storagePath, 'events/active', slug);
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Insert into database
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
customer_name,
|
||||
customer_email,
|
||||
host_name: customer_name,
|
||||
host_email: customer_email,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
upload_category_id,
|
||||
photo_cap: photo_cap || null
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
{
|
||||
event_type,
|
||||
expires_at,
|
||||
password_strength: passwordValidation.score
|
||||
},
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Rest of the implementation remains the same...
|
||||
// Queue creation email, etc.
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error);
|
||||
res.status(500).json({ error: 'Failed to create event' });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,196 +0,0 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||
// registration order so Express route matching is unchanged.
|
||||
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const { archiveEvent } = require('../../services/archiveService');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { deleteEventCascade } = require('./helpers');
|
||||
|
||||
|
||||
// Bulk delete — destructive, irreversible. Caps at 100 events per request
|
||||
// to keep request time bounded; the per-event cascade touches 5 DB tables
|
||||
// + 3 filesystem paths so 1000 events would risk timing out the request.
|
||||
// Loops via deleteEventCascade so the per-event delete behaviour stays in
|
||||
// lock-step with DELETE /:id.
|
||||
//
|
||||
// Confirmation is enforced client-side via the typed-DELETE pattern in
|
||||
// BulkDeleteModal (#417). The previous server-side bcrypt-password gate
|
||||
// was dropped because the destructive single-event DELETE /:id has never
|
||||
// required a password either — events.delete permission + admin session
|
||||
// is the auth boundary for both. The typed-literal client gate is the
|
||||
// "accidental click" safeguard, and unlike a password input it isn't
|
||||
// affected by passkey/Windows Hello autofill that auto-submits the form.
|
||||
const BULK_DELETE_MAX = 100;
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (event.is_archived) {
|
||||
return res.status(400).json({ error: 'Event is already archived' });
|
||||
}
|
||||
|
||||
// Use the archive service to create ZIP archive
|
||||
await archiveEvent(event);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_archived',
|
||||
{ eventName: event.event_name },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event archived successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to archive event');
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk archive events
|
||||
router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
||||
body('eventIds').isArray().withMessage('eventIds must be an array'),
|
||||
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { eventIds } = req.body;
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No events selected for archiving' });
|
||||
}
|
||||
|
||||
// Get all events to archive
|
||||
const events = await db('events')
|
||||
.whereIn('id', eventIds)
|
||||
.where('is_archived', formatBoolean(false));
|
||||
|
||||
if (events.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid events found to archive' });
|
||||
}
|
||||
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: []
|
||||
};
|
||||
|
||||
// Process each event
|
||||
for (const event of events) {
|
||||
try {
|
||||
// Use the archive service to create ZIP archive
|
||||
await archiveEvent(event);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_archived',
|
||||
{ eventName: event.event_name, bulkOperation: true },
|
||||
event.id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
results.successful.push({
|
||||
id: event.id,
|
||||
name: event.event_name
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to archive event ${event.id}:`, error);
|
||||
results.failed.push({
|
||||
id: event.id,
|
||||
name: event.event_name,
|
||||
error: 'Failed to archive event. Check server logs for details.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Log bulk archive activity
|
||||
await logActivity('bulk_archive_completed',
|
||||
{
|
||||
totalEvents: eventIds.length,
|
||||
successfulCount: results.successful.length,
|
||||
failedCount: results.failed.length
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to perform bulk archive');
|
||||
}
|
||||
});
|
||||
router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
|
||||
body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`),
|
||||
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { eventIds } = req.body;
|
||||
|
||||
// Editor-role events.delete permission is already gated by the route
|
||||
// middleware. We do NOT additionally filter to created_by here because
|
||||
// the per-event delete-cascade is global (matches DELETE /:id which
|
||||
// also has no role-based filter — that's why events.delete is a
|
||||
// sensitive permission).
|
||||
|
||||
const results = { successful: [], failed: [] };
|
||||
const adminContext = { id: req.admin.id, username: req.admin.username };
|
||||
|
||||
for (const eventId of eventIds) {
|
||||
try {
|
||||
const deleted = await deleteEventCascade(eventId, adminContext);
|
||||
results.successful.push(deleted);
|
||||
} catch (err) {
|
||||
results.failed.push({
|
||||
id: eventId,
|
||||
name: null,
|
||||
error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event'
|
||||
});
|
||||
logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
await logActivity('bulk_delete_completed',
|
||||
{
|
||||
totalEvents: eventIds.length,
|
||||
successfulCount: results.successful.length,
|
||||
failedCount: results.failed.length
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to perform bulk delete');
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,326 +0,0 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Shared helpers + module-level caches used across the adminEvents sub-routers.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const logger = require('../../utils/logger');
|
||||
const { parseStringInput } = require('../../utils/parsers');
|
||||
|
||||
// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point
|
||||
const validateHeroImageAnchor = (value) => {
|
||||
if (['top', 'center', 'bottom'].includes(value)) return true;
|
||||
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
|
||||
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
|
||||
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
|
||||
}
|
||||
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
|
||||
};
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
||||
|
||||
// Helper to get event field requirements from settings
|
||||
const getEventFieldRequirements = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email',
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
value = value === 'true';
|
||||
}
|
||||
}
|
||||
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
|
||||
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
|
||||
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
|
||||
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
|
||||
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
|
||||
});
|
||||
|
||||
return requirements;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get event field requirements', { error: error.message });
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to read app_settings booleans by key, used to inherit per-setting
|
||||
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
|
||||
// so callers can fall back to a legacy default.
|
||||
const readBooleanSetting = async (key) => {
|
||||
try {
|
||||
const setting = await db('app_settings').where('setting_key', key).first();
|
||||
if (!setting) return undefined;
|
||||
let value = setting.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read app setting', { key, error: error.message });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to read the global "enable_devtools_protection" admin setting so
|
||||
// new events inherit it instead of always falling back to the DB column default
|
||||
// (#317 — admin disabled it globally but new events still got it ON).
|
||||
const getDownloadProtectionDefaults = async () => {
|
||||
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
|
||||
};
|
||||
|
||||
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
|
||||
//
|
||||
// Note: `branding_logo_position` (header bar — left/center/right) is a
|
||||
// different concept from `hero_logo_position` (hero block — top/center/
|
||||
// bottom) and must NOT be mapped here. A previous version copied the
|
||||
// branding value over, which wrote 'left'/'right' into per-event
|
||||
// hero_logo_position columns and broke any subsequent PUT validation
|
||||
// (#357). Migration 084 heals existing rows.
|
||||
const getBrandingDefaults = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_display_hero',
|
||||
'branding_logo_size'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const defaults = {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_display_hero') {
|
||||
defaults.hero_logo_visible = value !== false;
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_size' && value) {
|
||||
defaults.hero_logo_size = value;
|
||||
}
|
||||
});
|
||||
|
||||
return defaults;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get branding defaults', { error: error.message });
|
||||
return {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
|
||||
|
||||
// Whether the global "phone field" toggle (#322) is enabled. Cached for
|
||||
// the request via a module-level read; drift is acceptable since this
|
||||
// only governs whether to persist the field, not security boundaries.
|
||||
const isPhoneFieldEnabled = async () => {
|
||||
try {
|
||||
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
|
||||
if (!row) return false;
|
||||
let value = row.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
return value === true;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
customer_phone,
|
||||
password_hash: _ph,
|
||||
client_password_hash: _cph,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null,
|
||||
customer_phone: customer_phone ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to detect customer_email column', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Cascade-delete a single event: photos, audit/access logs, queued emails,
|
||||
// the event row itself (in one transaction), then the on-disk folder /
|
||||
// archive zip / hero logo (best-effort — file failures don't unwind the DB
|
||||
// changes since the source of truth is the database). Used by both the
|
||||
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
|
||||
//
|
||||
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
|
||||
// bulk-delete loop can report it as a per-id failure without aborting the
|
||||
// whole batch. Any other error propagates and is the caller's problem.
|
||||
async function deleteEventCascade(eventId, adminContext) {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) {
|
||||
const err = new Error('Event not found');
|
||||
err.code = 'EVENT_NOT_FOUND';
|
||||
throw err;
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// 1. Delete activity logs (audit trail)
|
||||
await trx('activity_logs').where('event_id', eventId).del();
|
||||
// 2. Delete access logs
|
||||
await trx('access_logs').where('event_id', eventId).del();
|
||||
// 3. Delete email queue entries
|
||||
await trx('email_queue').where('event_id', eventId).del();
|
||||
// 4. Delete photos (also handles hero_photo_id foreign key)
|
||||
await trx('photos').where('event_id', eventId).del();
|
||||
// 5. Finally delete the event row
|
||||
await trx('events').where('id', eventId).del();
|
||||
|
||||
// Best-effort filesystem cleanup. Failures are logged but don't unwind
|
||||
// the transaction — the canonical state lives in the DB; orphan files
|
||||
// are recoverable noise, a half-deleted DB row is a permanent mess.
|
||||
//
|
||||
// #608 — previous code read `event.folder_path`, but that column is
|
||||
// never written anywhere in the codebase (grep confirms: two reads in
|
||||
// this function, zero writes). It's always undefined, so the
|
||||
// `if (event.folder_path)` branch silently no-op'd and every event
|
||||
// delete since this cascade landed left its photos orphaned on disk.
|
||||
// jodrmx's Pi report (v3.44.0) was the first surfacing.
|
||||
//
|
||||
// Files actually live at:
|
||||
// {STORAGE_PATH}/events/active/{slug}/... (uploaded photos)
|
||||
// {STORAGE_PATH}/events/archived/{slug}/... (after the event
|
||||
// was archived — folder copy survives the archive flow)
|
||||
//
|
||||
// `event.slug` is NOT NULL on the events table and is slugify-sanitized
|
||||
// on every write (lower-case ASCII + dashes only via utils/slug.js),
|
||||
// so path-traversal isn't a concern.
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
||||
for (const sub of ['active', 'archived']) {
|
||||
const eventFolderPath = path.join(storagePath, 'events', sub, event.slug);
|
||||
try {
|
||||
await fs.rm(eventFolderPath, { recursive: true, force: true });
|
||||
} catch (fsErr) {
|
||||
logger.warn('Failed to delete event folder during cascade delete', { eventId, path: eventFolderPath, error: fsErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
||||
const archiveFile = path.join(storagePath, event.archive_path);
|
||||
try {
|
||||
await fs.unlink(archiveFile);
|
||||
} catch (fsErr) {
|
||||
logger.warn('Failed to delete archive file during cascade delete', { eventId, path: archiveFile, error: fsErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
} catch (fsErr) {
|
||||
logger.warn('Failed to delete event logo during cascade delete', { eventId, path: event.hero_logo_path, error: fsErr.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Audit trail (outside the transaction so a logging failure can't undo
|
||||
// the actual delete).
|
||||
await logActivity('event_deleted',
|
||||
{ event_name: event.event_name },
|
||||
null,
|
||||
{ type: 'admin', id: adminContext.id, name: adminContext.username }
|
||||
);
|
||||
|
||||
return { id: event.id, name: event.event_name };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live
|
||||
// events that auto-picks-up new uploads (migration 138). Mirrors the
|
||||
// client-access second-token pattern: the link is minted on demand, rotatable
|
||||
// and disable-able, independent of the gallery password / share link.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Allowed slide transition styles (kept in sync with the SlideshowPage).
|
||||
// dipwhite/dipblack = fade through highlights / lowlights between images.
|
||||
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||
// Allowed per-slide color filters.
|
||||
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||
module.exports = {
|
||||
validateHeroImageAnchor,
|
||||
getStoragePath,
|
||||
getEventFieldRequirements,
|
||||
readBooleanSetting,
|
||||
getDownloadProtectionDefaults,
|
||||
getBrandingDefaults,
|
||||
getCustomerNameFromPayload,
|
||||
getCustomerEmailFromPayload,
|
||||
getCustomerPhoneFromPayload,
|
||||
isPhoneFieldEnabled,
|
||||
mapEventForApi,
|
||||
hasCustomerContactColumns,
|
||||
deleteEventCascade,
|
||||
SLIDESHOW_TRANSITIONS,
|
||||
SLIDESHOW_COLORFILTERS,
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
// adminEvents router — decomposed move-code refactor of the original
|
||||
// routes/adminEvents.js god file. Each sub-module attaches its routes onto the
|
||||
// shared router below. CRITICAL: the require(...)(router) calls preserve the
|
||||
// original registration order — Express matches in registration order, so
|
||||
// literal segments and '/:id' patterns must keep their relative positions.
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
require('./crud')(router);
|
||||
require('./slideshow')(router);
|
||||
require('./resets')(router);
|
||||
require('./archiveBulk')(router);
|
||||
require('./logo')(router);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,145 +0,0 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||
// registration order so Express route matching is unchanged.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { validateFileType } = require('../../utils/fileSecurityUtils');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { getStoragePath } = require('./helpers');
|
||||
|
||||
|
||||
// Configure multer for event logo uploads
|
||||
const eventLogoStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const eventLogoUpload = multer({
|
||||
storage: eventLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
// Upload event custom logo
|
||||
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if event exists
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No logo file provided' });
|
||||
}
|
||||
|
||||
// Delete old logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
|
||||
const logoPath = req.file.path;
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: logoUrl,
|
||||
hero_logo_path: logoPath
|
||||
});
|
||||
|
||||
await logActivity('event_logo_uploaded',
|
||||
{ eventName: event.event_name, filename: req.file.filename },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Event logo uploaded successfully',
|
||||
hero_logo_url: logoUrl
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to upload event logo');
|
||||
}
|
||||
});
|
||||
|
||||
// Delete event custom logo
|
||||
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Delete logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: null,
|
||||
hero_logo_path: null
|
||||
});
|
||||
|
||||
await logActivity('event_logo_removed',
|
||||
{ eventName: event.event_name },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event logo removed successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to delete event logo');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||
// registration order so Express route matching is unchanged.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { queueEmail } = require('../../services/emailProcessor');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
// Reset event password
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sendEmail = true, password: clientPassword } = req.body;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (event.is_archived) {
|
||||
return res.status(400).json({ error: 'Cannot reset password for archived event' });
|
||||
}
|
||||
|
||||
// Use the admin-supplied password when provided; otherwise auto-generate
|
||||
// (preserves the previous one-click behaviour for callers/cron that don't
|
||||
// pass a body). Validation matches the create-event flow so the same
|
||||
// strength rules apply both ways.
|
||||
let newPassword;
|
||||
if (typeof clientPassword === 'string' && clientPassword.length > 0) {
|
||||
const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', {
|
||||
eventName: event.event_name
|
||||
});
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
newPassword = clientPassword;
|
||||
} else {
|
||||
const { generateReadablePassword } = require('../../utils/passwordGenerator');
|
||||
newPassword = generateReadablePassword();
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||
|
||||
// Update event with new password
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
password_hash: passwordHash
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_reset',
|
||||
{ eventName: event.event_name, emailSent: sendEmail },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Queue email notification if requested
|
||||
if (sendEmail) {
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form (`/gallery/<slug>/<token>`).
|
||||
// Use the full URL so customers can click straight from the email.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: newPassword,
|
||||
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Password reset successfully',
|
||||
newPassword: newPassword,
|
||||
emailSent: sendEmail
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to reset password');
|
||||
}
|
||||
});
|
||||
|
||||
// Resend creation email
|
||||
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get event details
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// The email processor will determine the language based on:
|
||||
// 1. Event language setting
|
||||
// 2. App settings general_default_language
|
||||
// 3. Email config default language
|
||||
// 4. Domain-based detection
|
||||
// So we don't need to determine it here
|
||||
|
||||
// For resending creation email, we need the actual password
|
||||
// First, try to get it from the request body if provided
|
||||
// Use optional chaining to handle cases where req.body might be undefined
|
||||
let galleryPassword = req.body?.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
if (!galleryPassword) {
|
||||
// We'll let the email processor determine the language for the security message
|
||||
galleryPassword = '{{password_security_message}}';
|
||||
}
|
||||
|
||||
// Dates will be formatted by the email processor based on recipient language
|
||||
|
||||
// Queue the email
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form; use the full URL so the
|
||||
// customer's mail client renders a clickable absolute link.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: galleryPassword,
|
||||
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
|
||||
welcome_message: event.welcome_message || '',
|
||||
eventId: id,
|
||||
isResend: true // Flag to indicate this is a resend
|
||||
});
|
||||
|
||||
// Log the activity using the proper schema
|
||||
try {
|
||||
await logActivity('email_resent', {
|
||||
email_type: 'gallery_created',
|
||||
recipient: recipientEmail,
|
||||
ip_address: req.ip || '0.0.0.0',
|
||||
user_agent: req.get('user-agent') || 'Unknown'
|
||||
}, id, {
|
||||
type: 'admin',
|
||||
id: req.admin.id,
|
||||
name: req.admin.username
|
||||
});
|
||||
} catch (logError) {
|
||||
logger.error('Warning: Failed to log activity:', logError);
|
||||
// Don't fail the request if activity logging fails
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Creation email has been queued for sending'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error resending creation email:', error);
|
||||
errorResponse(res, error, 500, 'Failed to resend creation email');
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||
// registration order so Express route matching is unchanged.
|
||||
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const crypto = require('crypto');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
|
||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
|
||||
|
||||
// The watermark LOOK (source/position/opacity/style/size) is global-only
|
||||
// (app_settings, Settings → Slideshow); events only carry the show_watermark
|
||||
// mode (NULL=inherit / true / false), so no per-event look enums live here.
|
||||
|
||||
// Build the public slideshow URL for a freshly-minted/existing token.
|
||||
async function buildSlideshowUrl(slug, token) {
|
||||
if (!token) return null;
|
||||
const base = await getFrontendBaseUrl();
|
||||
return `${base.replace(/\/$/, '')}/gallery/${slug}/show/${token}`;
|
||||
}
|
||||
|
||||
// Fetch the event respecting the editor-role ownership scope (requireEventOwnership
|
||||
// already gates the route; this re-applies the created_by filter for editors so the
|
||||
// 404 is identical to the rest of this file).
|
||||
async function loadOwnedEvent(req) {
|
||||
let q = db('events').where('id', req.params.id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
q = q.where('created_by', req.admin.id);
|
||||
}
|
||||
return q.first();
|
||||
}
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
// Generate (or rotate) the slideshow share token. Idempotent in intent: each
|
||||
// call mints a fresh token, which both "Generate" (first time) and "Regenerate"
|
||||
// (rotate, kills the old link) use.
|
||||
router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const event = await loadOwnedEvent(req);
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
// NB: the events table has no updated_at column (only created_at), so we
|
||||
// must not set it here or the UPDATE throws.
|
||||
await db('events').where('id', req.params.id).update({
|
||||
show_share_token: token
|
||||
});
|
||||
|
||||
await logActivity('slideshow_link_generated',
|
||||
{ eventName: event.event_name, rotated: Boolean(event.show_share_token) },
|
||||
req.params.id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
show_share_token: token,
|
||||
slideshow_url: await buildSlideshowUrl(event.slug, token)
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to generate slideshow link');
|
||||
}
|
||||
});
|
||||
|
||||
// Disable the slideshow link (null the token). The public /show/ route dies on
|
||||
// its next poll, killing any projector currently pointed at the old link.
|
||||
router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const event = await loadOwnedEvent(req);
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
await db('events').where('id', req.params.id).update({
|
||||
show_share_token: null
|
||||
});
|
||||
|
||||
await logActivity('slideshow_link_disabled',
|
||||
{ eventName: event.event_name },
|
||||
req.params.id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ show_share_token: null });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to disable slideshow link');
|
||||
}
|
||||
});
|
||||
|
||||
// Update the LIVE slideshow settings (display time / transition style / speed).
|
||||
// A running projector picks these up via the show-page settings poll within a
|
||||
// few seconds — no need to regenerate the link.
|
||||
router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, [
|
||||
body('show_interval_ms').optional().isInt({ min: 1000, max: 120000 }),
|
||||
body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS),
|
||||
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
|
||||
body('show_watermark').optional({ nullable: true }),
|
||||
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS)
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() });
|
||||
}
|
||||
|
||||
const event = await loadOwnedEvent(req);
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// events has no updated_at column — don't set it.
|
||||
const updates = {};
|
||||
if (req.body.show_interval_ms !== undefined) updates.show_interval_ms = parseInt(req.body.show_interval_ms, 10);
|
||||
if (req.body.show_transition !== undefined) updates.show_transition = req.body.show_transition;
|
||||
if (req.body.show_transition_ms !== undefined) updates.show_transition_ms = parseInt(req.body.show_transition_ms, 10);
|
||||
// Tri-state: explicit null = inherit the global default.
|
||||
if (req.body.show_watermark !== undefined) {
|
||||
updates.show_watermark = req.body.show_watermark === null
|
||||
? null
|
||||
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
|
||||
}
|
||||
if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
|
||||
|
||||
// Knex throws on an empty update; only write if something changed.
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db('events').where('id', req.params.id).update(updates);
|
||||
}
|
||||
|
||||
res.json({
|
||||
show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000,
|
||||
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
|
||||
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
|
||||
show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
|
||||
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update slideshow settings');
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
@@ -8,7 +8,6 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || '';
|
||||
|
||||
@@ -74,10 +73,10 @@ router.get(
|
||||
'gallery_guests.created_at',
|
||||
'gallery_guests.last_seen_at',
|
||||
'gallery_guests.email_verified_at',
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'like\' THEN 1 END) AS likes'),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'favorite\' THEN 1 END) AS favorites'),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'comment\' THEN 1 END) AS comments'),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'rating\' THEN 1 END) AS ratings'),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"),
|
||||
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
|
||||
)
|
||||
.orderBy('gallery_guests.created_at', 'desc');
|
||||
@@ -95,7 +94,8 @@ router.get(
|
||||
|
||||
res.json({ guests });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to list guests');
|
||||
logger.error('Error listing guests:', error);
|
||||
res.status(500).json({ error: 'Failed to list guests' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -117,7 +117,7 @@ router.get(
|
||||
const photos = await db('photos')
|
||||
.leftJoin('photo_feedback', function () {
|
||||
this.on('photo_feedback.photo_id', '=', 'photos.id')
|
||||
.andOn(db.raw('photo_feedback.feedback_type IN (\'like\',\'favorite\')'))
|
||||
.andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')"))
|
||||
.andOnNotNull('photo_feedback.guest_id');
|
||||
})
|
||||
.where('photos.event_id', eventId)
|
||||
@@ -144,7 +144,8 @@ router.get(
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch aggregate view');
|
||||
logger.error('Error fetching aggregate view:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch aggregate view' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -195,7 +196,8 @@ router.get(
|
||||
|
||||
res.json({ invites });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to list invites');
|
||||
logger.error('Error listing invites:', error);
|
||||
res.status(500).json({ error: 'Failed to list invites' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -264,7 +266,8 @@ router.post(
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to create invite');
|
||||
logger.error('Error creating invite:', error);
|
||||
res.status(500).json({ error: 'Failed to create invite' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -299,7 +302,8 @@ router.delete(
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to revoke invite');
|
||||
logger.error('Error revoking invite:', error);
|
||||
res.status(500).json({ error: 'Failed to revoke invite' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -453,7 +457,8 @@ router.get(
|
||||
selections,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch guest detail');
|
||||
logger.error('Error fetching guest detail:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch guest detail' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -504,7 +509,8 @@ router.get(
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(selections.map((s) => s.original_filename || s.filename).join('\n'));
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to export guest');
|
||||
logger.error('Error exporting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to export guest' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -542,7 +548,8 @@ router.delete(
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to delete guest');
|
||||
logger.error('Error deleting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to delete guest' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -593,7 +600,8 @@ router.post(
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to merge guests');
|
||||
logger.error('Error merging guests:', error);
|
||||
res.status(500).json({ error: 'Failed to merge guests' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ const express = require('express');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get notifications (unread activity logs)
|
||||
@@ -40,7 +39,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
if (typeof notification.metadata === 'object') return notification.metadata;
|
||||
return JSON.parse(notification.metadata);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse metadata for notification:', notification.id, e.message);
|
||||
console.warn('Failed to parse metadata for notification:', notification.id, e.message);
|
||||
return {};
|
||||
}
|
||||
})(),
|
||||
@@ -60,7 +59,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
unreadCount: unreadCount.count || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Notifications fetch error:', error);
|
||||
console.error('Notifications fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notifications' });
|
||||
}
|
||||
});
|
||||
@@ -78,7 +77,7 @@ router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
res.json({ message: 'Notification marked as read' });
|
||||
} catch (error) {
|
||||
logger.error('Mark notification read error:', error);
|
||||
console.error('Mark notification read error:', error);
|
||||
res.status(500).json({ error: 'Failed to mark notification as read' });
|
||||
}
|
||||
});
|
||||
@@ -94,7 +93,7 @@ router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
res.json({ message: 'All notifications marked as read' });
|
||||
} catch (error) {
|
||||
logger.error('Mark all notifications read error:', error);
|
||||
console.error('Mark all notifications read error:', error);
|
||||
res.status(500).json({ error: 'Failed to mark all notifications as read' });
|
||||
}
|
||||
});
|
||||
@@ -113,7 +112,7 @@ router.delete('/clear-all', adminAuth, requirePermission('settings.edit'), async
|
||||
const deletedCount = await db('activity_logs').delete();
|
||||
res.json({ message: 'All notifications cleared', deletedCount });
|
||||
} catch (error) {
|
||||
logger.error('Clear notifications error:', error);
|
||||
console.error('Clear notifications error:', error);
|
||||
res.status(500).json({ error: 'Failed to clear notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,9 +11,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const { PhotoExportService } = require('../services/photoExportService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const exportService = new PhotoExportService();
|
||||
|
||||
@@ -68,7 +66,8 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
|
||||
|
||||
const sort = req.query.sort || 'date';
|
||||
const order = req.query.order || 'desc';
|
||||
const { page, limit } = getPagination(req, { limit: 50 });
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
|
||||
// Build filtered query
|
||||
const filterBuilder = new PhotoFilterBuilder(
|
||||
@@ -125,7 +124,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Filter photos error:', error);
|
||||
console.error('Filter photos error:', error);
|
||||
res.status(500).json({ error: 'Failed to filter photos' });
|
||||
}
|
||||
});
|
||||
@@ -147,7 +146,7 @@ router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view
|
||||
data: summary
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Filter summary error:', error);
|
||||
console.error('Filter summary error:', error);
|
||||
res.status(500).json({ error: 'Failed to get filter summary' });
|
||||
}
|
||||
});
|
||||
@@ -207,7 +206,7 @@ router.post('/:eventId/export', adminAuth, requirePermission('photos.download'),
|
||||
res.send(result.content);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Export photos error:', error);
|
||||
console.error('Export photos error:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to export photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,8 +22,6 @@ const downloadZipService = require('../services/downloadZipService');
|
||||
const { findReplacementCandidate, replacePhoto } = require('../services/photoReplacementService');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -33,7 +31,7 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
logger.info('Multer destination called for file:', file.originalname);
|
||||
console.log('Multer destination called for file:', file.originalname);
|
||||
const { eventId } = req.params;
|
||||
|
||||
// We'll validate the event exists in the route handler
|
||||
@@ -42,7 +40,7 @@ const storage = multer.diskStorage({
|
||||
|
||||
// Create directory synchronously
|
||||
require('fs').mkdirSync(tempPath, { recursive: true });
|
||||
logger.info('Temp destination path:', tempPath);
|
||||
console.log('Temp destination path:', tempPath);
|
||||
|
||||
// Store temp path for cleanup
|
||||
req.tempUploadPath = tempPath;
|
||||
@@ -50,10 +48,10 @@ const storage = multer.diskStorage({
|
||||
cb(null, tempPath);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
logger.info('Multer filename called for file:', file.originalname);
|
||||
console.log('Multer filename called for file:', file.originalname);
|
||||
// Use a simple temporary filename
|
||||
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
|
||||
logger.info('Temp filename:', tempName);
|
||||
console.log('Temp filename:', tempName);
|
||||
cb(null, tempName);
|
||||
}
|
||||
});
|
||||
@@ -91,7 +89,7 @@ const resolveAllowedTypes = async (req, res, next) => {
|
||||
try {
|
||||
req.allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve allowed MIME types:', error);
|
||||
console.error('Failed to resolve allowed MIME types:', error);
|
||||
req.allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
next();
|
||||
@@ -113,7 +111,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
return (req, res, next) => {
|
||||
// Set timeout for the request
|
||||
req.setTimeout(timeout, () => {
|
||||
logger.error('Upload request timed out');
|
||||
console.error('Upload request timed out');
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({ error: 'Upload request timed out' });
|
||||
}
|
||||
@@ -121,7 +119,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
|
||||
// Set response timeout as well
|
||||
res.setTimeout(timeout, () => {
|
||||
logger.error('Upload response timed out');
|
||||
console.error('Upload response timed out');
|
||||
});
|
||||
|
||||
next();
|
||||
@@ -135,12 +133,13 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
} catch (error) {
|
||||
return errorResponse(res, error, 500, 'Unable to determine upload limits');
|
||||
console.error('Failed to resolve max files per upload:', error);
|
||||
return res.status(500).json({ error: 'Unable to determine upload limits' });
|
||||
}
|
||||
|
||||
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
|
||||
if (err) {
|
||||
logger.error('Multer error:', err);
|
||||
console.error('Multer error:', err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 10GB per file.' });
|
||||
@@ -167,7 +166,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
logger.error('Failed to clean up temp upload directory:', e);
|
||||
console.error('Failed to clean up temp upload directory:', e);
|
||||
}
|
||||
};
|
||||
res.on('finish', cleanupTempDir);
|
||||
@@ -178,16 +177,16 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
const { category_id, replace_by_name } = req.body;
|
||||
const replaceByName = replace_by_name === 'true' || replace_by_name === true;
|
||||
|
||||
logger.info('Upload request received for event:', eventId);
|
||||
logger.info('Body:', req.body);
|
||||
logger.info('Files:', req.files ? req.files.length : 'none');
|
||||
logger.info('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype })));
|
||||
logger.info('Category ID received:', category_id);
|
||||
console.log('Upload request received for event:', eventId);
|
||||
console.log('Body:', req.body);
|
||||
console.log('Files:', req.files ? req.files.length : 'none');
|
||||
console.log('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype })));
|
||||
console.log('Category ID received:', category_id);
|
||||
|
||||
// Verify event exists and admin has access
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
logger.error('Event not found:', eventId);
|
||||
console.error('Event not found:', eventId);
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
@@ -214,8 +213,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
logger.error('No files in request. req.files:', req.files);
|
||||
logger.error('Request body keys:', Object.keys(req.body));
|
||||
console.error('No files in request. req.files:', req.files);
|
||||
console.error('Request body keys:', Object.keys(req.body));
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
@@ -390,7 +389,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
category_id: parsedCategoryId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Error queuing file ${file.originalname}:`, err);
|
||||
console.error(`Error queuing file ${file.originalname}:`, err);
|
||||
errors.push({ filename: file.originalname, error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -452,9 +451,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
// 202 Accepted — files stored, processing happens in background.
|
||||
res.status(202).json(response);
|
||||
} catch (error) {
|
||||
console.error('Error uploading photos:', error);
|
||||
// Temp directory cleanup is handled by the response finish/close
|
||||
// listeners above, regardless of which exit path fires.
|
||||
errorResponse(res, error, 500, 'Failed to upload photos');
|
||||
res.status(500).json({ error: 'Failed to upload photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -523,7 +523,8 @@ router.get(
|
||||
...summariseUpload(group.photos),
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to read upload status');
|
||||
console.error('Error reading upload status:', error);
|
||||
res.status(500).json({ error: 'Failed to read upload status' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -574,7 +575,7 @@ router.get(
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Upload stream poll error:', e);
|
||||
console.error('Upload stream poll error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -621,7 +622,8 @@ router.post(
|
||||
});
|
||||
res.json({ id: photo.id, status: 'pending' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to retry photo processing');
|
||||
console.error('Error retrying photo processing:', error);
|
||||
res.status(500).json({ error: 'Failed to retry photo processing' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -649,7 +651,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
const originalKey = resolvePhotoStorageKey(event, photo);
|
||||
if (originalKey) await storage.delete(originalKey);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting photo file:', error);
|
||||
console.error('Error deleting photo file:', error);
|
||||
}
|
||||
|
||||
// photo.thumbnail_path is stored as the canonical storage key
|
||||
@@ -658,7 +660,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
try {
|
||||
await storage.delete(photo.thumbnail_path);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting thumbnail:', error);
|
||||
console.error('Error deleting thumbnail:', error);
|
||||
}
|
||||
}
|
||||
if (photo.hero_path) {
|
||||
@@ -698,7 +700,8 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
downloadZipService.invalidate(parseInt(eventId));
|
||||
res.json({ message: 'Photo deleted successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to delete photo');
|
||||
console.error('Error deleting photo:', error);
|
||||
res.status(500).json({ error: 'Failed to delete photo' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -760,7 +763,8 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
|
||||
photo: updatedPhoto
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update photo');
|
||||
console.error('Error updating photo:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -793,7 +797,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
const originalKey = resolvePhotoStorageKey(event, photo);
|
||||
if (originalKey) await storage.delete(originalKey);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting photo file:', error);
|
||||
console.error('Error deleting photo file:', error);
|
||||
}
|
||||
|
||||
if (photo.thumbnail_path) {
|
||||
@@ -838,7 +842,8 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
downloadZipService.invalidate(parseInt(eventId));
|
||||
res.json({ message: `${photos.length} photos deleted successfully` });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to delete photos');
|
||||
console.error('Error bulk deleting photos:', error);
|
||||
res.status(500).json({ error: 'Failed to delete photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -900,7 +905,8 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
|
||||
res.json({ message: `${photoIds.length} photos updated successfully` });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update photos');
|
||||
console.error('Error bulk updating photos:', error);
|
||||
res.status(500).json({ error: 'Failed to update photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -955,7 +961,8 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
});
|
||||
res.sendFile(filePath);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download photo');
|
||||
console.error('Error downloading photo:', error);
|
||||
res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1089,7 +1096,8 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch photos');
|
||||
console.error('Error fetching photos:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1145,7 +1153,8 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
|
||||
}
|
||||
res.sendFile(path.resolve(filePath));
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve photo');
|
||||
console.error('Error serving photo:', error);
|
||||
res.status(500).json({ error: 'Failed to serve photo' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1159,7 +1168,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
logger.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||
console.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
@@ -1183,7 +1192,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
if (!thumbnailPath) {
|
||||
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
console.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||
}
|
||||
|
||||
@@ -1200,9 +1209,10 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
const stream = await storage.get(thumbnailPath);
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
logger.error('Error serving thumbnail:', error);
|
||||
logger.error('Photo ID:', req.params.photoId);
|
||||
errorResponse(res, error, 500, 'Failed to serve thumbnail');
|
||||
console.error('Error serving thumbnail:', error);
|
||||
console.error('Photo ID:', req.params.photoId);
|
||||
console.error('Event ID:', req.params.eventId);
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1222,7 +1232,8 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requi
|
||||
storagePath: getStoragePath()
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch photo debug data');
|
||||
console.error('Error fetching admin photo debug data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch photo debug data' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1251,7 +1262,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
|
||||
// Validate file size (max 10GB)
|
||||
const maxSize = 10 * 1024 * 1024 * 1024;
|
||||
if (fileSize > maxSize) {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 10GB.' });
|
||||
return res.status(400).json({ error: `File too large. Maximum size is 10GB.` });
|
||||
}
|
||||
|
||||
const result = await chunkedUpload.initializeUpload({
|
||||
@@ -1264,7 +1275,8 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to initialize upload');
|
||||
console.error('Error initializing chunked upload:', error);
|
||||
res.status(500).json({ error: 'Failed to initialize upload' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1284,7 +1296,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error uploading chunk:', error);
|
||||
console.error('Error uploading chunk:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload chunk' });
|
||||
}
|
||||
});
|
||||
@@ -1317,7 +1329,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
||||
try {
|
||||
await fs.rm(mergedFile.tempDir, { recursive: true, force: true });
|
||||
} catch (cleanupErr) {
|
||||
logger.warn('Failed to clean up temp directory:', cleanupErr.message);
|
||||
console.warn('Failed to clean up temp directory:', cleanupErr.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -1326,7 +1338,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
||||
photos: uploadedPhotos
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error completing chunked upload:', error);
|
||||
console.error('Error completing chunked upload:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to complete upload' });
|
||||
}
|
||||
});
|
||||
@@ -1344,7 +1356,8 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermis
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to get upload status');
|
||||
console.error('Error getting upload status:', error);
|
||||
res.status(500).json({ error: 'Failed to get upload status' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1357,7 +1370,8 @@ router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission
|
||||
|
||||
res.json({ success: true, message: 'Upload aborted' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to abort upload');
|
||||
console.error('Error aborting upload:', error);
|
||||
res.status(500).json({ error: 'Failed to abort upload' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const { db } = require('../database/db');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
@@ -49,7 +48,7 @@ function transformS3Config(body) {
|
||||
*/
|
||||
router.get('/status', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { limit } = getPagination(req, { limit: 10 });
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const history = await restoreService.getRestoreHistory(limit);
|
||||
|
||||
const status = {
|
||||
|
||||
@@ -22,8 +22,6 @@ const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const { upsertAppSetting } = require('../utils/appSettings');
|
||||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
@@ -160,7 +158,8 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
|
||||
res.json(settingsObject);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch settings');
|
||||
console.error('Settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -201,7 +200,8 @@ router.get('/customer-surface', adminAuth, requirePermission('settings.view'), a
|
||||
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch customer surface settings');
|
||||
console.error('Customer surface settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch customer surface settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,7 +231,8 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a
|
||||
|
||||
res.json({ message: 'Customer surface settings updated', updated: updates.map((u) => u.setting_key) });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to save customer surface settings');
|
||||
console.error('Customer surface settings save error:', error);
|
||||
res.status(500).json({ error: 'Failed to save customer surface settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -294,7 +295,8 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
|
||||
}
|
||||
res.json({ message: 'Accounting settings updated', updated: updates.map((u) => u.setting_key) });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to save accounting settings');
|
||||
console.error('Accounting settings save error:', error);
|
||||
res.status(500).json({ error: 'Failed to save accounting settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -358,7 +360,8 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
|
||||
require('../utils/slideshowGlobals').invalidateSlideshowGlobals();
|
||||
res.json({ message: 'Slideshow settings updated', updated: updates.map((u) => u.setting_key) });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to save slideshow settings');
|
||||
console.error('Slideshow settings save error:', error);
|
||||
res.status(500).json({ error: 'Failed to save slideshow settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -410,7 +413,8 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
|
||||
res.json(settingsObject);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch settings');
|
||||
console.error('Settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -430,7 +434,8 @@ router.get('/password/complexity', adminAuth, requirePermission('settings.view')
|
||||
config
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch password complexity settings');
|
||||
console.error('Password complexity settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch password complexity settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -572,9 +577,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
const faviconPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(faviconPath);
|
||||
logger.info('Deleted favicon file:', faviconPath);
|
||||
console.log('Deleted favicon file:', faviconPath);
|
||||
} catch (err) {
|
||||
logger.error('Error deleting favicon file:', err);
|
||||
console.error('Error deleting favicon file:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -603,9 +608,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
const logoPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(logoPath);
|
||||
logger.info('Deleted logo file:', logoPath);
|
||||
console.log('Deleted logo file:', logoPath);
|
||||
} catch (err) {
|
||||
logger.error('Error deleting logo file:', err);
|
||||
console.error('Error deleting logo file:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,20 +656,20 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
// Start background regeneration of all watermarks
|
||||
logger.info('Watermark settings changed, starting background regeneration');
|
||||
console.log('Watermark settings changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
logger.info(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('Watermark regeneration failed:', err);
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
} else {
|
||||
// Watermarking was disabled, clear all pre-generated watermarks
|
||||
logger.info('Watermarking disabled, clearing pre-generated watermarks');
|
||||
console.log('Watermarking disabled, clearing pre-generated watermarks');
|
||||
watermarkGeneratorService.clearAllWatermarks()
|
||||
.catch(err => logger.error('Failed to clear watermarks:', err));
|
||||
.catch(err => console.error('Failed to clear watermarks:', err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,7 +678,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update branding settings');
|
||||
console.error('Branding update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update branding settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -705,7 +711,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
|
||||
}
|
||||
await fs.unlink(oldPath);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old logo:', error);
|
||||
console.error('Failed to delete old logo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,7 +751,8 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
|
||||
logoUrl: publicPath
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to upload logo');
|
||||
console.error('Logo upload error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload logo' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -765,7 +772,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
|
||||
if (p.startsWith('"')) p = JSON.parse(p);
|
||||
await fs.unlink(p);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete logo file:', error);
|
||||
console.error('Failed to delete logo file:', error);
|
||||
}
|
||||
}
|
||||
await db('app_settings')
|
||||
@@ -774,7 +781,8 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
|
||||
|
||||
res.json({ message: 'Logo removed' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to remove logo');
|
||||
console.error('Logo delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to remove logo' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -804,7 +812,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old watermark logo:', error);
|
||||
console.error('Failed to delete old watermark logo:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,13 +854,13 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
let watermarkRegenerationStarted = false;
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
logger.info('Watermark logo changed, starting background regeneration');
|
||||
console.log('Watermark logo changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
logger.info(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('Watermark regeneration failed:', err);
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
}
|
||||
@@ -863,7 +871,8 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to upload watermark logo');
|
||||
console.error('Watermark logo upload error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload watermark logo' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -899,7 +908,8 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
|
||||
res.json({ message: 'Theme settings updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update theme settings');
|
||||
console.error('Theme update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update theme settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -995,7 +1005,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
||||
require('../services/downloadFilenameService').clearCache();
|
||||
require('../services/downloadZipService').invalidateAll();
|
||||
} catch (e) {
|
||||
logger.warn('Failed to invalidate download caches after filename setting change:', e.message);
|
||||
console.warn('Failed to invalidate download caches after filename setting change:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1010,7 +1020,8 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
||||
|
||||
res.json({ message: 'General settings updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update general settings');
|
||||
console.error('General settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update general settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1048,7 +1059,8 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
res.json({ message: 'Security settings updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update security settings');
|
||||
console.error('Security settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update security settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1103,7 +1115,8 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
|
||||
|
||||
res.json({ message: 'Analytics settings updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update analytics settings');
|
||||
console.error('Analytics settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update analytics settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1166,7 +1179,8 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
|
||||
|
||||
res.json({ message: 'SEO settings updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update SEO settings');
|
||||
console.error('SEO settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update SEO settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1202,7 +1216,7 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
logger.error('Archive file not found:', archive.archive_path, error.message);
|
||||
console.error('Archive file not found:', archive.archive_path, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1220,7 +1234,7 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
|
||||
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
|
||||
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
|
||||
} catch (diskError) {
|
||||
logger.error('Disk stats error:', diskError.message);
|
||||
console.error('Disk stats error:', diskError.message);
|
||||
}
|
||||
|
||||
const clampDiskValue = (value) => {
|
||||
@@ -1299,27 +1313,27 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
|
||||
}
|
||||
|
||||
switch (setting.setting_key) {
|
||||
case 'general_storage_soft_limit_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
configuredSoftLimit = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_capacity_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
capacityOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_available_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
availableOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
case 'general_storage_soft_limit_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
configuredSoftLimit = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_capacity_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
capacityOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_available_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
availableOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Storage settings read error:', error.message);
|
||||
console.error('Storage settings read error:', error.message);
|
||||
}
|
||||
|
||||
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
|
||||
@@ -1393,7 +1407,8 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
|
||||
disk_override_source: overrideSource
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch storage information');
|
||||
console.error('Storage info error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch storage information' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1430,7 +1445,8 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp
|
||||
|
||||
res.json({ faviconUrl });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to upload favicon');
|
||||
console.error('Error uploading favicon:', error);
|
||||
res.status(500).json({ error: 'Failed to upload favicon' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1493,7 +1509,8 @@ router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit')
|
||||
|
||||
res.json({ message: 'Rate limit settings updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update rate limit settings');
|
||||
console.error('Rate limit settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update rate limit settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1513,7 +1530,8 @@ router.get('/public-site/default', adminAuth, requirePermission('settings.view')
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load defaults');
|
||||
console.error('Failed to load public site defaults:', error);
|
||||
res.status(500).json({ error: 'Failed to load defaults' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1566,7 +1584,8 @@ router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'),
|
||||
branding: defaults.branding
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to reset template');
|
||||
console.error('Failed to reset public site template:', error);
|
||||
res.status(500).json({ error: 'Failed to reset template' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
|
||||
const packageJson = JSON.parse(packageContent);
|
||||
backendVersion = packageJson.version || '1.0.0';
|
||||
} catch (err) {
|
||||
logger.error('Could not read package.json:', err);
|
||||
console.error('Could not read package.json:', err);
|
||||
}
|
||||
|
||||
const channel = getCurrentChannel(backendVersion);
|
||||
@@ -42,7 +42,7 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
|
||||
channel: channel
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching version:', error);
|
||||
console.error('Error fetching version:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch version information' });
|
||||
}
|
||||
});
|
||||
@@ -231,7 +231,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
|
||||
`, [dbName]);
|
||||
dbSize = result.rows[0]?.size || 0;
|
||||
} catch (error) {
|
||||
logger.error('Error getting PostgreSQL database size:', error);
|
||||
console.error('Error getting PostgreSQL database size:', error);
|
||||
}
|
||||
} else {
|
||||
// SQLite - check file size
|
||||
@@ -240,7 +240,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
|
||||
const stats = await fs.stat(dbPath);
|
||||
dbSize = stats.size;
|
||||
} catch (error) {
|
||||
logger.error('Error getting SQLite database size:', error);
|
||||
console.error('Error getting SQLite database size:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
logger.error('Archive file not found:', archive.archive_path);
|
||||
console.error('Archive file not found:', archive.archive_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,7 +345,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching system status:', error);
|
||||
console.error('Error fetching system status:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch system status' });
|
||||
}
|
||||
});
|
||||
@@ -407,7 +407,7 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re
|
||||
timestamp: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching database info:', error);
|
||||
console.error('Error fetching database info:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch database information' });
|
||||
}
|
||||
});
|
||||
|
||||
+177
-44
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const {
|
||||
trackFailedAttempt,
|
||||
trackSuccessfulLogin,
|
||||
@@ -15,7 +16,6 @@ const {
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const {
|
||||
setAdminAuthCookie,
|
||||
clearAdminAuthCookie,
|
||||
@@ -33,6 +33,49 @@ const {
|
||||
} = require('../utils/passwordValidation');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Finish a successful admin login: reset the lockout counter, stamp
|
||||
* last_login, mint the 24h admin JWT, set the HttpOnly cookie, and return the
|
||||
* user payload. Shared by the direct (no-MFA) path and the MFA-verify path so
|
||||
* both produce an identical session. `lockoutKey` is the identifier the user
|
||||
* typed (username or email) so success/failure tracking stays in one bucket.
|
||||
*/
|
||||
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
|
||||
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name,
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
return res.json({
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Admin login with enhanced security
|
||||
router.post('/admin/login', [
|
||||
body('username').notEmpty().trim(),
|
||||
@@ -95,45 +138,129 @@ router.post('/admin/login', [
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// Successful login
|
||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
||||
|
||||
// Update last login and login metadata
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
// Generate token with additional claims including role
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name, // Add role to JWT
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
// Token is delivered via HttpOnly cookie only (not in response body)
|
||||
res.json({
|
||||
user: {
|
||||
// Second factor: if this admin has TOTP enabled, do NOT complete the login
|
||||
// yet. Issue a short-lived, single-purpose mfa_pending token and require the
|
||||
// code via /admin/login/mfa. We deliberately don't reset the lockout counter
|
||||
// (trackSuccessfulLogin) or stamp last_login until the second factor passes,
|
||||
// so MFA brute-force is still gated by the account lockout. `loginId` carries
|
||||
// the typed identifier so the verify step tracks the same lockout bucket.
|
||||
if (mfaService.isEnrolled(admin)) {
|
||||
const mfaToken = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
}
|
||||
});
|
||||
type: 'mfa_pending',
|
||||
loginId: username
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '5m',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
return res.json({ mfaRequired: true, mfaToken });
|
||||
}
|
||||
|
||||
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, username);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Login failed');
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Second-factor verification. Exchanges the short-lived mfa_pending token
|
||||
// (from /admin/login) plus a TOTP or recovery code for a full admin session.
|
||||
router.post('/admin/login/mfa', [
|
||||
body('mfaToken').notEmpty(),
|
||||
body('code').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { mfaToken, code } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(401).json({
|
||||
error: 'Your verification session expired. Please sign in again.',
|
||||
code: 'MFA_SESSION_EXPIRED'
|
||||
});
|
||||
}
|
||||
|
||||
if (decoded.type !== 'mfa_pending') {
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
const lockoutKey = decoded.loginId || decoded.username;
|
||||
const lockoutStatus = await checkAccountLockout(lockoutKey);
|
||||
if (lockoutStatus.isLocked) {
|
||||
return res.status(423).json({
|
||||
error: 'Account temporarily locked due to too many failed attempts',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', decoded.id)
|
||||
.select(
|
||||
'admin_users.*',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!admin || !admin.is_active || !mfaService.isEnrolled(admin)) {
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// TOTP first, then a one-time recovery code.
|
||||
let ok = mfaService.verifyTotpEncrypted(code, admin.two_factor_secret);
|
||||
let usedRecovery = false;
|
||||
let remainingHashes = null;
|
||||
if (!ok) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
const result = await mfaService.consumeRecoveryCode(code, stored);
|
||||
if (result.matched) {
|
||||
ok = true;
|
||||
usedRecovery = true;
|
||||
remainingHashes = result.remainingHashes;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid verification code', code: 'MFA_INVALID' });
|
||||
}
|
||||
|
||||
if (usedRecovery) {
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_recovery_codes: JSON.stringify(remainingHashes),
|
||||
updated_at: new Date()
|
||||
});
|
||||
await logActivity('admin_mfa_recovery_used',
|
||||
{ admin_id: admin.id, remaining: remainingHashes.length },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
}
|
||||
|
||||
await logActivity('admin_mfa_login',
|
||||
{ admin_id: admin.id, method: usedRecovery ? 'recovery_code' : 'totp' },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey);
|
||||
} catch (error) {
|
||||
logger.error('MFA verification error:', error);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -175,7 +302,8 @@ router.post('/logout', async (req, res) => {
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Logout failed');
|
||||
logger.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -287,7 +415,8 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Verification failed');
|
||||
logger.error('Gallery verification error:', error);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -362,7 +491,8 @@ router.post('/gallery/:slug/client-login', [
|
||||
accessLevel: 'client'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Authentication failed');
|
||||
logger.error('Client login error:', error);
|
||||
res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -448,7 +578,8 @@ router.post('/gallery/share-login', [
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Share link login failed');
|
||||
logger.error('Share link authentication error:', error);
|
||||
res.status(500).json({ error: 'Share link login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -463,7 +594,8 @@ router.post('/gallery/logout', async (req, res) => {
|
||||
clearGalleryAuthCookies(res, slug);
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Logout failed');
|
||||
logger.error('Gallery logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -677,7 +809,8 @@ router.post('/admin/change-password', [
|
||||
score: passwordValidation.score
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to change password');
|
||||
logger.error('Password change error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const { customerAuth } = require('../middleware/customerAuth');
|
||||
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
|
||||
@@ -118,7 +117,8 @@ router.get('/events', customerAuth, async (req, res) => {
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load events');
|
||||
logger.error('Customer event list error:', error);
|
||||
res.status(500).json({ error: 'Failed to load events' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -225,7 +225,8 @@ router.get('/events/:slug/access-token', [
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to issue access token');
|
||||
logger.error('Customer access-token exchange error:', error);
|
||||
res.status(500).json({ error: 'Failed to issue access token' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -247,7 +248,8 @@ router.get('/profile', customerAuth, async (req, res) => {
|
||||
}
|
||||
res.json({ profile: shapeProfile(row) });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load profile');
|
||||
logger.error('Customer profile read error:', error);
|
||||
res.status(500).json({ error: 'Failed to load profile' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -314,7 +316,8 @@ router.put('/profile', [
|
||||
|
||||
res.json({ profile: shapeProfile(row) });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update profile');
|
||||
logger.error('Customer profile update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update profile' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -373,7 +376,8 @@ router.post('/profile/password', [
|
||||
|
||||
res.json({ message: 'Password updated' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to change password');
|
||||
logger.error('Customer password change error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -453,7 +457,8 @@ router.get('/quotes', customerAuth, async (req, res) => {
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load quotes');
|
||||
logger.error('Customer quotes list error:', error);
|
||||
res.status(500).json({ error: 'Failed to load quotes' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -540,7 +545,8 @@ router.get('/invoices', customerAuth, async (req, res) => {
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load invoices');
|
||||
logger.error('Customer invoice list error:', error);
|
||||
res.status(500).json({ error: 'Failed to load invoices' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -576,7 +582,8 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.send(buf);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to render quote PDF');
|
||||
logger.error('Customer quote PDF error:', error);
|
||||
res.status(500).json({ error: 'Failed to render quote PDF' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -607,7 +614,8 @@ router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.send(buf);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to render invoice PDF');
|
||||
logger.error('Customer invoice PDF error:', error);
|
||||
res.status(500).json({ error: 'Failed to render invoice PDF' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -671,7 +679,8 @@ router.get('/contracts', customerAuth, async (req, res) => {
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load contracts');
|
||||
logger.error('Customer contracts list error:', error);
|
||||
res.status(500).json({ error: 'Failed to load contracts' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -708,7 +717,8 @@ router.get('/contracts/:id/pdf', customerAuth, async (req, res) => {
|
||||
res.set('Content-Disposition', `inline; filename="${path.basename(filePath)}"`);
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to render contract PDF');
|
||||
logger.error('Customer contract PDF error:', error);
|
||||
res.status(500).json({ error: 'Failed to render contract PDF' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
@@ -236,7 +235,7 @@ router.post('/', adminAuth, [
|
||||
});
|
||||
}
|
||||
} catch (waError) {
|
||||
logger.warn('Failed to queue WhatsApp notification on create', waError.message);
|
||||
console.warn('Failed to queue WhatsApp notification on create', waError.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +271,7 @@ router.post('/', adminAuth, [
|
||||
customer_email: customerEmail
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Failed to create event' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
||||
const { handleAsync } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
|
||||
const downloadZipService = require('../services/downloadZipService');
|
||||
@@ -213,7 +213,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
promo_markdown: event.promo_markdown || null
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch gallery info');
|
||||
console.error('Error fetching gallery info:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch gallery info' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -738,7 +739,8 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch photos');
|
||||
console.error('Error fetching photos:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -770,7 +772,8 @@ router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (re
|
||||
|
||||
res.json({ message: 'Photo visibility updated', visibility });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||
logger.error('Error updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -798,7 +801,8 @@ router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, r
|
||||
|
||||
res.json({ message: `${count} photos updated`, visibility });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||
logger.error('Error bulk updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -912,7 +916,13 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download photo');
|
||||
logger.error('Unexpected error processing gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1083,7 +1093,12 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
action: 'download_all'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||
logger.error('Error creating bulk gallery download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to create download archive' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1200,7 +1215,12 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
action: 'download_selected'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||
logger.error('Error in download-selected:', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download selected photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1432,7 +1452,13 @@ router.get('/:slug/photo/:photoId',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve photo');
|
||||
logger.error('Error serving photo:', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to serve photo' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1523,7 +1549,12 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
stream.pipe(res);
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve thumbnail');
|
||||
logger.error('Error serving thumbnail:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1732,7 +1763,8 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
|
||||
identity_mode: settings.identity_mode || 'simple'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch feedback settings');
|
||||
console.error('Error fetching feedback settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch feedback settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1794,7 +1826,8 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
|
||||
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
|
||||
logger.info('Created temp upload directory:', tempUploadDir);
|
||||
} catch (mkdirErr) {
|
||||
return errorResponse(res, mkdirErr, 500, 'Server configuration error: unable to create upload directory');
|
||||
logger.error('Failed to create temp upload directory:', mkdirErr);
|
||||
return res.status(500).json({ error: 'Server configuration error: unable to create upload directory' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1844,7 +1877,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
|
||||
// Handle upload
|
||||
upload(req, res, async (err) => {
|
||||
if (err) {
|
||||
logger.error('Upload error:', err);
|
||||
console.error('Upload error:', err);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
@@ -1878,11 +1911,13 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
});
|
||||
} catch (processError) {
|
||||
errorResponse(res, processError, 500, 'Failed to process photos');
|
||||
console.error('Photo processing error:', processError);
|
||||
res.status(500).json({ error: 'Failed to process photos' });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to upload photos');
|
||||
console.error('Upload route error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1920,7 +1955,7 @@ router.get('/:slug/css-template', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
|
||||
res.send(template.css_content);
|
||||
} catch (error) {
|
||||
logger.error('Get CSS template error:', error);
|
||||
console.error('Get CSS template error:', error);
|
||||
res.status(500).send('/* Error loading template */');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ const { getStorage } = require('../services/storage');
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { withLocalCopy } = require('../services/imageProcessor');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -165,7 +164,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
|
||||
res.send(finalImage);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error serving protected image:', error);
|
||||
console.error('Error serving protected image:', error);
|
||||
res.status(500).json({ error: 'Failed to serve image' });
|
||||
}
|
||||
});
|
||||
@@ -209,7 +208,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error generating secure token:', error);
|
||||
console.error('Error generating secure token:', error);
|
||||
res.status(500).json({ error: 'Failed to generate token' });
|
||||
}
|
||||
});
|
||||
@@ -243,7 +242,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error generating signed URL:', error);
|
||||
console.error('Error generating signed URL:', error);
|
||||
res.status(500).json({ error: 'Failed to generate URL' });
|
||||
}
|
||||
});
|
||||
@@ -305,7 +304,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
||||
res.send(imageBuffer);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error serving signed image:', error);
|
||||
console.error('Error serving signed image:', error);
|
||||
res.status(500).json({ error: 'Failed to serve image' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get public CMS page
|
||||
@@ -42,7 +41,7 @@ router.get('/pages/:slug', async (req, res) => {
|
||||
updated_at: page.updated_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching public CMS page:', error);
|
||||
console.error('Error fetching public CMS page:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch page' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const express = require('express');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get public settings (branding and theme)
|
||||
@@ -206,7 +205,7 @@ router.get('/', async (req, res) => {
|
||||
|
||||
res.json(publicSettings);
|
||||
} catch (error) {
|
||||
logger.error('Public settings fetch error:', error);
|
||||
console.error('Public settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -412,7 +412,7 @@ async function getRecentAccessStats() {
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
logger.error('Error getting recent access stats:', error);
|
||||
console.error('Error getting recent access stats:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -440,7 +440,7 @@ async function getSuspiciousActivityStats() {
|
||||
uniqueIPs: parseInt(uniqueIPs.count)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error getting suspicious activity stats:', error);
|
||||
console.error('Error getting suspicious activity stats:', error);
|
||||
return { suspiciousEvents: 0, uniqueIPs: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ const cron = require('node-cron');
|
||||
const { db } = require('../database/db');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBytes } = require('../utils/formatBytes');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const backupManifest = require('./backupManifest');
|
||||
const S3StorageAdapter = require('./storage/s3Storage');
|
||||
@@ -779,6 +778,19 @@ async function performRsyncBackup(config, files) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (!bytes) {
|
||||
return '0 Bytes';
|
||||
}
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
async function performS3Backup(config, files) {
|
||||
try {
|
||||
const bucket = config.backup_s3_bucket;
|
||||
@@ -1177,7 +1189,7 @@ async function runBackupInternal(isManual = false) {
|
||||
|
||||
async function startBackupService() {
|
||||
try {
|
||||
const config = await resolveConfigWithFallback();
|
||||
const config = await resolveConfigWithFallback();
|
||||
if (!config || !normalizeBoolean(config.backup_enabled)) {
|
||||
if (backupJob) {
|
||||
backupJob.stop();
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
const cron = require('node-cron');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
const { db } = require('../database/db');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const backupManifest = require('./backupManifest');
|
||||
|
||||
// Backup job reference
|
||||
let backupJob = null;
|
||||
let backupConfig = null;
|
||||
let isRunning = false;
|
||||
|
||||
// Storage paths
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Calculate file checksum using SHA256
|
||||
*/
|
||||
async function calculateChecksum(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = require('fs').createReadStream(filePath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', data => hash.update(data));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database backup information
|
||||
*/
|
||||
async function getDatabaseBackupInfo() {
|
||||
try {
|
||||
// Check for recent database backup
|
||||
const recentDbBackup = await db('database_backup_runs')
|
||||
.where('status', 'completed')
|
||||
.orderBy('completed_at', 'desc')
|
||||
.first();
|
||||
|
||||
if (recentDbBackup && recentDbBackup.file_path) {
|
||||
return {
|
||||
type: recentDbBackup.backup_type,
|
||||
backupFile: recentDbBackup.file_path,
|
||||
size: recentDbBackup.file_size_bytes,
|
||||
checksum: recentDbBackup.checksum,
|
||||
tables: recentDbBackup.statistics ? JSON.parse(recentDbBackup.statistics).tables : {},
|
||||
rowCounts: recentDbBackup.table_checksums ? JSON.parse(recentDbBackup.table_checksums) : {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
|
||||
backupFile: null,
|
||||
size: 0,
|
||||
checksum: null,
|
||||
tables: {},
|
||||
rowCounts: {}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get database backup info:', error);
|
||||
return {
|
||||
type: 'unknown',
|
||||
backupFile: null,
|
||||
size: 0,
|
||||
checksum: null,
|
||||
tables: {},
|
||||
rowCounts: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup configuration from database
|
||||
*/
|
||||
async function getBackupConfig() {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const config = {};
|
||||
settings.forEach(setting => {
|
||||
try {
|
||||
config[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
config[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup configuration:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of files to backup
|
||||
*/
|
||||
async function getFilesToBackup(includeArchived = true) {
|
||||
const files = [];
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
try {
|
||||
// Active events
|
||||
const activePath = path.join(storagePath, 'events/active');
|
||||
await scanDirectory(activePath, files, storagePath);
|
||||
|
||||
// Archived events (if enabled)
|
||||
if (includeArchived) {
|
||||
const archivePath = path.join(storagePath, 'events/archived');
|
||||
await scanDirectory(archivePath, files, storagePath);
|
||||
}
|
||||
|
||||
// Thumbnails
|
||||
const thumbsPath = path.join(storagePath, 'thumbnails');
|
||||
await scanDirectory(thumbsPath, files, storagePath);
|
||||
|
||||
// Uploads (logos, favicons, etc.)
|
||||
const uploadsPath = path.join(storagePath, 'uploads');
|
||||
await scanDirectory(uploadsPath, files, storagePath);
|
||||
|
||||
return files;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get files to backup:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan directory for files
|
||||
*/
|
||||
async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
const relativePath = path.relative(basePath, fullPath);
|
||||
|
||||
// Check exclude patterns
|
||||
if (excludePatterns.some(pattern => {
|
||||
if (pattern.includes('*')) {
|
||||
return new RegExp(pattern.replace(/\*/g, '.*')).test(entry.name);
|
||||
}
|
||||
return entry.name === pattern;
|
||||
})) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await scanDirectory(fullPath, fileList, basePath, excludePatterns);
|
||||
} else if (entry.isFile()) {
|
||||
const stats = await fs.stat(fullPath);
|
||||
fileList.push({
|
||||
path: fullPath,
|
||||
relativePath: relativePath,
|
||||
size: stats.size,
|
||||
modified: stats.mtime
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
logger.error(`Failed to scan directory ${dirPath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file has changed since last backup
|
||||
*/
|
||||
async function hasFileChanged(filePath, checksum) {
|
||||
try {
|
||||
const fileState = await db('backup_file_states')
|
||||
.where('file_path', filePath)
|
||||
.first();
|
||||
|
||||
return !fileState || fileState.checksum !== checksum;
|
||||
} catch (error) {
|
||||
logger.error('Failed to check file state:', error);
|
||||
return true; // Assume changed if we can't check
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update file state in database
|
||||
*/
|
||||
async function updateFileState(filePath, checksum, size, modified) {
|
||||
try {
|
||||
const existing = await db('backup_file_states')
|
||||
.where('file_path', filePath)
|
||||
.first();
|
||||
|
||||
const data = {
|
||||
file_path: filePath,
|
||||
checksum: checksum,
|
||||
size_bytes: size,
|
||||
last_modified: modified,
|
||||
last_backed_up: new Date()
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db('backup_file_states')
|
||||
.where('id', existing.id)
|
||||
.update(data);
|
||||
} else {
|
||||
await db('backup_file_states').insert(data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update file state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform local directory backup
|
||||
*/
|
||||
async function performLocalBackup(config, files) {
|
||||
const destPath = config.backup_destination_path;
|
||||
const storagePath = getStoragePath();
|
||||
let backedUpCount = 0;
|
||||
let backedUpSize = 0;
|
||||
const backedUpFiles = [];
|
||||
|
||||
// Ensure destination exists
|
||||
await fs.mkdir(destPath, { recursive: true });
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Skip large files if configured
|
||||
const maxSizeMB = config.backup_max_file_size_mb || 5000;
|
||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||
logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate checksum
|
||||
const checksum = await calculateChecksum(file.path);
|
||||
file.checksum = checksum; // Add checksum to file object
|
||||
|
||||
// Check if file has changed
|
||||
const changed = await hasFileChanged(file.relativePath, checksum);
|
||||
if (!changed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Copy file
|
||||
const destFilePath = path.join(destPath, file.relativePath);
|
||||
const destDir = path.dirname(destFilePath);
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await fs.copyFile(file.path, destFilePath);
|
||||
|
||||
// Update state
|
||||
await updateFileState(file.relativePath, checksum, file.size, file.modified);
|
||||
|
||||
backedUpCount++;
|
||||
backedUpSize += file.size;
|
||||
backedUpFiles.push(file.relativePath);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to backup file ${file.relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { backedUpCount, backedUpSize, backedUpFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform rsync backup
|
||||
*/
|
||||
async function performRsyncBackup(config, files) {
|
||||
const storagePath = getStoragePath();
|
||||
const host = config.backup_rsync_host;
|
||||
const user = config.backup_rsync_user;
|
||||
const remotePath = config.backup_rsync_path;
|
||||
const sshKey = config.backup_rsync_ssh_key;
|
||||
|
||||
if (!host || !remotePath) {
|
||||
throw new Error('Rsync configuration incomplete');
|
||||
}
|
||||
|
||||
// Build rsync command
|
||||
const rsyncOptions = [
|
||||
'-avz', // archive, verbose, compress
|
||||
'--delete', // remove deleted files
|
||||
'--stats' // show statistics
|
||||
];
|
||||
|
||||
if (sshKey) {
|
||||
rsyncOptions.push(`-e "ssh -i ${sshKey} -o StrictHostKeyChecking=no"`);
|
||||
}
|
||||
|
||||
// Add exclude patterns
|
||||
const excludePatterns = config.backup_exclude_patterns || [];
|
||||
excludePatterns.forEach(pattern => {
|
||||
rsyncOptions.push(`--exclude="${pattern}"`);
|
||||
});
|
||||
|
||||
const source = `${storagePath}/`;
|
||||
const destination = user ? `${user}@${host}:${remotePath}` : `${host}:${remotePath}`;
|
||||
|
||||
const rsyncCommand = `rsync ${rsyncOptions.join(' ')} "${source}" "${destination}"`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(rsyncCommand);
|
||||
|
||||
// Parse rsync stats
|
||||
const stats = parseRsyncStats(stdout);
|
||||
|
||||
// Update file states for successfully synced files
|
||||
for (const file of files) {
|
||||
try {
|
||||
const checksum = await calculateChecksum(file.path);
|
||||
await updateFileState(file.relativePath, checksum, file.size, file.modified);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update state for ${file.relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backedUpCount: stats.filesTransferred || files.length,
|
||||
backedUpSize: stats.totalSize || files.reduce((sum, f) => sum + f.size, 0),
|
||||
backedUpFiles: files.map(f => f.relativePath)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Rsync backup failed:', error);
|
||||
throw new Error(`Rsync backup failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse rsync statistics from output
|
||||
*/
|
||||
function parseRsyncStats(output) {
|
||||
const stats = {};
|
||||
|
||||
// Extract files transferred
|
||||
const filesMatch = output.match(/Number of files transferred: (\d+)/);
|
||||
if (filesMatch) {
|
||||
stats.filesTransferred = parseInt(filesMatch[1]);
|
||||
}
|
||||
|
||||
// Extract total size
|
||||
const sizeMatch = output.match(/Total file size: ([\d,]+) bytes/);
|
||||
if (sizeMatch) {
|
||||
stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, ''));
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform S3-compatible backup
|
||||
*/
|
||||
async function performS3Backup(config, files) {
|
||||
// This would require AWS SDK or similar
|
||||
// For now, return a placeholder
|
||||
throw new Error('S3 backup not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run backup process
|
||||
*/
|
||||
async function runBackup() {
|
||||
if (isRunning) {
|
||||
logger.warn('Backup already running, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
const startTime = new Date();
|
||||
let backupRun = null;
|
||||
|
||||
try {
|
||||
// Get current configuration
|
||||
const config = await getBackupConfig();
|
||||
if (!config.backup_enabled) {
|
||||
logger.info('Backup is disabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create backup run record
|
||||
const [runId] = await db('backup_runs').insert({
|
||||
started_at: startTime,
|
||||
status: 'running',
|
||||
backup_type: 'scheduled'
|
||||
});
|
||||
|
||||
backupRun = { id: runId };
|
||||
|
||||
// Get files to backup
|
||||
const files = await getFilesToBackup(config.backup_include_archived);
|
||||
logger.info(`Found ${files.length} files to check for backup`);
|
||||
|
||||
// Perform backup based on destination type
|
||||
let result;
|
||||
switch (config.backup_destination_type) {
|
||||
case 'local':
|
||||
result = await performLocalBackup(config, files);
|
||||
break;
|
||||
case 'rsync':
|
||||
result = await performRsyncBackup(config, files);
|
||||
break;
|
||||
case 's3':
|
||||
result = await performS3Backup(config, files);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`);
|
||||
}
|
||||
|
||||
// Calculate duration
|
||||
const endTime = new Date();
|
||||
const durationSeconds = Math.round((endTime - startTime) / 1000);
|
||||
|
||||
// Generate backup manifest
|
||||
let manifestPath = null;
|
||||
try {
|
||||
logger.info('Generating backup manifest...');
|
||||
|
||||
// Get database backup info if available
|
||||
const databaseInfo = await getDatabaseBackupInfo();
|
||||
|
||||
// Determine if this is an incremental backup
|
||||
const lastSuccessfulBackup = await db('backup_runs')
|
||||
.where('status', 'completed')
|
||||
.whereNot('id', runId)
|
||||
.orderBy('completed_at', 'desc')
|
||||
.first();
|
||||
|
||||
let manifest;
|
||||
const manifestOptions = {
|
||||
backupType: lastSuccessfulBackup ? 'incremental' : 'full',
|
||||
backupPath: config.backup_destination_path || config.backup_destination_type,
|
||||
files: files.filter(f => result.backedUpFiles && result.backedUpFiles.includes(f.relativePath)),
|
||||
databaseInfo: databaseInfo,
|
||||
parentBackupId: lastSuccessfulBackup ? lastSuccessfulBackup.manifest_id : null,
|
||||
format: config.backup_manifest_format || 'json',
|
||||
customMetadata: {
|
||||
backup_run_id: runId,
|
||||
destination_type: config.backup_destination_type,
|
||||
operator: 'system',
|
||||
reason: 'scheduled',
|
||||
retentionDays: config.backup_retention_days || 30
|
||||
}
|
||||
};
|
||||
|
||||
if (lastSuccessfulBackup && lastSuccessfulBackup.manifest_path) {
|
||||
try {
|
||||
const parentManifest = await backupManifest.loadManifest(lastSuccessfulBackup.manifest_path);
|
||||
manifest = await backupManifest.generateIncrementalManifest(manifestOptions, parentManifest);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load parent manifest, generating full manifest:', error);
|
||||
manifest = await backupManifest.generateManifest(manifestOptions);
|
||||
}
|
||||
} else {
|
||||
manifest = await backupManifest.generateManifest(manifestOptions);
|
||||
}
|
||||
|
||||
// Save manifest
|
||||
const manifestDir = config.backup_manifest_path || path.join(config.backup_destination_path || '/backup', 'manifests');
|
||||
await fs.mkdir(manifestDir, { recursive: true });
|
||||
|
||||
const manifestFileName = `backup-manifest-${manifest.backup.id}.${config.backup_manifest_format || 'json'}`;
|
||||
manifestPath = path.join(manifestDir, manifestFileName);
|
||||
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
|
||||
|
||||
logger.info(`Backup manifest saved to ${manifestPath}`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate backup manifest:', error);
|
||||
// Don't fail the entire backup for manifest generation failure
|
||||
}
|
||||
|
||||
// Update backup run record
|
||||
await db('backup_runs')
|
||||
.where('id', runId)
|
||||
.update({
|
||||
completed_at: endTime,
|
||||
status: 'completed',
|
||||
files_backed_up: result.backedUpCount,
|
||||
total_size_bytes: result.backedUpSize,
|
||||
duration_seconds: durationSeconds,
|
||||
manifest_path: manifestPath,
|
||||
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
||||
statistics: JSON.stringify({
|
||||
totalFilesChecked: files.length,
|
||||
filesBackedUp: result.backedUpCount,
|
||||
totalSize: result.backedUpSize,
|
||||
averageFileSize: result.backedUpCount > 0 ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
|
||||
manifestGenerated: !!manifestPath
|
||||
})
|
||||
});
|
||||
|
||||
logger.info(`Backup completed: ${result.backedUpCount} files, ${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB in ${durationSeconds}s`);
|
||||
|
||||
// Send success email if configured
|
||||
if (config.backup_email_on_success) {
|
||||
// Get admin emails
|
||||
const admins = await db('admin_users').where('is_active', formatBoolean(true));
|
||||
for (const admin of admins) {
|
||||
await queueEmail(null, admin.email, 'backup_completed', {
|
||||
start_time: startTime.toISOString(),
|
||||
duration: `${durationSeconds} seconds`,
|
||||
files_count: result.backedUpCount.toString(),
|
||||
total_size: `${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB`,
|
||||
backup_type: config.backup_destination_type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Backup failed:', error);
|
||||
|
||||
// Update backup run record
|
||||
if (backupRun) {
|
||||
await db('backup_runs')
|
||||
.where('id', backupRun.id)
|
||||
.update({
|
||||
completed_at: new Date(),
|
||||
status: 'failed',
|
||||
error_message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Send failure email
|
||||
const config = await getBackupConfig();
|
||||
if (config && config.backup_email_on_failure) {
|
||||
const admins = await db('admin_users').where('is_active', formatBoolean(true));
|
||||
for (const admin of admins) {
|
||||
await queueEmail(null, admin.email, 'backup_failed', {
|
||||
start_time: startTime.toISOString(),
|
||||
backup_type: config.backup_destination_type || 'unknown',
|
||||
error_message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start backup service
|
||||
*/
|
||||
async function startBackupService() {
|
||||
try {
|
||||
// Get configuration
|
||||
backupConfig = await getBackupConfig();
|
||||
|
||||
if (!backupConfig || !backupConfig.backup_enabled) {
|
||||
logger.info('Backup service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel existing job if any
|
||||
if (backupJob) {
|
||||
backupJob.stop();
|
||||
}
|
||||
|
||||
// Schedule backup job
|
||||
const schedule = backupConfig.backup_schedule || '0 2 * * *'; // Default: 2 AM daily
|
||||
backupJob = cron.schedule(schedule, async () => {
|
||||
logger.info('Starting scheduled backup');
|
||||
await runBackup();
|
||||
});
|
||||
|
||||
logger.info(`Backup service started with schedule: ${schedule}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start backup service:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop backup service
|
||||
*/
|
||||
function stopBackupService() {
|
||||
if (backupJob) {
|
||||
backupJob.stop();
|
||||
backupJob = null;
|
||||
logger.info('Backup service stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual backup
|
||||
*/
|
||||
async function triggerManualBackup() {
|
||||
logger.info('Starting manual backup');
|
||||
await runBackup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup status and history
|
||||
*/
|
||||
async function getBackupStatus(limit = 10) {
|
||||
try {
|
||||
const runs = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.limit(limit);
|
||||
|
||||
const lastRun = runs[0];
|
||||
const isHealthy = lastRun && lastRun.status === 'completed';
|
||||
|
||||
// Validate manifest if exists
|
||||
let manifestValid = false;
|
||||
if (lastRun && lastRun.manifest_path) {
|
||||
try {
|
||||
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
|
||||
backupManifest.validateManifest(manifest);
|
||||
manifestValid = true;
|
||||
} catch (error) {
|
||||
logger.warn('Manifest validation failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
isHealthy,
|
||||
lastRun: lastRun ? {
|
||||
...lastRun,
|
||||
manifestValid
|
||||
} : null,
|
||||
recentRuns: runs,
|
||||
nextScheduledRun: backupJob ? getNextScheduledRun() : null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup status:', error);
|
||||
return {
|
||||
isRunning,
|
||||
isHealthy: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next scheduled run time
|
||||
*/
|
||||
function getNextScheduledRun() {
|
||||
// This is a simplified version - would need proper cron parsing
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(2, 0, 0, 0); // Assuming default 2 AM schedule
|
||||
return tomorrow.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old backup runs
|
||||
*/
|
||||
async function cleanupOldBackupRuns(retentionDays = 30) {
|
||||
try {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
|
||||
const deleted = await db('backup_runs')
|
||||
.where('started_at', '<', cutoffDate)
|
||||
.delete();
|
||||
|
||||
if (deleted > 0) {
|
||||
logger.info(`Cleaned up ${deleted} old backup runs`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup old backup runs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup manifest for a specific backup run
|
||||
*/
|
||||
async function getBackupManifest(backupRunId) {
|
||||
try {
|
||||
const run = await db('backup_runs')
|
||||
.where('id', backupRunId)
|
||||
.first();
|
||||
|
||||
if (!run || !run.manifest_path) {
|
||||
throw new Error('Backup manifest not found');
|
||||
}
|
||||
|
||||
const manifest = await backupManifest.loadManifest(run.manifest_path);
|
||||
return {
|
||||
manifest,
|
||||
summary: backupManifest.generateSummaryReport(manifest)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup manifest:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a backup manifest file
|
||||
*/
|
||||
async function validateBackupManifest(manifestPath) {
|
||||
try {
|
||||
const manifest = await backupManifest.loadManifest(manifestPath);
|
||||
backupManifest.validateManifest(manifest);
|
||||
return { valid: true, manifest };
|
||||
} catch (error) {
|
||||
return { valid: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startBackupService,
|
||||
stopBackupService,
|
||||
triggerManualBackup,
|
||||
getBackupStatus,
|
||||
runBackup,
|
||||
cleanupOldBackupRuns,
|
||||
getBackupManifest,
|
||||
validateBackupManifest
|
||||
};
|
||||
@@ -1,409 +0,0 @@
|
||||
// Extracted verbatim from contractService.js — see ../contractService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const businessProfileService = require('../businessProfileService');
|
||||
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
|
||||
|
||||
|
||||
/**
|
||||
* Convert an accepted quote into a fresh draft contract, pre-populating
|
||||
* the customer, language, title, valid-until window, and source_quote_id
|
||||
* back-pointer. Idempotent — if the quote already has a linked contract
|
||||
* (quote.converted_contract_id set), returns that contract's id without
|
||||
* creating a duplicate.
|
||||
*
|
||||
* Does NOT flip quote.status — the quote stays 'accepted' while the
|
||||
* contract is the active deliverable. The quote→event / quote→invoice
|
||||
* paths are gated against the converted_contract_id back-pointer so an
|
||||
* admin can't accidentally double-spend the quote.
|
||||
*/
|
||||
async function createFromQuote(quoteId, adminId) {
|
||||
// Same self-heal as createContract — the quote-conversion path seeds
|
||||
// the contract with every active system block, and the new
|
||||
// quote_line_items_table block needs to be present for it to land
|
||||
// in the default inclusion list.
|
||||
await ensureSystemBlocksSeeded();
|
||||
|
||||
const quote = await db('quotes').where({ id: quoteId }).first();
|
||||
if (!quote) throw new AppError('Quote not found', 404);
|
||||
if (quote.status !== 'accepted') {
|
||||
throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409, 'QUOTE_NOT_ACCEPTED');
|
||||
}
|
||||
if (quote.converted_contract_id) {
|
||||
return { contractId: quote.converted_contract_id, alreadyConverted: true };
|
||||
}
|
||||
if (quote.converted_event_id) {
|
||||
throw new AppError(
|
||||
'This quote was already converted to an event. Create the contract from the event instead.',
|
||||
409, 'ALREADY_CONVERTED_TO_EVENT',
|
||||
);
|
||||
}
|
||||
|
||||
const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
|
||||
ensureCustomerActive(customer);
|
||||
|
||||
const profile = (await businessProfileService.getProfile()).profile;
|
||||
const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30;
|
||||
const issueDate = new Date().toISOString().slice(0, 10);
|
||||
const validUntil = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000)
|
||||
.toISOString().slice(0, 10);
|
||||
|
||||
const title = quote.event_name
|
||||
? `Contract — ${quote.event_name}`
|
||||
: `Contract from quote ${quote.quote_number}`;
|
||||
|
||||
// Schema-drift safety: the lineage columns landed in migration 130
|
||||
// as in-place edits. Dev installs that ran 130 BEFORE that edit
|
||||
// won't have these columns yet. hasColumn() lets us skip the
|
||||
// affected writes instead of crashing with a generic 500.
|
||||
const hasContractSourceQuote = await hasColumnCached('contracts', 'source_quote_id');
|
||||
const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id');
|
||||
const hasContractEventCols = await hasColumnCached('contracts', 'event_name');
|
||||
|
||||
// Resolve the actor BEFORE opening the transaction — adminActor reads
|
||||
// admin_users via the global db, which deadlocks the single-connection
|
||||
// SQLite pool if evaluated inside the trx (prepare_contract runs unattended).
|
||||
const actor = await adminActor(adminId);
|
||||
|
||||
return await db.transaction(async (trx) => {
|
||||
// Pass trx so the sequence claim joins our outer transaction —
|
||||
// SQLite deadlocks otherwise (1-connection default).
|
||||
const contractNumber = await nextContractNumber(trx);
|
||||
const contractRow = {
|
||||
contract_number: contractNumber,
|
||||
customer_account_id: quote.customer_account_id,
|
||||
status: 'draft',
|
||||
language: quote.language || customer.preferred_language || profile?.default_locale || 'de',
|
||||
issue_date: issueDate,
|
||||
valid_until: validUntil,
|
||||
title,
|
||||
intro_text: quote.intro_text || null,
|
||||
outro_text: quote.outro_text || null,
|
||||
created_by_admin_id: adminId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (hasContractSourceQuote) contractRow.source_quote_id = quote.id;
|
||||
// Migration 140 — contract from quote inherits the quote's
|
||||
// deal_uuid so both documents belong to the same deal chain.
|
||||
// Falls back to a fresh UUID only if the source quote predates the
|
||||
// backfill (shouldn't happen on a migrated install, but defensive).
|
||||
contractRow.deal_uuid = quote.deal_uuid || crypto.randomUUID();
|
||||
// Propagate the quote's event snapshot — same fields the quote
|
||||
// already carries (set by createQuote). Means contract-from-quote
|
||||
// chains preserve "this contract is for the Wedding Doe / Müller"
|
||||
// labelling all the way through to the resulting invoice's
|
||||
// event_name field.
|
||||
if (hasContractEventCols) {
|
||||
contractRow.event_name = quote.event_name || null;
|
||||
contractRow.event_date = quote.event_date || null;
|
||||
contractRow.event_time_start = quote.event_time_start || null;
|
||||
contractRow.event_time_end = quote.event_time_end || null;
|
||||
}
|
||||
const inserted = await trx('contracts').insert(contractRow).returning('id');
|
||||
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
// Seed every active system block. Same shape as createContract.
|
||||
// D.3 — batched insert (one DB round-trip vs N).
|
||||
const systemBlocks = await trx('contract_blocks')
|
||||
.where({ is_system: true, is_active: true })
|
||||
.orderBy(['section', 'display_order']);
|
||||
const sectionCounters = {};
|
||||
const inclusionRows = systemBlocks.map((block) => {
|
||||
sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1;
|
||||
return {
|
||||
contract_id: contractId,
|
||||
block_id: block.id,
|
||||
section: block.section,
|
||||
position: sectionCounters[block.section],
|
||||
body_text_snapshot: null,
|
||||
body_text_de_snapshot: null,
|
||||
included: true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
});
|
||||
if (inclusionRows.length > 0) {
|
||||
await trx('contract_block_inclusions').insert(inclusionRows);
|
||||
}
|
||||
|
||||
// Back-pointer so the quote detail page can deep-link to its
|
||||
// resulting contract and the convert-to-event/invoice paths know
|
||||
// to refuse double conversion. Skipped silently when the column
|
||||
// hasn't migrated — the contract is still created cleanly.
|
||||
if (hasQuoteContractBackPointer) {
|
||||
await trx('quotes').where({ id: quote.id }).update({
|
||||
converted_contract_id: contractId,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass `trx` so the audit insert rides the transaction's connection;
|
||||
// the global db here deadlocks the single-connection SQLite pool.
|
||||
await logActivity('contract_created_from_quote',
|
||||
{ contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number },
|
||||
null, actor, trx);
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id });
|
||||
return { contractId, alreadyConverted: false };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a fully-signed contract into an event + scheduled invoices.
|
||||
* Delegates to quoteService.convertToEvent using the contract's
|
||||
* source_quote_id so the line items + payment plan come from the
|
||||
* original quote. The quote MUST still be in 'accepted' status (i.e.
|
||||
* not previously converted) — createFromQuote keeps it that way.
|
||||
*
|
||||
* On success the contract's converted_event_id is set (back-pointer)
|
||||
* and the source quote flips to 'converted'.
|
||||
*/
|
||||
async function convertToEvent(contractId, adminId) {
|
||||
const contract = await db('contracts').where({ id: contractId }).first();
|
||||
if (!contract) throw new AppError('Contract not found', 404);
|
||||
if (contract.status !== 'fully_signed') {
|
||||
throw new AppError(
|
||||
`Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`,
|
||||
409, 'CONTRACT_NOT_FULLY_SIGNED',
|
||||
);
|
||||
}
|
||||
if (contract.converted_event_id) {
|
||||
return { eventId: contract.converted_event_id, alreadyConverted: true };
|
||||
}
|
||||
|
||||
const hasContractConvertedEvent = await hasColumnCached('contracts', 'converted_event_id');
|
||||
|
||||
// Path A: source quote present → delegate to quoteService which
|
||||
// replays the full installment schedule into invoices alongside
|
||||
// the event row.
|
||||
if (contract.source_quote_id) {
|
||||
const quoteService = require('../quoteService');
|
||||
const result = await quoteService.convertToEvent(contract.source_quote_id, adminId, { fromContract: true });
|
||||
if (hasContractConvertedEvent) {
|
||||
await db('contracts').where({ id: contractId }).update({
|
||||
converted_event_id: result.eventId,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await logActivity('contract_converted_to_event',
|
||||
{ contractId, eventId: result.eventId, quoteId: contract.source_quote_id },
|
||||
result.eventId, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
// Path B: standalone contract → mint an empty placeholder event
|
||||
// row the admin fleshes out from the events admin page. Same
|
||||
// column-introspection trick quoteService uses so installs with
|
||||
// old/new host_*/customer_* column variants both work.
|
||||
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
|
||||
ensureCustomerActive(customer);
|
||||
const adminRow = await db('admin_users').where({ id: adminId }).first();
|
||||
const today = new Date();
|
||||
const oneYearFromNow = new Date(today.getTime());
|
||||
oneYearFromNow.setFullYear(today.getFullYear() + 1);
|
||||
|
||||
const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|
||||
|| customer.display_name || customer.company_name || contract.contract_number;
|
||||
const customerEmail = customer.email || `${contract.contract_number.toLowerCase()}@picpeak.local`;
|
||||
const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local';
|
||||
const placeholderHash = crypto.randomBytes(32).toString('hex');
|
||||
const shareToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const eventCols = await db('events').columnInfo();
|
||||
const candidate = {
|
||||
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
|
||||
// Prefer the contract's event_name snapshot (set on the contract
|
||||
// editor or inherited from the source quote) over the contract
|
||||
// title. Falls back to a deterministic placeholder so the event
|
||||
// row never has a blank name.
|
||||
event_name: contract.event_name || contract.title || `Event ${contract.contract_number}`,
|
||||
event_date: contract.event_date || contract.issue_date,
|
||||
host_name: fullName,
|
||||
host_email: customerEmail,
|
||||
customer_name: fullName,
|
||||
customer_email: customerEmail,
|
||||
customer_phone: customer.phone,
|
||||
admin_email: adminEmail,
|
||||
event_type: 'wedding',
|
||||
password_hash: placeholderHash,
|
||||
share_link: shareToken,
|
||||
share_token: shareToken,
|
||||
expires_at: oneYearFromNow,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
is_draft: true,
|
||||
created_by: adminId,
|
||||
quote_id: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
const eventRow = {};
|
||||
for (const [k, v] of Object.entries(candidate)) {
|
||||
if (Object.prototype.hasOwnProperty.call(eventCols, k)) eventRow[k] = v;
|
||||
}
|
||||
const inserted = await db('events').insert(eventRow).returning('id');
|
||||
const eventId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
// Link the customer so they see the event on their portal once
|
||||
// the admin activates it. Best-effort — older installs without
|
||||
// the junction table still get the event row.
|
||||
try {
|
||||
if (await db.schema.hasTable('event_customer_assignments')) {
|
||||
await db('event_customer_assignments').insert({
|
||||
event_id: eventId,
|
||||
customer_account_id: customer.id,
|
||||
assigned_by_admin_id: adminId,
|
||||
assigned_at: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (_) { /* best-effort */ }
|
||||
|
||||
if (hasContractConvertedEvent) {
|
||||
await db('contracts').where({ id: contractId }).update({
|
||||
converted_event_id: eventId,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('contract_converted_to_empty_event',
|
||||
{ contractId, eventId }, eventId, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
|
||||
return { eventId, alreadyConverted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a fully-signed contract directly into invoice(s) without
|
||||
* creating an event row. Same delegation pattern as convertToEvent.
|
||||
*/
|
||||
async function convertToInvoiceOnly(contractId, adminId) {
|
||||
const contract = await db('contracts').where({ id: contractId }).first();
|
||||
if (!contract) throw new AppError('Contract not found', 404);
|
||||
if (contract.status !== 'fully_signed') {
|
||||
throw new AppError(
|
||||
`Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`,
|
||||
409, 'CONTRACT_NOT_FULLY_SIGNED',
|
||||
);
|
||||
}
|
||||
|
||||
// Schema-drift guard — the lineage columns are in-place edits to
|
||||
// migration 130. Skip the back-pointer update silently when the
|
||||
// column hasn't migrated yet.
|
||||
const hasInvoiceContractBackPointer = await hasColumnCached('invoices', 'source_contract_id');
|
||||
|
||||
// Path A: contract has a source quote → replay its line items +
|
||||
// payment plan via quoteService (full installment schedule).
|
||||
if (contract.source_quote_id) {
|
||||
const quoteService = require('../quoteService');
|
||||
const result = await quoteService.convertToInvoiceOnly(contract.source_quote_id, adminId, { fromContract: true });
|
||||
if (hasInvoiceContractBackPointer) {
|
||||
await db('invoices')
|
||||
.where({ source_quote_id: contract.source_quote_id })
|
||||
.whereNull('source_contract_id')
|
||||
.update({ source_contract_id: contractId });
|
||||
}
|
||||
try {
|
||||
await logActivity('contract_converted_to_invoices',
|
||||
{ contractId, quoteId: contract.source_quote_id, installments: result.installmentsCreated },
|
||||
null, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
// Path B: standalone contract (no source quote) → direct DB insert
|
||||
// of an empty draft. We deliberately bypass invoiceService.createInvoice
|
||||
// because that runs ensureCustomerCanBill, which throws if the
|
||||
// customer doesn't have feature_bills enabled. Admin clicking
|
||||
// "Convert to invoice" on the contract detail page IS the
|
||||
// authorisation; the admin will fill in line items manually before
|
||||
// sending.
|
||||
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
|
||||
ensureCustomerActive(customer);
|
||||
|
||||
const invoiceService = require('../invoiceService');
|
||||
const profile = (await businessProfileService.getProfile()).profile || {};
|
||||
const currency = (profile.default_currency || 'CHF').toUpperCase();
|
||||
const language = contract.language || customer.preferred_language || profile.default_locale || 'de';
|
||||
const issueDate = new Date().toISOString().slice(0, 10);
|
||||
const netDays = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30;
|
||||
const dueDate = new Date(Date.now() + netDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
// Pre-resolve which event-snapshot columns the invoices table has
|
||||
// (migration 123) so we can copy contract.event_name etc onto the
|
||||
// new invoice. Falls back to contract.title when event_name is
|
||||
// empty — gives standalone contracts a useful label even when
|
||||
// the admin didn't fill out the event field.
|
||||
const invoiceHasEventName = await hasColumnCached('invoices', 'event_name');
|
||||
const eventNameSnapshot = (contract.event_name || contract.title || null);
|
||||
|
||||
const invoiceNumber = await invoiceService.nextInvoiceNumber();
|
||||
const invoiceRow = {
|
||||
invoice_number: invoiceNumber,
|
||||
customer_account_id: contract.customer_account_id,
|
||||
source_quote_id: null,
|
||||
event_id: null,
|
||||
language,
|
||||
currency,
|
||||
issue_date: issueDate,
|
||||
due_date: dueDate,
|
||||
installment_index: 0,
|
||||
installment_total: 1,
|
||||
status: 'scheduled',
|
||||
net_amount_minor: 0,
|
||||
vat_rate: 0,
|
||||
vat_amount_minor: 0,
|
||||
shipping_amount_minor: 0,
|
||||
total_amount_minor: 0,
|
||||
paid_amount_minor: 0,
|
||||
reminder_level: 0,
|
||||
late_fee_amount_minor: 0,
|
||||
created_by_admin_id: adminId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (hasInvoiceContractBackPointer) invoiceRow.source_contract_id = contractId;
|
||||
// Migration 140 — invoice inherits the contract's deal_uuid so the
|
||||
// contract + invoice belong to the same deal chain. Fresh UUID if
|
||||
// the contract predates the backfill (defensive).
|
||||
invoiceRow.deal_uuid = contract.deal_uuid || crypto.randomUUID();
|
||||
// Snapshot the contract's event fields onto the invoice so the
|
||||
// BillDetailPage + customer portal show the same "Wedding Doe /
|
||||
// Müller" label that the contract carries. event_name is also the
|
||||
// field the dunning emails reference in their templates.
|
||||
if (invoiceHasEventName) {
|
||||
invoiceRow.event_name = eventNameSnapshot;
|
||||
invoiceRow.event_date = contract.event_date || null;
|
||||
invoiceRow.event_time_start = contract.event_time_start || null;
|
||||
invoiceRow.event_time_end = contract.event_time_end || null;
|
||||
}
|
||||
const inserted = await db('invoices').insert(invoiceRow).returning('id');
|
||||
const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
try {
|
||||
await logActivity('contract_converted_to_empty_invoice',
|
||||
{ contractId, invoiceId, invoiceNumber }, null, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
|
||||
// Match the result shape of the source-quote path so the frontend
|
||||
// toast can use the same translation key. `installmentsCreated` is
|
||||
// always 1 here (single empty invoice).
|
||||
return { installmentsCreated: 1, invoiceId };
|
||||
}
|
||||
module.exports = {
|
||||
createFromQuote,
|
||||
convertToEvent,
|
||||
convertToInvoiceOnly,
|
||||
};
|
||||
@@ -1,384 +0,0 @@
|
||||
// Extracted verbatim from contractService.js — see ../contractService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { db, withRetry, logActivity } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const businessProfileService = require('../businessProfileService');
|
||||
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
|
||||
return await withRetry(async () => {
|
||||
let query = db('contracts')
|
||||
.leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id')
|
||||
.select(
|
||||
'contracts.*',
|
||||
'customer_accounts.email as customer_email',
|
||||
'customer_accounts.display_name as customer_display_name',
|
||||
'customer_accounts.first_name as customer_first_name',
|
||||
'customer_accounts.last_name as customer_last_name',
|
||||
'customer_accounts.company_name as customer_company_name',
|
||||
);
|
||||
|
||||
if (Array.isArray(filters.status) && filters.status.length > 0) {
|
||||
query = query.whereIn('contracts.status', filters.status);
|
||||
}
|
||||
if (filters.customerAccountId) {
|
||||
query = query.where('contracts.customer_account_id', filters.customerAccountId);
|
||||
}
|
||||
if (filters.q && String(filters.q).trim()) {
|
||||
const term = `%${String(filters.q).trim()}%`;
|
||||
query = query.andWhere(function() {
|
||||
this.where('contracts.contract_number', 'like', term)
|
||||
.orWhere('contracts.title', 'like', term)
|
||||
.orWhere('customer_accounts.email', 'like', term)
|
||||
.orWhere('customer_accounts.company_name', 'like', term);
|
||||
});
|
||||
}
|
||||
|
||||
const countQuery = query.clone().clearSelect().clearOrder().count('contracts.id as total').first();
|
||||
const totalRow = await countQuery;
|
||||
const total = ensureInt(totalRow?.total || 0);
|
||||
|
||||
switch (sort) {
|
||||
case 'oldest':
|
||||
query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc');
|
||||
break;
|
||||
case 'issue_asc':
|
||||
query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc');
|
||||
break;
|
||||
case 'issue_desc':
|
||||
query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc');
|
||||
break;
|
||||
case 'customer_asc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
|
||||
.orderBy('contracts.id', 'desc');
|
||||
break;
|
||||
case 'customer_desc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
|
||||
.orderBy('contracts.id', 'desc');
|
||||
break;
|
||||
case 'newest':
|
||||
default:
|
||||
query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc');
|
||||
break;
|
||||
}
|
||||
|
||||
const offset = Math.max(0, (page - 1) * pageSize);
|
||||
query = query.offset(offset).limit(pageSize);
|
||||
const rows = await query;
|
||||
return { rows, total, page, pageSize };
|
||||
});
|
||||
}
|
||||
|
||||
async function getContractById(id) {
|
||||
return await withRetry(async () => {
|
||||
const contract = await db('contracts')
|
||||
.leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id')
|
||||
.where('contracts.id', id)
|
||||
.select(
|
||||
'contracts.*',
|
||||
'customer_accounts.email as customer_email',
|
||||
'customer_accounts.display_name as customer_display_name',
|
||||
'customer_accounts.first_name as customer_first_name',
|
||||
'customer_accounts.last_name as customer_last_name',
|
||||
'customer_accounts.company_name as customer_company_name',
|
||||
'customer_accounts.preferred_language as customer_preferred_language',
|
||||
)
|
||||
.first();
|
||||
if (!contract) return null;
|
||||
|
||||
const inclusions = await db('contract_block_inclusions as inc')
|
||||
.leftJoin('contract_blocks as blk', 'blk.id', 'inc.block_id')
|
||||
.where('inc.contract_id', id)
|
||||
.orderByRaw(`
|
||||
CASE inc.section
|
||||
WHEN 'basics' THEN 1
|
||||
WHEN 'scope' THEN 2
|
||||
WHEN 'privacy' THEN 3
|
||||
WHEN 'commercial' THEN 4
|
||||
WHEN 'nda' THEN 5
|
||||
WHEN 'closing' THEN 6
|
||||
ELSE 99
|
||||
END
|
||||
`)
|
||||
.orderBy('inc.position', 'asc')
|
||||
.select(
|
||||
'inc.*',
|
||||
'blk.slug as block_slug',
|
||||
'blk.name as block_name',
|
||||
'blk.description as block_description',
|
||||
'blk.body_text as block_body_text',
|
||||
'blk.body_text_de as block_body_text_de',
|
||||
// Migration 131 — locale variants. Pulled with column-existence
|
||||
// guard so installs that haven't run migration 131 still load
|
||||
// contracts (just without the new columns).
|
||||
...(await hasColumnCached('contract_blocks', 'body_text_ru')
|
||||
? ['blk.body_text_ru as block_body_text_ru'] : []),
|
||||
...(await hasColumnCached('contract_blocks', 'body_text_pt')
|
||||
? ['blk.body_text_pt as block_body_text_pt'] : []),
|
||||
...(await hasColumnCached('contract_blocks', 'body_text_nl')
|
||||
? ['blk.body_text_nl as block_body_text_nl'] : []),
|
||||
...(await hasColumnCached('contract_blocks', 'body_text_fr')
|
||||
? ['blk.body_text_fr as block_body_text_fr'] : []),
|
||||
'blk.is_system as block_is_system',
|
||||
);
|
||||
return { contract, inclusions };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a draft contract. Pre-populates `contract_block_inclusions`
|
||||
* with every active system block toggled ON so the admin sees a
|
||||
* sensible starting point and just toggles off what they don't need.
|
||||
*
|
||||
* Custom (non-system) blocks are NOT auto-included — admin opts in to
|
||||
* those explicitly so a runaway block library doesn't pollute every
|
||||
* new contract.
|
||||
*/
|
||||
async function createContract(payload, adminId) {
|
||||
// Self-heal: ensure runtime-seeded system blocks (e.g. the
|
||||
// quote_line_items_table added after migration 131 was deployed)
|
||||
// exist before we copy active system blocks into the new contract's
|
||||
// inclusion list. Idempotent — only fires if rows are missing.
|
||||
await ensureSystemBlocksSeeded();
|
||||
|
||||
const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first();
|
||||
ensureCustomerActive(customer);
|
||||
|
||||
const profile = (await businessProfileService.getProfile()).profile;
|
||||
const language = payload.language || customer.preferred_language || profile?.default_locale || 'de';
|
||||
const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30;
|
||||
const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10);
|
||||
const validUntil = payload.validUntil || new Date(Date.now() + validDays * 24 * 60 * 60 * 1000)
|
||||
.toISOString().slice(0, 10);
|
||||
|
||||
// Schema-drift guard for the event-snapshot columns added as
|
||||
// in-place migration 130 edits. We only write them when the DB
|
||||
// actually has them; older dev installs that haven't re-migrated
|
||||
// simply skip these fields (contract still saves successfully).
|
||||
const hasEventCols = await hasColumnCached('contracts', 'event_name');
|
||||
|
||||
return await db.transaction(async (trx) => {
|
||||
// Pass trx so the sequence claim joins our outer transaction —
|
||||
// SQLite deadlocks otherwise (1-connection default).
|
||||
const contractNumber = await nextContractNumber(trx);
|
||||
const row = {
|
||||
contract_number: contractNumber,
|
||||
customer_account_id: payload.customerAccountId,
|
||||
status: 'draft',
|
||||
language,
|
||||
issue_date: issueDate,
|
||||
valid_until: validUntil,
|
||||
title: payload.title || null,
|
||||
intro_text: payload.introText || null,
|
||||
outro_text: payload.outroText || null,
|
||||
// Migration 140 — standalone contract is a deal root; mint a
|
||||
// fresh UUID. The createFromQuote path (line ~1557) sets this
|
||||
// from the source quote's deal_uuid instead.
|
||||
deal_uuid: crypto.randomUUID(),
|
||||
created_by_admin_id: adminId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (hasEventCols) {
|
||||
row.event_name = payload.eventName || null;
|
||||
row.event_date = payload.eventDate || null;
|
||||
row.event_time_start = payload.eventTimeStart || null;
|
||||
row.event_time_end = payload.eventTimeEnd || null;
|
||||
}
|
||||
// Migration 121 — optional link to a Project Overview project.
|
||||
if (payload.projectId !== undefined && await hasColumnCached('contracts', 'project_id')) {
|
||||
row.project_id = payload.projectId || null;
|
||||
}
|
||||
const inserted = await trx('contracts').insert(row).returning('id');
|
||||
if (row.project_id && row.deal_uuid) {
|
||||
await require('../projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
|
||||
}
|
||||
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
// Seed with every active system block, toggled on. Per-section
|
||||
// position = display_order from the source block.
|
||||
//
|
||||
// D.3 — batched insert. Previously this loop fired one INSERT per
|
||||
// block (12+ round-trips inside the transaction on a fresh contract).
|
||||
// Batched into a single `.insert(rows)` since the row count is
|
||||
// bounded (system block count) and the inserts are independent.
|
||||
const systemBlocks = await trx('contract_blocks')
|
||||
.where({ is_system: true, is_active: true })
|
||||
.orderBy(['section', 'display_order']);
|
||||
const sectionCounters = {};
|
||||
const inclusionRows = systemBlocks.map((block) => {
|
||||
sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1;
|
||||
return {
|
||||
contract_id: contractId,
|
||||
block_id: block.id,
|
||||
section: block.section,
|
||||
position: sectionCounters[block.section],
|
||||
body_text_snapshot: null,
|
||||
body_text_de_snapshot: null,
|
||||
included: true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
});
|
||||
if (inclusionRows.length > 0) {
|
||||
await trx('contract_block_inclusions').insert(inclusionRows);
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('contract_created', { contractId, contractNumber, customerAccountId: payload.customerAccountId }, null, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
|
||||
logger.info('Contract created', { adminId, contractId, contractNumber });
|
||||
return contractId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a draft contract. Editing a sent contract is refused — admin
|
||||
* must cancel + create a fresh one (avoids invalidating the customer's
|
||||
* signed copy).
|
||||
*
|
||||
* payload.blocks is an array of `{ blockId, included, position }`
|
||||
* tuples; the service rewrites the contract_block_inclusions rows
|
||||
* accordingly.
|
||||
*/
|
||||
async function updateContract(id, payload, adminId) {
|
||||
const existing = await db('contracts').where({ id }).first();
|
||||
if (!existing) throw new AppError('Contract not found', 404);
|
||||
if (existing.status !== 'draft') {
|
||||
throw new AppError(
|
||||
`Cannot edit a contract with status '${existing.status}'. Cancel and create a new contract for amendments.`,
|
||||
409,
|
||||
'CONTRACT_LOCKED',
|
||||
);
|
||||
}
|
||||
|
||||
const hasEventCols = await hasColumnCached('contracts', 'event_name');
|
||||
|
||||
return await db.transaction(async (trx) => {
|
||||
const updates = { updated_at: new Date() };
|
||||
const map = {
|
||||
title: 'title',
|
||||
introText: 'intro_text',
|
||||
outroText: 'outro_text',
|
||||
language: 'language',
|
||||
validUntil: 'valid_until',
|
||||
issueDate: 'issue_date',
|
||||
};
|
||||
// Event-snapshot fields only flow through when the DB has them
|
||||
// (in-place migration 130 edit). Guarded so dev installs that
|
||||
// haven't re-migrated don't crash the update.
|
||||
if (hasEventCols) {
|
||||
Object.assign(map, {
|
||||
eventName: 'event_name',
|
||||
eventDate: 'event_date',
|
||||
eventTimeStart: 'event_time_start',
|
||||
eventTimeEnd: 'event_time_end',
|
||||
});
|
||||
}
|
||||
for (const [api, col] of Object.entries(map)) {
|
||||
if (api in payload) updates[col] = payload[api] || null;
|
||||
}
|
||||
// Migration 121 — optional Project Overview link.
|
||||
if ('projectId' in payload && await hasColumnCached('contracts', 'project_id')) {
|
||||
updates.project_id = payload.projectId || null;
|
||||
}
|
||||
await trx('contracts').where({ id }).update(updates);
|
||||
|
||||
// Cascade across the deal lineage (linked quote / event / invoices).
|
||||
if (updates.project_id) {
|
||||
const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first();
|
||||
await require('../projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
|
||||
}
|
||||
|
||||
// Replace inclusions only when the caller sent an explicit list.
|
||||
// (Editor's "save" sends every row; an inline "toggle" save could
|
||||
// send a partial update — current frontend always sends full list.)
|
||||
if (Array.isArray(payload.blocks)) {
|
||||
await trx('contract_block_inclusions').where({ contract_id: id }).del();
|
||||
// Recompute per-section position so we don't trust caller order
|
||||
// for ordering integrity; caller controls only the section
|
||||
// sequence via the order of items in payload.blocks.
|
||||
//
|
||||
// Previously this loop did one SELECT per block to look up its
|
||||
// section. On a contract with 12 included blocks that's 12
|
||||
// round-trips inside the transaction — pure N+1. Batch the
|
||||
// lookup into a single WHERE…IN, build a Map, and read it in
|
||||
// the loop. The insert itself stays sequential because the
|
||||
// editor's payload size is bounded (<30 blocks in practice) and
|
||||
// a single batch insert would lose row-by-row insert ordering
|
||||
// guarantees we don't actually need.
|
||||
const blockIds = [
|
||||
...new Set(payload.blocks.map((e) => e.blockId).filter((id) => Number.isFinite(id))),
|
||||
];
|
||||
const blocksFound = blockIds.length > 0
|
||||
? await trx('contract_blocks').whereIn('id', blockIds).select('id', 'section')
|
||||
: [];
|
||||
const sectionByBlockId = new Map(blocksFound.map((b) => [b.id, b.section]));
|
||||
const sectionCounters = {};
|
||||
for (const entry of payload.blocks) {
|
||||
const section = sectionByBlockId.get(entry.blockId);
|
||||
if (!section) continue;
|
||||
sectionCounters[section] = (sectionCounters[section] || 0) + 1;
|
||||
await trx('contract_block_inclusions').insert({
|
||||
contract_id: id,
|
||||
block_id: entry.blockId,
|
||||
section,
|
||||
position: ensureInt(entry.position) || sectionCounters[section],
|
||||
body_text_snapshot: null,
|
||||
body_text_de_snapshot: null,
|
||||
included: entry.included === false ? false : true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('contract_updated', { contractId: id }, null, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelContract(id, adminId) {
|
||||
const contract = await db('contracts').where({ id }).first();
|
||||
if (!contract) throw new AppError('Contract not found', 404);
|
||||
if (!['draft', 'sent'].includes(contract.status)) {
|
||||
throw new AppError(`Cannot cancel a contract with status '${contract.status}'`, 409);
|
||||
}
|
||||
await db('contracts').where({ id }).update({
|
||||
status: 'cancelled',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
// Invalidate any outstanding tokens.
|
||||
await db('contract_action_tokens').where({ contract_id: id, used_at: null }).update({
|
||||
expires_at: new Date(),
|
||||
});
|
||||
try {
|
||||
await logActivity('contract_cancelled', { contractId: id }, null, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
return { status: 'cancelled' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listContracts,
|
||||
getContractById,
|
||||
createContract,
|
||||
updateContract,
|
||||
cancelContract,
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
// Extracted verbatim from contractService.js — see ../contractService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const { db } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { nextDocumentNumber } = require('../../utils/documentSequences');
|
||||
|
||||
|
||||
const SECTIONS_ORDER = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
|
||||
|
||||
/**
|
||||
* Build a proper {id, type, name} actor object for logActivity. The
|
||||
* db.js helper silently downgrades string actors (e.g. 'admin:1') to
|
||||
* actor_type='system' with null name, so the audit timeline showed
|
||||
* "system" for every admin-driven event. Fetching the admin's name
|
||||
* once per service call is a small read cost on a non-hot path.
|
||||
*
|
||||
* Pass `customerPublic()` for events triggered by the public token
|
||||
* (customer signing, customer wet-signed PDF upload).
|
||||
*/
|
||||
async function adminActor(adminId) {
|
||||
if (!adminId) return { type: 'system' };
|
||||
try {
|
||||
// admin_users only carries username + email (no first/last/name
|
||||
// columns — confirmed from db.js:265). Prefer username for the
|
||||
// audit timeline because it's the operator-chosen identifier
|
||||
// shown elsewhere in the admin UI; fall back to email when an
|
||||
// older install seeded a row without a username.
|
||||
const row = await db('admin_users')
|
||||
.where({ id: adminId })
|
||||
.select('id', 'username', 'email')
|
||||
.first();
|
||||
if (!row) return { id: adminId, type: 'admin', name: `Admin #${adminId}` };
|
||||
const displayName = row.username || row.email || `Admin #${adminId}`;
|
||||
return { id: adminId, type: 'admin', name: displayName };
|
||||
} catch (_) {
|
||||
return { id: adminId, type: 'admin', name: `Admin #${adminId}` };
|
||||
}
|
||||
}
|
||||
|
||||
function customerPublicActor() {
|
||||
return { type: 'customer', name: 'Customer (public link)' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a contract lifecycle event for the workflow engine. Best-effort:
|
||||
* resolves the customer email (so send_email actions have a recipient) and
|
||||
* never throws into the caller. No-op when the workflows flag is off (emit
|
||||
* fails closed). Mirrors quoteService.emitQuoteEvent.
|
||||
*/
|
||||
async function emitContractEvent(contract, status) {
|
||||
try {
|
||||
let customerEmail = null;
|
||||
if (contract.customer_account_id) {
|
||||
const c = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
|
||||
customerEmail = c?.email || null;
|
||||
}
|
||||
await require('../workflows').emitWorkflowEvent(`contract.${status}`, {
|
||||
entityType: 'contract',
|
||||
entityId: contract.id,
|
||||
payload: {
|
||||
contractId: contract.id,
|
||||
contractNumber: contract.contract_number,
|
||||
customerAccountId: contract.customer_account_id || null,
|
||||
customerEmail,
|
||||
eventName: contract.event_name || null,
|
||||
title: contract.title || null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to emit contract workflow event', { contractId: contract.id, status, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy gate for the customer/admin IP captured at signing time.
|
||||
* The `crm_contracts_store_ip` setting (default true) controls
|
||||
* whether the IP is persisted into the DB. When off, this helper
|
||||
* returns null regardless of what the route passed in — same shape
|
||||
* the rest of the code expects, just with no IP data.
|
||||
*
|
||||
* Default-true means upgrades preserve current behaviour. Operators
|
||||
* with strict data-minimisation requirements opt out in Settings →
|
||||
* CRM-Settings → Contracts.
|
||||
*/
|
||||
async function maybeStoreIp(ip) {
|
||||
if (!ip) return null;
|
||||
const enabled = await getAppSetting('crm_contracts_store_ip');
|
||||
// Default true: only block when EXPLICITLY opted out. The audit
|
||||
// flagged that `enabled === false` missed legacy installs where
|
||||
// app_settings stored the toggle as a string ('false', '0') — those
|
||||
// would slip through and the IP would still get persisted despite
|
||||
// the operator's intent. Cover string/number/bool variants
|
||||
// defensively. Anything else (null, undefined, true) preserves
|
||||
// the default-on behavior.
|
||||
if (enabled === false) return null;
|
||||
if (enabled === 0 || enabled === '0') return null;
|
||||
if (typeof enabled === 'string' && enabled.toLowerCase() === 'false') return null;
|
||||
return ip;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
|
||||
/**
|
||||
* Gap-free per-year contract number sequence. See
|
||||
* utils/documentSequences.js for the locking story; migration 132
|
||||
* created the underlying table. Atomic against concurrent admin
|
||||
* creates — the previous SELECT-MAX-then-INSERT raced and could
|
||||
* emit `C-2026-AB12C3` after 5 retries.
|
||||
*/
|
||||
async function nextContractNumber(trx) {
|
||||
return nextDocumentNumber('contract', 'crm_contracts_number_format', 'C-{YEAR}-{SEQ:04d}', trx);
|
||||
}
|
||||
|
||||
function ensureCustomerActive(customer) {
|
||||
if (!customer) throw new AppError('Customer not found', 404);
|
||||
if (customer.is_active === false || customer.is_active === 0) {
|
||||
throw new AppError('Customer is deactivated', 409);
|
||||
}
|
||||
}
|
||||
module.exports = {
|
||||
SECTIONS_ORDER,
|
||||
adminActor,
|
||||
customerPublicActor,
|
||||
emitContractEvent,
|
||||
maybeStoreIp,
|
||||
nextContractNumber,
|
||||
ensureCustomerActive,
|
||||
};
|
||||
@@ -1,282 +0,0 @@
|
||||
// Extracted verbatim from contractService.js — see ../contractService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const { db } = require('../../database/db');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { formatShortDate } = require('../../utils/dateFormatter');
|
||||
const businessProfileService = require('../businessProfileService');
|
||||
const { buildIssuerBlock, buildRecipientBlock } = require('../_renderContext');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
const { SECTIONS_ORDER } = require('./helpers');
|
||||
|
||||
|
||||
/**
|
||||
* Handlebars-lite renderer:
|
||||
* - `{{#if var}}…{{/if}}` blocks resolved by truthiness of variables[var].
|
||||
* - `{{var}}` substituted with the matching variable. Missing
|
||||
* placeholders are left literally as `{{var}}` so the admin
|
||||
* notices the unresolved field in preview.
|
||||
*
|
||||
* Mirrors safeTemplateReplace in emailProcessor.js (lines 424-461) but
|
||||
* without HTML escaping — contract bodies are rendered into PDF via
|
||||
* pdfService.drawText, which doesn't need HTML safety.
|
||||
*/
|
||||
function renderTemplatedBody(template, variables) {
|
||||
if (typeof template !== 'string' || template.length === 0) return template;
|
||||
const conditionalsResolved = template.replace(
|
||||
/\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g,
|
||||
(_match, key, inner) => {
|
||||
const v = variables ? variables[key] : undefined;
|
||||
const truthy = v !== undefined && v !== null && v !== '' && v !== false && v !== 0;
|
||||
return truthy ? inner : '';
|
||||
}
|
||||
);
|
||||
return conditionalsResolved.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
if (!variables || !Object.prototype.hasOwnProperty.call(variables, key)) return match;
|
||||
return String(variables[key]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the variable bag used by renderTemplatedBody. Reads the
|
||||
* customer record, business profile, and (when available) the
|
||||
* customer's active payment-term defaults so block placeholders for
|
||||
* net_days / skonto_percent / etc. resolve. Returns plain strings —
|
||||
* dates formatted DD.MM.YYYY in DE-CH style, numbers as-is.
|
||||
*/
|
||||
async function buildPlaceholderContext(contract, customer) {
|
||||
const profile = (await businessProfileService.getProfile()).profile || {};
|
||||
const issuerCompany = profile.company_name || '';
|
||||
const issuerAddress = [profile.address_line1, profile.postal_code, profile.city]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
// Resolve net_days + skonto from app_settings defaults so the
|
||||
// payment_terms_reference block has sensible numbers to substitute
|
||||
// when the admin hasn't tied the contract to a specific quote.
|
||||
const netDaysDefault = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30;
|
||||
const skontoPercentDefault = await getAppSetting('crm_invoices_skonto_percent_default');
|
||||
const skontoWithinDaysDefault = ensureInt(await getAppSetting('crm_invoices_skonto_business_days')) || 5;
|
||||
|
||||
// {{source_quote_number}} placeholder — substituted into the body of
|
||||
// the `quote_line_items_table` system block (and any admin-authored
|
||||
// block that wants to reference the quote). Empty string when the
|
||||
// contract wasn't generated from a quote.
|
||||
let sourceQuoteNumber = '';
|
||||
if (contract.source_quote_id) {
|
||||
const srcQuote = await db('quotes').where({ id: contract.source_quote_id })
|
||||
.select('quote_number').first();
|
||||
if (srcQuote) sourceQuoteNumber = srcQuote.quote_number || '';
|
||||
}
|
||||
|
||||
const customerName = customer
|
||||
? (customer.company_name
|
||||
|| [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|
||||
|| customer.display_name
|
||||
|| customer.email
|
||||
|| '')
|
||||
: '';
|
||||
const customerAddress = customer
|
||||
? [customer.address_line1, customer.address_line2, customer.postal_code, customer.city]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: '';
|
||||
|
||||
return {
|
||||
customer_name: customerName,
|
||||
customer_address: customerAddress,
|
||||
event_name: contract.event_name || '',
|
||||
event_date: formatShortDate(contract.event_date),
|
||||
issue_date: formatShortDate(contract.issue_date),
|
||||
contract_number: contract.contract_number || '',
|
||||
title: contract.title || '',
|
||||
net_days: String(netDaysDefault),
|
||||
skonto_percent: skontoPercentDefault == null ? '0' : String(skontoPercentDefault),
|
||||
skonto_within_days: String(skontoWithinDaysDefault),
|
||||
cancellation_30d_percent: '25',
|
||||
currency: (profile.default_currency || 'CHF').toUpperCase(),
|
||||
issuer_company_name: issuerCompany,
|
||||
issuer_address: issuerAddress,
|
||||
source_quote_number: sourceQuoteNumber,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Render-context builder + PDF helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the data shape pdfService.renderContractToBuffer expects.
|
||||
* Sections are emitted in canonical SECTIONS_ORDER; blocks within a
|
||||
* section are emitted in `position` order. Bodies are run through
|
||||
* renderTemplatedBody so {{placeholders}} are substituted.
|
||||
*
|
||||
* When the contract has been sent, `body_text_snapshot` is used (so
|
||||
* later edits to the source block don't mutate the rendered document).
|
||||
* Before send (preview from editor) the live `contract_blocks.body_text`
|
||||
* is used so the admin can iterate on block bodies and see the result.
|
||||
*/
|
||||
async function buildRenderContext(contract, inclusions) {
|
||||
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
|
||||
const profile = (await businessProfileService.getProfile()).profile || {};
|
||||
const placeholders = await buildPlaceholderContext(contract, customer);
|
||||
|
||||
// Pull source-quote line items when this contract was generated from a
|
||||
// quote. Surfaced on the render context so the renderer can draw a real
|
||||
// table at the location of the `quote_line_items_table` system block.
|
||||
// Sub-items keep their parent's position via the LEFT JOIN so the
|
||||
// renderer can indent them with a `↳` prefix.
|
||||
let quoteLineItems = [];
|
||||
let quoteCurrency = null;
|
||||
let quoteNumber = null;
|
||||
if (contract.source_quote_id) {
|
||||
const srcQuote = await db('quotes').where({ id: contract.source_quote_id })
|
||||
.select('quote_number', 'currency').first();
|
||||
if (srcQuote) {
|
||||
quoteCurrency = srcQuote.currency;
|
||||
quoteNumber = srcQuote.quote_number;
|
||||
quoteLineItems = await db('quote_line_items as li')
|
||||
.leftJoin('quote_line_items as parent', 'parent.id', 'li.parent_line_item_id')
|
||||
.where('li.quote_id', contract.source_quote_id)
|
||||
.orderBy('li.position', 'asc')
|
||||
.select('li.*', 'parent.position as parent_position');
|
||||
}
|
||||
}
|
||||
|
||||
const locale = contract.language || customer?.preferred_language || profile.default_locale || 'de';
|
||||
|
||||
// Group inclusions by section + render each block body.
|
||||
const blocksBySection = {};
|
||||
for (const section of SECTIONS_ORDER) blocksBySection[section] = [];
|
||||
const sortedInclusions = [...inclusions]
|
||||
.filter((row) => row.included === true || row.included === 1 || row.included === '1')
|
||||
.sort((a, b) => {
|
||||
const sa = SECTIONS_ORDER.indexOf(a.section);
|
||||
const sb = SECTIONS_ORDER.indexOf(b.section);
|
||||
if (sa !== sb) return sa - sb;
|
||||
return (a.position || 0) - (b.position || 0);
|
||||
});
|
||||
|
||||
for (const row of sortedInclusions) {
|
||||
if (!blocksBySection[row.section]) continue;
|
||||
// The inclusion row carries the JOINED block columns aliased with
|
||||
// a `block_` prefix (see getContractById). Pre-send drafts have
|
||||
// null snapshots, so fall through to the live block body.
|
||||
// Migration 131 added ru/pt/nl/fr columns. The body resolver
|
||||
// picks the locale-matching column first, falls back through
|
||||
// DE → EN, so an admin can stage translations one locale at a
|
||||
// time without breaking contracts in other languages.
|
||||
const bodyEn = row.body_text_snapshot || row.block_body_text || '';
|
||||
const bodyDe = row.body_text_de_snapshot || row.block_body_text_de || '';
|
||||
const bodyRu = row.block_body_text_ru || '';
|
||||
const bodyPt = row.block_body_text_pt || '';
|
||||
const bodyNl = row.block_body_text_nl || '';
|
||||
const bodyFr = row.block_body_text_fr || '';
|
||||
const localeBody = ({
|
||||
de: bodyDe,
|
||||
ru: bodyRu,
|
||||
pt: bodyPt,
|
||||
nl: bodyNl,
|
||||
fr: bodyFr,
|
||||
})[locale] || '';
|
||||
const sourceBody = localeBody || bodyEn || bodyDe;
|
||||
// Substitute placeholders, then strip any leading `**Title**\n`
|
||||
// line — the block's `name` field is already rendered as a bold
|
||||
// sub-heading by the PDF/public layouts, so a bold first line in
|
||||
// the body produces a duplicated title. Inline `**bold**` markers
|
||||
// elsewhere in the body are preserved (the PDF renders them as
|
||||
// actual bold via renderBodyMarkdown; the public route strips
|
||||
// them since the React page has no inline-bold UI).
|
||||
const rendered = renderTemplatedBody(sourceBody, placeholders)
|
||||
.replace(/^\s*\*\*[^*\n]+\*\*\s*\n+/, '');
|
||||
blocksBySection[row.section].push({
|
||||
slug: row.block_slug || null,
|
||||
name: row.block_name,
|
||||
section: row.section,
|
||||
body: rendered,
|
||||
});
|
||||
}
|
||||
|
||||
// Use the same robust logo resolver quote/invoice use — checks
|
||||
// business_profile.logo_path → app_settings.branding_logo_path →
|
||||
// app_settings.branding_logo_url, with ~7 disk-location candidates
|
||||
// before giving up.
|
||||
const { resolveLogoFile } = require('../../utils/resolveLogoFile');
|
||||
const resolvedLogoPath = await resolveLogoFile(profile);
|
||||
|
||||
// Global date format from Settings → General (general_date_format).
|
||||
let dateFormat = null;
|
||||
try {
|
||||
const raw = await getAppSetting('general_date_format');
|
||||
if (raw && typeof raw === 'object' && raw.format) dateFormat = raw;
|
||||
else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
|
||||
} catch (_) { /* fall back to default */ }
|
||||
|
||||
return {
|
||||
locale,
|
||||
dateFormat,
|
||||
// Mirror the quote/invoice issuer shape EXACTLY so drawIssuerBlock
|
||||
// honours the same business-profile toggles (pdf_show_logo,
|
||||
// pdf_show_company_name, pdf_logo_height, pdf_company_name_inline,
|
||||
// pdf_folding_marks) across all three document types. Per maintainer:
|
||||
// contracts reuse the same toggles — no contract-specific knobs.
|
||||
// Shared issuer + recipient builders. Contracts use the base toggle
|
||||
// set (no quote-only payment-block fields). The renderer-aware
|
||||
// recipient gating means contractService's previously-drifted
|
||||
// local attentionLine logic now matches quote + invoice exactly.
|
||||
issuer: buildIssuerBlock(profile, resolvedLogoPath),
|
||||
recipient: buildRecipientBlock(profile, customer),
|
||||
doc: {
|
||||
contractNumber: contract.contract_number,
|
||||
title: contract.title || '',
|
||||
issueDate: contract.issue_date,
|
||||
validUntil: contract.valid_until,
|
||||
introText: contract.intro_text ? renderTemplatedBody(contract.intro_text, placeholders) : null,
|
||||
outroText: contract.outro_text ? renderTemplatedBody(contract.outro_text, placeholders) : null,
|
||||
},
|
||||
// Blocks grouped + ordered by canonical section order.
|
||||
sections: SECTIONS_ORDER
|
||||
.map((section) => ({ section, blocks: blocksBySection[section] }))
|
||||
.filter((s) => s.blocks.length > 0),
|
||||
// Source-quote line items, surfaced at the top level so the PDF
|
||||
// renderer can draw a formatted table where the
|
||||
// `quote_line_items_table` system block is included. Empty array
|
||||
// when the contract has no source quote.
|
||||
quoteLineItems,
|
||||
quoteCurrency,
|
||||
quoteSourceNumber: quoteNumber,
|
||||
// Signature evidence (used by the PDF renderer to stamp signatures
|
||||
// into the closing section when present).
|
||||
signatures: {
|
||||
customer: contract.signed_customer_name ? {
|
||||
name: contract.signed_customer_name,
|
||||
signedAt: contract.signed_by_customer_at,
|
||||
ip: contract.signed_customer_ip,
|
||||
signaturePath: contract.signed_customer_signature_path,
|
||||
} : null,
|
||||
admin: contract.signed_admin_name ? {
|
||||
name: contract.signed_admin_name,
|
||||
signedAt: contract.signed_by_admin_at,
|
||||
ip: contract.signed_admin_ip,
|
||||
signaturePath: contract.signed_admin_signature_path,
|
||||
} : null,
|
||||
},
|
||||
// Audit-trail evidence appended to the rendered PDF as a final
|
||||
// page (issue #3). The renderer skips the page when this is null
|
||||
// OR when the contract isn't signed yet, so unsigned PDFs stay
|
||||
// unchanged. Hashes are best-effort: pdfSha256 may be null on
|
||||
// installs that haven't migrated to the new schema column yet —
|
||||
// the page still renders the rest of the evidence.
|
||||
audit: (contract.signed_customer_name || contract.signed_admin_name) ? {
|
||||
contractNumber: contract.contract_number,
|
||||
issuedAt: contract.sent_at,
|
||||
pdfSha256: contract.pdf_sha256 || null,
|
||||
signedPdfSha256: contract.signed_pdf_sha256 || null,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
module.exports = {
|
||||
renderTemplatedBody,
|
||||
buildPlaceholderContext,
|
||||
buildRenderContext,
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
// Extracted verbatim from contractService.js — see ../contractService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const { formatShortDate } = require('../../utils/dateFormatter');
|
||||
const pdfService = require('../pdfService');
|
||||
const emailProcessor = require('../emailProcessor');
|
||||
const { ensureContractEmailTemplatesSeeded } = require('../contractEmailTemplates');
|
||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||
const { adminActor, emitContractEvent, ensureCustomerActive } = require('./helpers');
|
||||
const { buildRenderContext } = require('./renderContext');
|
||||
const { persistContractPdf } = require('./signatureAssets');
|
||||
const { getContractById } = require('./crud');
|
||||
|
||||
|
||||
/**
|
||||
* Render PDF for a saved contract (preview before send, or re-render
|
||||
* after signing).
|
||||
*/
|
||||
async function renderContractPdfBuffer(contractId) {
|
||||
const data = await getContractById(contractId);
|
||||
if (!data) throw new AppError('Contract not found', 404);
|
||||
const ctx = await buildRenderContext(data.contract, data.inclusions);
|
||||
return await pdfService.renderContractToBuffer(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the contract: snapshot every included block's body, render PDF,
|
||||
* persist, mint a signing token, queue the customer email.
|
||||
*/
|
||||
async function sendContract(id, adminId) {
|
||||
// Self-heal: dev installs that ran migration 130 BEFORE we added
|
||||
// contract_fully_signed to the seed list won't have all three
|
||||
// contract templates in email_templates. Insert any missing rows
|
||||
// before we queue the email. Idempotent + module-cached.
|
||||
await ensureContractEmailTemplatesSeeded(db, logger);
|
||||
|
||||
const data = await getContractById(id);
|
||||
if (!data) throw new AppError('Contract not found', 404);
|
||||
const { contract, inclusions } = data;
|
||||
|
||||
if (!['draft'].includes(contract.status)) {
|
||||
throw new AppError(`Cannot send a contract with status '${contract.status}'`, 409);
|
||||
}
|
||||
|
||||
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
|
||||
ensureCustomerActive(customer);
|
||||
|
||||
// Snapshot every included block's body into the inclusion row so
|
||||
// future block edits don't mutate the sent contract.
|
||||
await db.transaction(async (trx) => {
|
||||
for (const inc of inclusions) {
|
||||
if (!(inc.included === true || inc.included === 1 || inc.included === '1')) continue;
|
||||
await trx('contract_block_inclusions').where({ id: inc.id }).update({
|
||||
body_text_snapshot: inc.block_body_text || null,
|
||||
body_text_de_snapshot: inc.block_body_text_de || null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fetch with snapshots populated so the renderer uses the frozen
|
||||
// bodies (matches post-send reads).
|
||||
const refreshed = await getContractById(id);
|
||||
const ctx = await buildRenderContext(refreshed.contract, refreshed.inclusions);
|
||||
const buffer = await pdfService.renderContractToBuffer(ctx);
|
||||
const { filePath: pdfPath, sha256: pdfSha256 } = await persistContractPdf(refreshed.contract, buffer);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = contract.valid_until
|
||||
? new Date(new Date(contract.valid_until).getTime() + 14 * 24 * 60 * 60 * 1000)
|
||||
: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Schema-drift guard for the new pdf_sha256 column (migration 130
|
||||
// in-place edit). Dev installs that haven't re-migrated skip the
|
||||
// hash write; the send still succeeds.
|
||||
const hasPdfSha = await hasColumnCached('contracts', 'pdf_sha256');
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('contract_action_tokens').insert({
|
||||
contract_id: id,
|
||||
token,
|
||||
expires_at: expiresAt,
|
||||
created_at: new Date(),
|
||||
});
|
||||
const updates = {
|
||||
status: 'sent',
|
||||
sent_at: new Date(),
|
||||
pdf_path: pdfPath,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (hasPdfSha) updates.pdf_sha256 = pdfSha256;
|
||||
await trx('contracts').where({ id }).update(updates);
|
||||
});
|
||||
|
||||
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
|
||||
const responseUrl = `${frontendUrl}/contract/${token}`;
|
||||
// Honour the admin's "Attach contract PDF to email" toggle. Default
|
||||
// ON; an admin who prefers a link-only email turns it off and the
|
||||
// customer reaches the PDF via the public sign page instead.
|
||||
const attachPdf = await getAppSetting('crm_contracts_pdf_attachment_enabled');
|
||||
await emailProcessor.queueEmail(null, customer.email, 'contract_sent', {
|
||||
contract_number: contract.contract_number,
|
||||
customer_name: customer.display_name
|
||||
|| [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|
||||
|| customer.email.split('@')[0],
|
||||
response_url: responseUrl,
|
||||
title: contract.title || '',
|
||||
event_name: contract.event_name || '',
|
||||
valid_until: formatShortDate(contract.valid_until),
|
||||
attachments: (attachPdf !== false && pdfPath) ? [{
|
||||
filename: `${contract.contract_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
contentType: 'application/pdf',
|
||||
}] : undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
|
||||
await emitContractEvent(contract, 'sent');
|
||||
|
||||
logger.info('Contract sent', { adminId, contractId: id });
|
||||
return { token, pdfPath };
|
||||
}
|
||||
module.exports = {
|
||||
renderContractPdfBuffer,
|
||||
sendContract,
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
// Extracted verbatim from contractService.js — see ../contractService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../../utils/logger');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const pdfStampService = require('../pdfStampService');
|
||||
|
||||
|
||||
/**
|
||||
* SHA-256 hex digest of a Buffer or file path. Used at every PDF
|
||||
* write so we can persist a content hash alongside the path —
|
||||
* either party can later re-hash the PDF they hold and prove (or
|
||||
* disprove) it matches what we issued.
|
||||
*/
|
||||
function sha256OfBuffer(buffer) {
|
||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
function sha256OfFile(filePath) {
|
||||
try {
|
||||
return sha256OfBuffer(fs.readFileSync(filePath));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a contract PDF to disk and return both the path AND the
|
||||
* SHA-256 hash of the buffer we just wrote. Callers persist BOTH on
|
||||
* the contracts row so audit defence is single-query: SELECT
|
||||
* pdf_path, pdf_sha256 FROM contracts WHERE id = ? then re-hash the
|
||||
* file on disk and compare.
|
||||
*
|
||||
* History-preserving (per requirement #6): every write appends a
|
||||
* deterministic suffix so old versions stay on disk. The contract
|
||||
* row's `pdf_path` / `signed_pdf_path` always points at the most
|
||||
* recent one; earlier versions remain available for forensic
|
||||
* comparison.
|
||||
*/
|
||||
async function persistContractPdf(contract, buffer, suffix = '') {
|
||||
if (!contract.contract_number) return { filePath: null, sha256: null };
|
||||
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
// Always append a millisecond timestamp to the filename so writes
|
||||
// never overwrite an earlier version on disk. Forensic preservation.
|
||||
// Example filenames:
|
||||
// C-2026-0001_2026-05-19T1830-22-413.pdf (unsigned)
|
||||
// C-2026-0001_signed-by-customer_2026-05-19T1845-10-002.pdf
|
||||
// C-2026-0001_fully-signed_2026-05-19T1912-44-877.pdf
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const fileName = suffix
|
||||
? `${contract.contract_number}_${suffix}_${stamp}.pdf`
|
||||
: `${contract.contract_number}_${stamp}.pdf`;
|
||||
const filePath = path.join(root, fileName);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { filePath, sha256: sha256OfBuffer(buffer) };
|
||||
}
|
||||
|
||||
// Maximum decoded signature image size. Defends against a customer
|
||||
// (or attacker holding a captured signing token) POSTing a multi-MB
|
||||
// signature data URL to fill the disk. A typical signature_pad PNG
|
||||
// is 10–80 KB; even with retina upscaling we don't expect to see
|
||||
// 1 MB. The cap is enforced on the BASE64 length before decoding so
|
||||
// we never allocate the full Buffer for an oversized payload.
|
||||
//
|
||||
// The frontend (ContractResponsePage) downscales the canvas to a
|
||||
// fixed max width before exporting via `toDataURL`, so well-behaved
|
||||
// clients land well under this cap. This server-side check is the
|
||||
// authoritative guard.
|
||||
const MAX_SIGNATURE_BASE64_BYTES = 1024 * 1024; // 1 MB of base64 → ~750 KB decoded
|
||||
|
||||
async function persistSignatureImage(contract, role, dataUrl) {
|
||||
if (!dataUrl || typeof dataUrl !== 'string') return null;
|
||||
if (dataUrl.length > MAX_SIGNATURE_BASE64_BYTES + 100 /* prefix slack */) {
|
||||
throw new AppError(
|
||||
`Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`,
|
||||
413, 'SIGNATURE_TOO_LARGE',
|
||||
);
|
||||
}
|
||||
const match = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/);
|
||||
if (!match) {
|
||||
throw new AppError('Signature must be a base64-encoded PNG or JPEG data URL', 400, 'BAD_SIGNATURE_FORMAT');
|
||||
}
|
||||
if (match[2].length > MAX_SIGNATURE_BASE64_BYTES) {
|
||||
throw new AppError(
|
||||
`Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`,
|
||||
413, 'SIGNATURE_TOO_LARGE',
|
||||
);
|
||||
}
|
||||
const ext = match[1] === 'jpeg' ? 'jpg' : 'png';
|
||||
const root = path.join(
|
||||
process.cwd(),
|
||||
'storage',
|
||||
'business-docs',
|
||||
'contract',
|
||||
'signatures',
|
||||
String(contract.id),
|
||||
);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
// Filename already carries Date.now() so re-stamping a signature
|
||||
// never overwrites an earlier capture — forensic preservation.
|
||||
// Per role, the contract row's signed_*_signature_path always
|
||||
// points at the most recent; older files stay alongside.
|
||||
const filePath = path.join(root, `${role}-${Date.now()}.${ext}`);
|
||||
fs.writeFileSync(filePath, Buffer.from(match[2], 'base64'));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stamp sequence the pdf-lib stamp service expects from a
|
||||
* single contract row. Customer first, admin second — provenance
|
||||
* order matches the visual order on the signature page.
|
||||
*
|
||||
* Used by the recovery paths (rerenderAndResend, restampSignatures).
|
||||
* The hot path (recordCustomerSignature / recordAdminCountersignature)
|
||||
* stamps incrementally so it constructs the stamp inline.
|
||||
*/
|
||||
function buildSignatureStamps(contract) {
|
||||
const locale = contract.language || 'de';
|
||||
const nameLabel = 'Name';
|
||||
const dateLabel = locale === 'de' ? 'Datum' : 'Date';
|
||||
const stamps = [];
|
||||
if (contract.signed_customer_signature_path) {
|
||||
stamps.push({
|
||||
signaturePngPath: contract.signed_customer_signature_path,
|
||||
role: 'customer',
|
||||
caption: {
|
||||
name: contract.signed_customer_name || '',
|
||||
signedAt: contract.signed_by_customer_at,
|
||||
nameLabel,
|
||||
dateLabel,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (contract.signed_admin_signature_path) {
|
||||
stamps.push({
|
||||
signaturePngPath: contract.signed_admin_signature_path,
|
||||
role: 'admin',
|
||||
caption: {
|
||||
name: contract.signed_admin_name || '',
|
||||
signedAt: contract.signed_by_admin_at,
|
||||
nameLabel,
|
||||
dateLabel,
|
||||
},
|
||||
});
|
||||
}
|
||||
return stamps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the audit-certificate context expected by
|
||||
* pdfStampService.renderAuditCertificate from a fully-signed
|
||||
* contract row. Returns null when the contract isn't signed enough
|
||||
* to warrant a certificate (no customer + no admin signature data).
|
||||
*/
|
||||
function buildAuditCertContext(contract) {
|
||||
const hasCustomerSig = contract.signed_by_customer_at || contract.signed_customer_name;
|
||||
const hasAdminSig = contract.signed_by_admin_at || contract.signed_admin_name;
|
||||
if (!hasCustomerSig && !hasAdminSig) return null;
|
||||
return {
|
||||
contract: {
|
||||
contract_number: contract.contract_number,
|
||||
sent_at: contract.sent_at,
|
||||
pdf_sha256: contract.pdf_sha256 || null,
|
||||
signed_pdf_sha256: contract.signed_pdf_sha256 || null,
|
||||
},
|
||||
customer: hasCustomerSig ? {
|
||||
name: contract.signed_customer_name,
|
||||
signedAt: contract.signed_by_customer_at,
|
||||
ip: contract.signed_customer_ip,
|
||||
} : null,
|
||||
admin: hasAdminSig ? {
|
||||
name: contract.signed_admin_name,
|
||||
signedAt: contract.signed_by_admin_at,
|
||||
ip: contract.signed_admin_ip,
|
||||
} : null,
|
||||
locale: contract.language || 'de',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the audit certificate PDF, write it to disk under the same
|
||||
* year directory as the contract PDFs (suffix `audit`), and return
|
||||
* its file path. Returns null when there's nothing to certify or when
|
||||
* rendering fails (the email still goes out without the cert — the
|
||||
* stamped PDF alone remains delivered).
|
||||
*/
|
||||
async function persistAuditCertificate(contract) {
|
||||
const ctx = buildAuditCertContext(contract);
|
||||
if (!ctx) return null;
|
||||
try {
|
||||
const { buffer } = await pdfStampService.renderAuditCertificate(ctx);
|
||||
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filePath = path.join(root, `${contract.contract_number}_audit_${stamp}.pdf`);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
logger.error('Failed to render audit certificate', {
|
||||
contractId: contract.id,
|
||||
contractNumber: contract.contract_number,
|
||||
message: err.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
module.exports = {
|
||||
sha256OfBuffer,
|
||||
sha256OfFile,
|
||||
persistContractPdf,
|
||||
MAX_SIGNATURE_BASE64_BYTES,
|
||||
persistSignatureImage,
|
||||
buildSignatureStamps,
|
||||
buildAuditCertContext,
|
||||
persistAuditCertificate,
|
||||
};
|
||||
@@ -1,861 +0,0 @@
|
||||
// Extracted verbatim from contractService.js — see ../contractService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const fs = require('fs');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const businessProfileService = require('../businessProfileService');
|
||||
const pdfStampService = require('../pdfStampService');
|
||||
const emailProcessor = require('../emailProcessor');
|
||||
const { ensureContractEmailTemplatesSeeded } = require('../contractEmailTemplates');
|
||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||
const { adminActor, customerPublicActor, emitContractEvent, maybeStoreIp } = require('./helpers');
|
||||
const { buildSignatureStamps, persistAuditCertificate, persistContractPdf, persistSignatureImage, sha256OfFile } = require('./signatureAssets');
|
||||
const { getContractById } = require('./crud');
|
||||
|
||||
|
||||
/**
|
||||
* Record a customer's in-browser signature (canvas + typed name +
|
||||
* "I accept" checkbox). Validates the token, persists the signature
|
||||
* PNG, re-renders the PDF with the signature stamped, flips status
|
||||
* to `signed_by_customer`, and queues the admin notification email.
|
||||
*/
|
||||
async function recordCustomerSignature({ token, name, ip, signatureDataUrl, accepted }) {
|
||||
// Self-heal contract email templates. The contract_signed_admin_notification
|
||||
// email fires from this function — if its row is missing, the admin
|
||||
// never learns the customer signed.
|
||||
await ensureContractEmailTemplatesSeeded(db, logger);
|
||||
|
||||
if (accepted !== true) {
|
||||
throw new AppError('You must confirm that you have read and agree to the terms.', 400, 'TOS_REQUIRED');
|
||||
}
|
||||
if (!name || !String(name).trim()) {
|
||||
throw new AppError('Your name is required.', 400, 'NAME_REQUIRED');
|
||||
}
|
||||
// Server-side guard for the "require drawn signature" admin toggle.
|
||||
// The public sign page also enforces this client-side, but the
|
||||
// server is the source of truth — a malicious caller posting
|
||||
// directly to /sign with a blank signatureDataUrl would otherwise
|
||||
// bypass the requirement.
|
||||
const requireDrawn = await getAppSetting('crm_contracts_require_drawn_signature');
|
||||
if (requireDrawn === true && (!signatureDataUrl || !String(signatureDataUrl).trim())) {
|
||||
throw new AppError(
|
||||
'A drawn signature is required for this contract — typing your name alone is not sufficient.',
|
||||
400, 'SIGNATURE_REQUIRED',
|
||||
);
|
||||
}
|
||||
const tokenRow = await db('contract_action_tokens').where({ token }).first();
|
||||
if (!tokenRow) throw new AppError('Token not found', 404);
|
||||
if (tokenRow.expires_at && new Date(tokenRow.expires_at).getTime() < Date.now()) {
|
||||
throw new AppError('This signing link has expired', 410);
|
||||
}
|
||||
if (tokenRow.used_at) {
|
||||
throw new AppError('This contract has already been signed', 410, 'TOKEN_ALREADY_USED');
|
||||
}
|
||||
|
||||
const contract = await db('contracts').where({ id: tokenRow.contract_id }).first();
|
||||
if (!contract) throw new AppError('Contract not found', 404);
|
||||
if (!['sent'].includes(contract.status)) {
|
||||
throw new AppError(`Contract cannot be signed in status '${contract.status}'`, 409);
|
||||
}
|
||||
|
||||
const signaturePath = signatureDataUrl
|
||||
? await persistSignatureImage(contract, 'customer', signatureDataUrl)
|
||||
: null;
|
||||
|
||||
const now = new Date();
|
||||
// Resolve the IP gate ONCE before the transaction so both writes
|
||||
// (contracts row + tokens row) agree. Setting flip mid-transaction
|
||||
// can't happen anyway, but doing it upfront keeps the data
|
||||
// consistent and saves a redundant read.
|
||||
const persistedIp = await maybeStoreIp(ip);
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('contracts').where({ id: contract.id }).update({
|
||||
status: 'signed_by_customer',
|
||||
signed_by_customer_at: now,
|
||||
signed_customer_name: String(name).trim(),
|
||||
signed_customer_ip: persistedIp,
|
||||
signed_customer_signature_path: signaturePath,
|
||||
updated_at: now,
|
||||
});
|
||||
await trx('contract_action_tokens').where({ id: tokenRow.id }).update({
|
||||
used_at: now,
|
||||
used_action: 'signed_by_customer',
|
||||
used_ip: persistedIp,
|
||||
});
|
||||
});
|
||||
} catch (txErr) {
|
||||
// C.7 — clean up the orphan signature PNG we wrote before the
|
||||
// transaction. The DB rollback already undid the contract +
|
||||
// token writes; the file would otherwise sit forever in
|
||||
// storage/business-docs/contract/.../signatures/. Best-effort
|
||||
// unlink — if the cleanup itself fails, log and re-throw the
|
||||
// original transaction error so the caller still sees the real
|
||||
// failure cause.
|
||||
if (signaturePath) {
|
||||
try {
|
||||
if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath);
|
||||
} catch (cleanupErr) {
|
||||
logger.warn('Orphan signature PNG cleanup failed', {
|
||||
path: signaturePath, message: cleanupErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw txErr;
|
||||
}
|
||||
|
||||
// Stamp the customer's signature onto the UNSIGNED PDF on disk.
|
||||
// Byte-immutable approach (see pdfStampService): we read pdf_path
|
||||
// (the immutable as-sent PDF), stamp the customer's signature PNG
|
||||
// at the fixed coordinates on the signature page, save as a new
|
||||
// timestamped file, and update signed_pdf_path. Original file
|
||||
// stays untouched on disk.
|
||||
const refreshed = await getContractById(contract.id);
|
||||
try {
|
||||
if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) {
|
||||
throw new Error(`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}`);
|
||||
}
|
||||
const originalPdfBuffer = fs.readFileSync(refreshed.contract.pdf_path);
|
||||
const stampedBuffer = await pdfStampService.stampSignature({
|
||||
pdfBuffer: originalPdfBuffer,
|
||||
signaturePngPath: signaturePath,
|
||||
role: 'customer',
|
||||
caption: {
|
||||
name: String(name).trim(),
|
||||
signedAt: now,
|
||||
nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name',
|
||||
dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date',
|
||||
},
|
||||
});
|
||||
const { filePath: signedPath, sha256: signedSha256 } = await persistContractPdf(
|
||||
refreshed.contract, stampedBuffer, 'signed-by-customer',
|
||||
);
|
||||
const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256');
|
||||
const updates = {
|
||||
signed_pdf_path: signedPath,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256;
|
||||
// Migration 136 — clear any pre-existing render-failed marker; the
|
||||
// most recent stamp attempt just succeeded.
|
||||
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
|
||||
updates.signed_pdf_render_failed_at = null;
|
||||
updates.signed_pdf_render_error = null;
|
||||
}
|
||||
await db('contracts').where({ id: contract.id }).update(updates);
|
||||
} catch (err) {
|
||||
// Signature recorded; PDF re-render is best-effort. The admin can
|
||||
// re-render manually from the detail page if this fails. Logged as
|
||||
// error (not warn) so persistent failures surface in monitoring.
|
||||
logger.error('Failed to re-render contract PDF after customer signature', {
|
||||
contractId: contract.id,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
// Migration 136 — surface the failure on the contract row so the
|
||||
// admin detail page can render a recovery banner instead of the
|
||||
// admin only discovering this through monitoring. err.message is
|
||||
// truncated to 2 KB; the full stack stays in server logs.
|
||||
try {
|
||||
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
|
||||
await db('contracts').where({ id: contract.id }).update({
|
||||
signed_pdf_render_failed_at: new Date(),
|
||||
signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (markErr) {
|
||||
// Marker write itself failed — log + swallow so the customer
|
||||
// sign response still succeeds. The orphan stays orphan but
|
||||
// we've at least surfaced both errors.
|
||||
logger.error('Failed to record signed_pdf_render_failed marker', {
|
||||
contractId: contract.id, message: markErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Notify admin.
|
||||
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
|
||||
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
|
||||
try {
|
||||
await emailProcessor.queueEmail(null, null, 'contract_signed_admin_notification', {
|
||||
contract_number: contract.contract_number,
|
||||
customer_email: customer?.email || '',
|
||||
signed_customer_name: String(name).trim(),
|
||||
admin_dashboard_url: `${frontendUrl}/admin/clients/contracts/${contract.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to queue admin notification after customer signature', {
|
||||
contractId: contract.id, error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('contract_signed_by_customer', { contractId: contract.id, token }, null, customerPublicActor());
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
|
||||
return { status: 'signed_by_customer', signedAt: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin counter-signature. Bumps status to `fully_signed` (or
|
||||
* `signed_by_admin` if the customer hasn't signed yet — edge case
|
||||
* where admin signs first, e.g. issuer-side framework agreement).
|
||||
*/
|
||||
async function recordAdminCountersignature(contractId, { name, ip, signatureDataUrl }, adminId) {
|
||||
// Self-heal: ensure the contract_fully_signed template exists
|
||||
// before we counter-sign. The dual-party send fires from this
|
||||
// function on the fully_signed transition; without the template
|
||||
// it silently fails and the customer never receives the PDF.
|
||||
await ensureContractEmailTemplatesSeeded(db, logger);
|
||||
|
||||
if (!name || !String(name).trim()) {
|
||||
throw new AppError('Your name is required.', 400, 'NAME_REQUIRED');
|
||||
}
|
||||
const contract = await db('contracts').where({ id: contractId }).first();
|
||||
if (!contract) throw new AppError('Contract not found', 404);
|
||||
if (!['signed_by_customer', 'sent'].includes(contract.status)) {
|
||||
throw new AppError(`Cannot counter-sign a contract with status '${contract.status}'`, 409);
|
||||
}
|
||||
|
||||
const signaturePath = signatureDataUrl
|
||||
? await persistSignatureImage(contract, 'admin', signatureDataUrl)
|
||||
: null;
|
||||
|
||||
const now = new Date();
|
||||
const newStatus = contract.status === 'signed_by_customer' ? 'fully_signed' : 'signed_by_admin';
|
||||
const persistedAdminIp = await maybeStoreIp(ip);
|
||||
try {
|
||||
await db('contracts').where({ id: contract.id }).update({
|
||||
status: newStatus,
|
||||
signed_by_admin_at: now,
|
||||
signed_admin_name: String(name).trim(),
|
||||
signed_admin_ip: persistedAdminIp,
|
||||
signed_admin_signature_path: signaturePath,
|
||||
updated_at: now,
|
||||
});
|
||||
} catch (updateErr) {
|
||||
// C.7 — clean up the orphan signature PNG if the contract row
|
||||
// update threw. Best-effort; log on cleanup failure and re-throw
|
||||
// the original update error.
|
||||
if (signaturePath) {
|
||||
try {
|
||||
if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath);
|
||||
} catch (cleanupErr) {
|
||||
logger.warn('Orphan admin signature PNG cleanup failed', {
|
||||
path: signaturePath, message: cleanupErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw updateErr;
|
||||
}
|
||||
|
||||
// Stamp the admin's signature ON TOP of whatever signed_pdf_path
|
||||
// currently holds (the customer-stamped PDF, in the normal flow)
|
||||
// — or directly onto the unsigned pdf_path if the admin is the
|
||||
// first to sign (edge case). Byte-immutable: each prior PDF stays
|
||||
// on disk; the new file is a fresh timestamped version.
|
||||
const refreshed = await getContractById(contract.id);
|
||||
let signedPath = null;
|
||||
let signedSha256 = null;
|
||||
try {
|
||||
const baseFile = (refreshed.contract.signed_pdf_path && fs.existsSync(refreshed.contract.signed_pdf_path))
|
||||
? refreshed.contract.signed_pdf_path
|
||||
: refreshed.contract.pdf_path;
|
||||
if (!baseFile || !fs.existsSync(baseFile)) {
|
||||
throw new Error(`Contract base PDF missing on disk for stamping (signed_pdf_path=${refreshed.contract.signed_pdf_path}, pdf_path=${refreshed.contract.pdf_path})`);
|
||||
}
|
||||
const baseBuffer = fs.readFileSync(baseFile);
|
||||
const stampedBuffer = await pdfStampService.stampSignature({
|
||||
pdfBuffer: baseBuffer,
|
||||
signaturePngPath: signaturePath,
|
||||
role: 'admin',
|
||||
caption: {
|
||||
name: String(name).trim(),
|
||||
signedAt: now,
|
||||
nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name',
|
||||
dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date',
|
||||
},
|
||||
});
|
||||
const suffix = newStatus === 'fully_signed' ? 'fully-signed' : 'signed-by-admin';
|
||||
const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, suffix);
|
||||
signedPath = persisted.filePath;
|
||||
signedSha256 = persisted.sha256;
|
||||
const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256');
|
||||
const updates = {
|
||||
signed_pdf_path: signedPath,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256;
|
||||
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
|
||||
updates.signed_pdf_render_failed_at = null;
|
||||
updates.signed_pdf_render_error = null;
|
||||
}
|
||||
await db('contracts').where({ id: contract.id }).update(updates);
|
||||
} catch (err) {
|
||||
logger.error('Failed to stamp contract PDF after admin signature', {
|
||||
contractId: contract.id,
|
||||
newStatus,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
// Migration 136 — mirror the customer-sign branch: persist a
|
||||
// recovery marker so the admin detail page can surface a banner.
|
||||
try {
|
||||
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
|
||||
await db('contracts').where({ id: contract.id }).update({
|
||||
signed_pdf_render_failed_at: new Date(),
|
||||
signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (markErr) {
|
||||
logger.error('Failed to record signed_pdf_render_failed marker (admin sign)', {
|
||||
contractId: contract.id, message: markErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// When the admin's signature is what FINALISED the contract (i.e.
|
||||
// status flipped to fully_signed), email a copy of the freshly
|
||||
// re-rendered PDF to both parties. We send two separate queueEmail
|
||||
// calls so each recipient gets the email rendered with their own
|
||||
// greeting + name. The admin BCC is delivered as "to the issuer"
|
||||
// so it lands in the same inbox the contract_sent email originated
|
||||
// from.
|
||||
if (newStatus === 'fully_signed') {
|
||||
try {
|
||||
// Pick the best available PDF as the attachment, in priority
|
||||
// order: this counter-sign's freshly-rendered signed copy →
|
||||
// the customer-only signed copy we wrote earlier → the
|
||||
// original unsigned PDF. Falling all the way through to no
|
||||
// attachment is acceptable; the email still goes out with the
|
||||
// contract number so the customer knows it's binding.
|
||||
const refetched = await db('contracts').where({ id: contract.id }).first();
|
||||
const attachmentPath = signedPath
|
||||
|| refetched?.signed_pdf_path
|
||||
|| refetched?.pdf_path
|
||||
|| null;
|
||||
|
||||
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
|
||||
const profile = (await businessProfileService.getProfile()).profile || {};
|
||||
const adminRow = await db('admin_users').where({ id: adminId }).first();
|
||||
const customerName = customer?.display_name
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email?.split('@')[0]
|
||||
|| '';
|
||||
// Generate the audit certificate as a SIBLING document (separate
|
||||
// PDF) and attach it alongside the stamped contract. Audit cert
|
||||
// captures timestamps, IPs, names, and SHA-256 hashes — the legal
|
||||
// provenance record. Reproducible from contract data so safe to
|
||||
// regenerate on demand; we still persist a copy to disk for the
|
||||
// forensic trail.
|
||||
const auditCertPath = await persistAuditCertificate(refetched || refreshed.contract);
|
||||
|
||||
const attachments = [];
|
||||
if (attachmentPath) {
|
||||
attachments.push({
|
||||
filename: `${refreshed.contract.contract_number}-signed.pdf`,
|
||||
contentPath: attachmentPath,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
}
|
||||
if (auditCertPath) {
|
||||
attachments.push({
|
||||
filename: `${refreshed.contract.contract_number}-audit.pdf`,
|
||||
contentPath: auditCertPath,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
}
|
||||
const attachmentsArg = attachments.length > 0 ? attachments : undefined;
|
||||
|
||||
// 1. Customer copy
|
||||
if (customer?.email) {
|
||||
await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', {
|
||||
contract_number: refreshed.contract.contract_number,
|
||||
customer_name: customerName,
|
||||
title: refreshed.contract.title || '',
|
||||
attachments: attachmentsArg,
|
||||
});
|
||||
}
|
||||
// 2. Admin copy. Prefer business_profile.email (the inbox the
|
||||
// contract was sent FROM); fall back to the counter-signing
|
||||
// admin's account email so the audit trail still reaches a
|
||||
// human even on installs where business_profile.email is blank.
|
||||
const adminEmail = profile.email || adminRow?.email;
|
||||
if (adminEmail && adminEmail !== customer?.email) {
|
||||
await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', {
|
||||
contract_number: refreshed.contract.contract_number,
|
||||
customer_name: profile.company_name || adminRow?.first_name || 'Team',
|
||||
title: refreshed.contract.title || '',
|
||||
attachments: attachmentsArg,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to send contract_fully_signed emails', {
|
||||
contractId: contract.id,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
|
||||
// The binding moment — fire contract.signed once the contract is fully signed
|
||||
// (matches the editor's trigger). Best-effort / fail-closed.
|
||||
if (newStatus === 'fully_signed') await emitContractEvent(contract, 'signed');
|
||||
|
||||
return { status: newStatus, signedAt: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a wet-signed PDF as the authoritative signed copy. Either
|
||||
* party can upload (admin via admin route, customer via public token
|
||||
* route). When the customer uploads, status flips to `fully_signed`
|
||||
* because the wet signature is treated as a full agreement (admin
|
||||
* would normally also sign the wet copy before sending it to the
|
||||
* customer).
|
||||
*/
|
||||
async function attachSignedPdfUpload(contractId, filePath, uploaderRole) {
|
||||
// Self-heal contract email templates — same reason as the
|
||||
// sendContract + recordAdminCountersignature paths.
|
||||
await ensureContractEmailTemplatesSeeded(db, logger);
|
||||
|
||||
if (!filePath) throw new AppError('No file uploaded', 400);
|
||||
const contract = await db('contracts').where({ id: contractId }).first();
|
||||
if (!contract) throw new AppError('Contract not found', 404);
|
||||
if (['cancelled', 'draft'].includes(contract.status)) {
|
||||
throw new AppError(`Cannot attach a signed PDF to a contract in status '${contract.status}'`, 409);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updates = {
|
||||
signed_pdf_path: filePath,
|
||||
status: 'fully_signed',
|
||||
updated_at: now,
|
||||
};
|
||||
// Migration 135 — durable wet-upload discriminator. Persists the
|
||||
// "this row holds an authoritative wet upload, do not auto-overwrite"
|
||||
// signal as a column rather than inferring from the file path. See
|
||||
// the migration body for the full rationale.
|
||||
if (await hasColumnCached('contracts', 'signed_pdf_is_wet_upload')) {
|
||||
updates.signed_pdf_is_wet_upload = true;
|
||||
}
|
||||
// Hash the uploaded PDF on disk so we can later prove it wasn't
|
||||
// tampered with after upload. Multer wrote the file synchronously
|
||||
// before this handler runs, so reading it here is safe.
|
||||
if (await hasColumnCached('contracts', 'signed_pdf_sha256')) {
|
||||
updates.signed_pdf_sha256 = sha256OfFile(filePath);
|
||||
}
|
||||
if (uploaderRole === 'customer' && !contract.signed_by_customer_at) {
|
||||
updates.signed_by_customer_at = now;
|
||||
}
|
||||
if (uploaderRole === 'admin' && !contract.signed_by_admin_at) {
|
||||
updates.signed_by_admin_at = now;
|
||||
}
|
||||
await db('contracts').where({ id: contractId }).update(updates);
|
||||
|
||||
// attachSignedPdfUpload always transitions to fully_signed (see
|
||||
// updates.status above), so the dual-party send fires here too —
|
||||
// same pattern as recordAdminCountersignature. The uploaded PDF
|
||||
// IS the authoritative copy so we attach it directly.
|
||||
try {
|
||||
const refreshedContract = await db('contracts').where({ id: contractId }).first();
|
||||
const customer = await db('customer_accounts').where({ id: refreshedContract.customer_account_id }).first();
|
||||
const profile = (await businessProfileService.getProfile()).profile || {};
|
||||
const customerName = customer?.display_name
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email?.split('@')[0]
|
||||
|| '';
|
||||
const attachments = [{
|
||||
filename: `${refreshedContract.contract_number}-signed.pdf`,
|
||||
contentPath: filePath,
|
||||
contentType: 'application/pdf',
|
||||
}];
|
||||
// Sibling audit certificate — same legal-provenance record as the
|
||||
// in-browser sign path. Best-effort; missing cert doesn't block the
|
||||
// wet-signed PDF from reaching the parties.
|
||||
const auditCertPath = await persistAuditCertificate(refreshedContract);
|
||||
if (auditCertPath) {
|
||||
attachments.push({
|
||||
filename: `${refreshedContract.contract_number}-audit.pdf`,
|
||||
contentPath: auditCertPath,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
}
|
||||
if (customer?.email) {
|
||||
await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', {
|
||||
contract_number: refreshedContract.contract_number,
|
||||
customer_name: customerName,
|
||||
title: refreshedContract.title || '',
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
if (profile.email && profile.email !== customer?.email) {
|
||||
await emailProcessor.queueEmail(null, profile.email, 'contract_fully_signed', {
|
||||
contract_number: refreshedContract.contract_number,
|
||||
customer_name: profile.company_name || 'Team',
|
||||
title: refreshedContract.title || '',
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to send contract_fully_signed emails after PDF upload', {
|
||||
contractId, error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('contract_signed_pdf_uploaded', { contractId, uploaderRole }, null,
|
||||
uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor());
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
|
||||
await emitContractEvent(contract, 'signed');
|
||||
|
||||
return { status: 'fully_signed', signedPdfPath: filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery helper: re-render the signed PDF + resend the
|
||||
* contract_fully_signed email to both parties. Used by the admin
|
||||
* detail page when:
|
||||
* - a previous render silently failed (signed_pdf_path is empty
|
||||
* on a fully_signed contract)
|
||||
* - the customer reports they didn't receive the email
|
||||
* - the bodies of the seeded blocks were updated post-signing and
|
||||
* the admin wants the latest text on file
|
||||
*
|
||||
* Only available on fully_signed contracts. The wet-signed PDF path
|
||||
* is preserved: when signed_pdf_path already points at an uploaded
|
||||
* file (not a re-render path) we DO NOT overwrite — the uploaded PDF
|
||||
* is the authoritative copy. We still resend the email with that
|
||||
* uploaded PDF as the attachment.
|
||||
*/
|
||||
async function rerenderAndResend(contractId, adminId) {
|
||||
// Self-heal contract email templates. This is the most likely
|
||||
// recovery path the admin reaches when a prior dual-party send
|
||||
// failed silently — including when the failure was caused by the
|
||||
// template being missing in the first place.
|
||||
const newlySeeded = await ensureContractEmailTemplatesSeeded(db, logger);
|
||||
if (newlySeeded.length > 0) {
|
||||
logger.warn('rerenderAndResend self-healed missing email templates', {
|
||||
contractId, seeded: newlySeeded,
|
||||
});
|
||||
}
|
||||
|
||||
const contract = await db('contracts').where({ id: contractId }).first();
|
||||
if (!contract) throw new AppError('Contract not found', 404);
|
||||
if (contract.status !== 'fully_signed') {
|
||||
throw new AppError(
|
||||
`Re-send is only available on fully-signed contracts (status: ${contract.status})`,
|
||||
409, 'NOT_FULLY_SIGNED',
|
||||
);
|
||||
}
|
||||
|
||||
let attachmentPath = contract.signed_pdf_path || null;
|
||||
// Migration 135 — `signed_pdf_is_wet_upload` is the durable
|
||||
// authoritative-source discriminator. It's set TRUE only by
|
||||
// attachSignedPdfUpload, so any non-wet path here is a system
|
||||
// stamp safe to replace. We still null-check the path so missing
|
||||
// (re-stamp recovery) cases trigger the re-stamp branch below.
|
||||
const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload');
|
||||
const isWetSignedUpload = hasWetFlagColumn
|
||||
? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1)
|
||||
// Fallback ONLY for installs where the migration hasn't applied yet:
|
||||
// preserve the historical substring rule so we don't accidentally
|
||||
// overwrite uploads on an un-migrated DB.
|
||||
: !!(attachmentPath && attachmentPath.includes('uploads/contracts/signed'));
|
||||
if (!attachmentPath || !isWetSignedUpload) {
|
||||
// Stamp signatures onto the immutable unsigned pdf_path using
|
||||
// pdf-lib (NOT a full re-render). This preserves the exact bytes
|
||||
// the customer originally agreed to and side-steps the silent re-
|
||||
// render failure that left signed_pdf_path NULL on prior contracts.
|
||||
const refreshed = await getContractById(contract.id);
|
||||
if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) {
|
||||
throw new AppError(
|
||||
`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`,
|
||||
500, 'UNSIGNED_PDF_MISSING',
|
||||
);
|
||||
}
|
||||
const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path);
|
||||
const stamps = buildSignatureStamps(refreshed.contract);
|
||||
const { buffer: stampedBuffer, sha256: signedSha256 } =
|
||||
await pdfStampService.stampSignatures(originalBuffer, stamps);
|
||||
const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, 'fully-signed');
|
||||
attachmentPath = persisted.filePath;
|
||||
const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256');
|
||||
const updates = {
|
||||
signed_pdf_path: attachmentPath,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256;
|
||||
// Migration 136 — this branch is a recovery path; clear any
|
||||
// existing failed-render marker.
|
||||
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
|
||||
updates.signed_pdf_render_failed_at = null;
|
||||
updates.signed_pdf_render_error = null;
|
||||
}
|
||||
await db('contracts').where({ id: contract.id }).update(updates);
|
||||
}
|
||||
|
||||
// Resend the dual-party email with the now-guaranteed attachment.
|
||||
const refetched = await db('contracts').where({ id: contract.id }).first();
|
||||
const customer = await db('customer_accounts').where({ id: refetched.customer_account_id }).first();
|
||||
const profile = (await businessProfileService.getProfile()).profile || {};
|
||||
const adminRow = await db('admin_users').where({ id: adminId }).first();
|
||||
const customerName = customer?.display_name
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email?.split('@')[0]
|
||||
|| '';
|
||||
// Sibling audit certificate (timestamps + IPs + hashes). Best-effort:
|
||||
// missing certificate doesn't block the email — the stamped contract
|
||||
// alone is the primary attachment.
|
||||
const auditCertPath = await persistAuditCertificate(refetched);
|
||||
|
||||
const attachments = [{
|
||||
filename: `${refetched.contract_number}-signed.pdf`,
|
||||
contentPath: attachmentPath,
|
||||
contentType: 'application/pdf',
|
||||
}];
|
||||
if (auditCertPath) {
|
||||
attachments.push({
|
||||
filename: `${refetched.contract_number}-audit.pdf`,
|
||||
contentPath: auditCertPath,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
}
|
||||
|
||||
if (customer?.email) {
|
||||
await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', {
|
||||
contract_number: refetched.contract_number,
|
||||
customer_name: customerName,
|
||||
title: refetched.title || '',
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
const adminEmail = profile.email || adminRow?.email;
|
||||
if (adminEmail && adminEmail !== customer?.email) {
|
||||
await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', {
|
||||
contract_number: refetched.contract_number,
|
||||
customer_name: profile.company_name || adminRow?.first_name || 'Team',
|
||||
title: refetched.title || '',
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('contract_resent_signed', { contractId }, null, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
|
||||
return { signedPdfPath: attachmentPath, resent: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery helper: admin re-stamps signatures (customer and/or admin)
|
||||
* on a contract whose signature_path columns are null/broken because
|
||||
* the original sign happened before the canvas worked correctly.
|
||||
*
|
||||
* The admin draws BOTH signatures on the detail page — the customer's
|
||||
* signature is admin-attested in this flow (the customer already
|
||||
* agreed via the original sign; this just makes the PDF show
|
||||
* something). Original signed_by_*_at + signed_*_name + signed_*_ip
|
||||
* stay untouched; only the *_signature_path columns + the rendered
|
||||
* PDF get refreshed.
|
||||
*
|
||||
* Available on contracts in status:
|
||||
* signed_by_customer (re-stamp customer, optionally admin too)
|
||||
* signed_by_admin (re-stamp admin, optionally customer too)
|
||||
* fully_signed (re-stamp either or both)
|
||||
*/
|
||||
async function restampSignatures(contractId, { customerSignatureDataUrl, adminSignatureDataUrl }, adminId) {
|
||||
const contract = await db('contracts').where({ id: contractId }).first();
|
||||
if (!contract) throw new AppError('Contract not found', 404);
|
||||
if (!['signed_by_customer', 'signed_by_admin', 'fully_signed'].includes(contract.status)) {
|
||||
throw new AppError(
|
||||
`Cannot re-stamp signatures on a contract in status '${contract.status}'.`,
|
||||
409, 'WRONG_STATUS',
|
||||
);
|
||||
}
|
||||
if (!customerSignatureDataUrl && !adminSignatureDataUrl) {
|
||||
throw new AppError('At least one signature data URL must be provided.', 400, 'NO_SIGNATURE');
|
||||
}
|
||||
|
||||
const updates = { updated_at: new Date() };
|
||||
if (customerSignatureDataUrl) {
|
||||
updates.signed_customer_signature_path = await persistSignatureImage(contract, 'customer', customerSignatureDataUrl);
|
||||
}
|
||||
if (adminSignatureDataUrl) {
|
||||
updates.signed_admin_signature_path = await persistSignatureImage(contract, 'admin', adminSignatureDataUrl);
|
||||
}
|
||||
await db('contracts').where({ id: contract.id }).update(updates);
|
||||
|
||||
// Re-stamp signature images onto the immutable unsigned pdf_path
|
||||
// using pdf-lib (NOT a full re-render). This is the recovery path
|
||||
// for contracts where signature images existed on disk but the
|
||||
// earlier re-render approach failed silently and left signed_pdf_path
|
||||
// NULL or pointing at a stale file. We always rebuild the stamp from
|
||||
// pdf_path (the as-sent bytes) so the result is reproducible from
|
||||
// the audit record.
|
||||
//
|
||||
// Wet-signed PDF uploads remain authoritative — if signed_pdf_path
|
||||
// already points at an uploaded PDF we still produce a stamped copy
|
||||
// on disk for the audit trail, but signed_pdf_path is not updated.
|
||||
const refreshed = await getContractById(contract.id);
|
||||
if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) {
|
||||
throw new AppError(
|
||||
`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`,
|
||||
500, 'UNSIGNED_PDF_MISSING',
|
||||
);
|
||||
}
|
||||
const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path);
|
||||
const stamps = buildSignatureStamps(refreshed.contract);
|
||||
const { buffer: stampedBuffer, sha256: signedSha256 } =
|
||||
await pdfStampService.stampSignatures(originalBuffer, stamps);
|
||||
const { filePath: signedPath } = await persistContractPdf(refreshed.contract, stampedBuffer,
|
||||
contract.status === 'fully_signed' ? 'fully-signed' : 'partially-signed');
|
||||
|
||||
// Migration 135 — read the discriminator column. Fall back to the
|
||||
// historical substring rule only when the column is absent (un-
|
||||
// migrated install) so we never accidentally overwrite a wet upload.
|
||||
const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload');
|
||||
const isWetSignedUpload = hasWetFlagColumn
|
||||
? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1)
|
||||
: !!(contract.signed_pdf_path
|
||||
&& contract.signed_pdf_path.includes('uploads/contracts/signed'));
|
||||
if (!isWetSignedUpload) {
|
||||
const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256');
|
||||
const updates = {
|
||||
signed_pdf_path: signedPath,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256;
|
||||
// Migration 136 — restamp is a recovery path; clear the marker.
|
||||
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
|
||||
updates.signed_pdf_render_failed_at = null;
|
||||
updates.signed_pdf_render_error = null;
|
||||
}
|
||||
await db('contracts').where({ id: contract.id }).update(updates);
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('contract_signatures_restamped', {
|
||||
contractId,
|
||||
stamped: {
|
||||
customer: !!customerSignatureDataUrl,
|
||||
admin: !!adminSignatureDataUrl,
|
||||
},
|
||||
}, null, await adminActor(adminId));
|
||||
} catch (_) { /* logging is best-effort */ }
|
||||
|
||||
return {
|
||||
signedPdfPath: isWetSignedUpload ? contract.signed_pdf_path : signedPath,
|
||||
stamped: {
|
||||
customer: !!customerSignatureDataUrl,
|
||||
admin: !!adminSignatureDataUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the chronological audit trail for a contract from activity_logs.
|
||||
* Matches every `contract_*` activity_type where metadata.contractId
|
||||
* equals this contract's id. Ordered oldest → newest so the UI can
|
||||
* render a vertical timeline. Read-only; used by the admin detail
|
||||
* page's AuditTrailCard.
|
||||
*/
|
||||
async function getAuditTrail(contractId) {
|
||||
if (!(await db.schema.hasTable('activity_logs'))) return [];
|
||||
// Push the metadata.contractId filter into SQL instead of fetching
|
||||
// every contract_* row and filtering in JS. The previous shape
|
||||
// scanned the entire history every time the detail page loaded —
|
||||
// O(rows-since-CRM-launch) per request. Both Postgres and SQLite
|
||||
// store metadata as a JSON-encoded string here, so we match on
|
||||
// a literal substring that covers either compact or whitespaced
|
||||
// JSON encodings — `"contractId":<n>` or `"contractId": <n>` —
|
||||
// bounded by the activity_type prefix so the search hits the
|
||||
// contract_* slice of the index.
|
||||
//
|
||||
// The substring patterns intentionally don't anchor on word
|
||||
// boundaries; activity_logs.metadata never contains a contractId
|
||||
// key collision with another id-shaped value because logActivity
|
||||
// serialises only what callers pass.
|
||||
const id = Number(contractId);
|
||||
if (!Number.isFinite(id)) return [];
|
||||
const rows = await db('activity_logs')
|
||||
.where('activity_type', 'like', 'contract_%')
|
||||
.andWhere(function () {
|
||||
this.where('metadata', 'like', `%"contractId":${id}%`)
|
||||
.orWhere('metadata', 'like', `%"contractId": ${id}%`);
|
||||
})
|
||||
.orderBy('created_at', 'asc')
|
||||
.select('id', 'activity_type', 'actor_type', 'actor_id', 'actor_name', 'metadata', 'created_at');
|
||||
|
||||
return rows.map((r) => {
|
||||
let meta = r.metadata;
|
||||
if (typeof meta === 'string') {
|
||||
try { meta = JSON.parse(meta); } catch { meta = {}; }
|
||||
}
|
||||
return { ...r, metadata: meta || {} };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-hash the two on-disk PDFs and compare against the stored hashes
|
||||
* (pdf_sha256 / signed_pdf_sha256 from migration 131). Lets the admin
|
||||
* confirm that backups, manual moves, or storage corruption haven't
|
||||
* silently altered the issued document.
|
||||
*
|
||||
* Each leg of the response carries:
|
||||
* - `path`: the stored path string (so the UI can show what was
|
||||
* checked even when it's missing)
|
||||
* - `present`: file exists on disk
|
||||
* - `expected`: the SHA-256 column value (null if never persisted)
|
||||
* - `actual`: the freshly-computed hash, or null when file missing
|
||||
* - `match`: true iff both hashes exist AND they're equal
|
||||
*
|
||||
* The customer already has both expected hashes via the audit
|
||||
* certificate the signing flow ships as a second email attachment, so
|
||||
* they can verify independently with `shasum -a 256`. This endpoint
|
||||
* is the admin-side equivalent — single click instead of dropping to
|
||||
* a shell.
|
||||
*/
|
||||
async function verifyIntegrity(id) {
|
||||
const contract = await db('contracts')
|
||||
.where({ id })
|
||||
.select('id', 'pdf_path', 'pdf_sha256', 'signed_pdf_path', 'signed_pdf_sha256')
|
||||
.first();
|
||||
if (!contract) throw new AppError('Contract not found', 404);
|
||||
|
||||
const checkLeg = (filePath, expected) => {
|
||||
const present = !!filePath && fs.existsSync(filePath);
|
||||
const actual = present ? sha256OfFile(filePath) : null;
|
||||
return {
|
||||
path: filePath || null,
|
||||
present,
|
||||
expected: expected || null,
|
||||
actual,
|
||||
match: !!(expected && actual && expected === actual),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
unsigned: checkLeg(contract.pdf_path, contract.pdf_sha256),
|
||||
signed: checkLeg(contract.signed_pdf_path, contract.signed_pdf_sha256),
|
||||
};
|
||||
}
|
||||
module.exports = {
|
||||
recordCustomerSignature,
|
||||
recordAdminCountersignature,
|
||||
attachSignedPdfUpload,
|
||||
rerenderAndResend,
|
||||
restampSignatures,
|
||||
getAuditTrail,
|
||||
verifyIntegrity,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Database Backup Service Usage Examples
|
||||
*
|
||||
* This service provides comprehensive database backup functionality
|
||||
* with support for both SQLite and PostgreSQL databases.
|
||||
*/
|
||||
|
||||
const { databaseBackupService } = require('./databaseBackup');
|
||||
|
||||
// Example 1: Manual backup with default settings
|
||||
async function manualBackup() {
|
||||
try {
|
||||
const result = await databaseBackupService.backup();
|
||||
console.log('Backup completed:', result);
|
||||
// Result includes: path, size, duration, checksum, compressionRatio
|
||||
} catch (error) {
|
||||
console.error('Backup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 2: Backup with custom options
|
||||
async function customBackup() {
|
||||
try {
|
||||
const result = await databaseBackupService.backup({
|
||||
destinationPath: '/custom/backup/path',
|
||||
compress: true, // Enable gzip compression
|
||||
validateIntegrity: true, // Validate backup after creation
|
||||
includeChecksums: true, // Calculate table checksums
|
||||
noTransaction: false // Use transaction for consistency (PostgreSQL)
|
||||
});
|
||||
console.log('Custom backup completed:', result);
|
||||
} catch (error) {
|
||||
console.error('Backup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 3: Check backup progress (useful for long-running backups)
|
||||
async function backupWithProgress() {
|
||||
// Start backup asynchronously
|
||||
const backupPromise = databaseBackupService.backup();
|
||||
|
||||
// Poll for progress
|
||||
const progressInterval = setInterval(() => {
|
||||
const progress = databaseBackupService.getProgress();
|
||||
if (progress) {
|
||||
console.log(`Progress: ${progress.message}`, progress.details);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const result = await backupPromise;
|
||||
clearInterval(progressInterval);
|
||||
console.log('Backup completed:', result);
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval);
|
||||
console.error('Backup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 4: Get backup history
|
||||
async function getBackupHistory() {
|
||||
const history = await databaseBackupService.getBackupHistory(10);
|
||||
|
||||
history.forEach(backup => {
|
||||
console.log(`Backup ${backup.id}:`);
|
||||
console.log(` Started: ${backup.started_at}`);
|
||||
console.log(` Status: ${backup.status}`);
|
||||
console.log(` Size: ${(backup.file_size_bytes / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Duration: ${backup.duration_seconds}s`);
|
||||
});
|
||||
}
|
||||
|
||||
// Example 5: Clean up old backups
|
||||
async function cleanupBackups() {
|
||||
// Delete backups older than 30 days
|
||||
await databaseBackupService.cleanupOldBackups(30);
|
||||
console.log('Old backups cleaned up');
|
||||
}
|
||||
|
||||
// Example 6: Get table checksums (useful for monitoring changes)
|
||||
async function getTableChecksums() {
|
||||
const checksums = await databaseBackupService.getTableChecksums();
|
||||
|
||||
console.log('Table Checksums:');
|
||||
Object.entries(checksums).forEach(([table, info]) => {
|
||||
console.log(` ${table}: ${info.rowCount} rows, checksum: ${info.checksum}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Example 7: Using the scheduled backup service
|
||||
const { startScheduledBackups, stopScheduledBackups } = require('./databaseBackup');
|
||||
|
||||
async function setupScheduledBackups() {
|
||||
// Start scheduled backups (reads schedule from database config)
|
||||
await startScheduledBackups();
|
||||
console.log('Scheduled backups started');
|
||||
|
||||
// Later, if needed, stop scheduled backups
|
||||
// stopScheduledBackups();
|
||||
}
|
||||
|
||||
// Example 8: Admin API endpoints available
|
||||
/*
|
||||
GET /api/admin/database-backup/status - Get backup status and config
|
||||
PUT /api/admin/database-backup/config - Update backup configuration
|
||||
POST /api/admin/database-backup/backup - Trigger manual backup
|
||||
GET /api/admin/database-backup/progress - Get current backup progress
|
||||
GET /api/admin/database-backup/history - Get backup history with pagination
|
||||
DELETE /api/admin/database-backup/cleanup - Delete old backup files
|
||||
POST /api/admin/database-backup/test - Test backup configuration
|
||||
GET /api/admin/database-backup/checksums - Get current table checksums
|
||||
*/
|
||||
|
||||
// Example 9: Configuration options stored in database
|
||||
/*
|
||||
database_backup_enabled: boolean - Enable/disable scheduled backups
|
||||
database_backup_schedule: string - Cron schedule (default: '0 3 * * *')
|
||||
database_backup_destination_path: string - Where to store backups
|
||||
database_backup_compress: boolean - Enable gzip compression
|
||||
database_backup_validate_integrity: boolean - Validate after backup
|
||||
database_backup_include_checksums: boolean - Calculate table checksums
|
||||
database_backup_retention_days: number - Days to keep old backups
|
||||
database_backup_email_on_failure: boolean - Send email on failure
|
||||
database_backup_email_on_success: boolean - Send email on success
|
||||
*/
|
||||
|
||||
// Example 10: Production considerations
|
||||
/*
|
||||
1. Ensure destination path has sufficient space
|
||||
2. For large databases, backups may take significant time
|
||||
3. PostgreSQL backups use single-transaction mode by default
|
||||
4. Compression typically reduces size by 70-90%
|
||||
5. Schedule backups during low-traffic periods
|
||||
6. Monitor backup history for failures
|
||||
7. Test restore procedures regularly
|
||||
8. Consider replication for real-time redundancy
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
manualBackup,
|
||||
customBackup,
|
||||
backupWithProgress,
|
||||
getBackupHistory,
|
||||
cleanupBackups,
|
||||
getTableChecksums,
|
||||
setupScheduledBackups
|
||||
};
|
||||
@@ -1,582 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { cleanNetMinor } = require('../../utils/invoiceRounding');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const businessProfileService = require('../businessProfileService');
|
||||
const { ensureInt, ensureNumber } = require('../../utils/numericHelpers');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const { computeDueDate, computeScheduledSendAt, ensureCustomerCanBill, getHierarchyHelpers, nextInvoiceNumber, resolveDealUuid, resolveNetDays, snapToNextBillingCycle } = require('./helpers');
|
||||
const { appendToMonthlyDraft } = require('./drafts');
|
||||
|
||||
|
||||
/**
|
||||
* Create one invoice. Returns id. Used both manually (admin creates a
|
||||
* standalone invoice) and by scheduleInvoicesForEvent (one per installment).
|
||||
*/
|
||||
async function createInvoice(payload, adminId, trx = db) {
|
||||
const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first();
|
||||
ensureCustomerCanBill(customer);
|
||||
|
||||
// PR #603 review follow-up #1 — when an invoice is attached to an event,
|
||||
// make sure that event actually belongs to the chosen customer. Without
|
||||
// this, a typo'd/copy-pasted eventId silently links the invoice to an
|
||||
// unrelated event, producing misleading reporting links. Only enforced
|
||||
// when the event HAS customer assignments (an event with none — e.g. a
|
||||
// legacy import — is allowed through, since we can't prove a mismatch).
|
||||
if (payload.eventId && await trx.schema.hasTable('event_customer_assignments')) {
|
||||
const assignments = await trx('event_customer_assignments')
|
||||
.where({ event_id: payload.eventId })
|
||||
.select('customer_account_id');
|
||||
if (assignments.length > 0 &&
|
||||
!assignments.some(a => a.customer_account_id === payload.customerAccountId)) {
|
||||
throw new AppError('The selected event is not assigned to this customer', 422, 'EVENT_CUSTOMER_MISMATCH');
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulator intercept (migration 128). For customers in
|
||||
// billing_cadence='monthly' OR 'manual' mode every createInvoice call
|
||||
// APPENDS line items onto a single running draft instead of minting a
|
||||
// fresh invoice. Admin sees the editor flow exactly as before; the
|
||||
// returned id is the draft's id so the UI can redirect to the
|
||||
// accumulator. The two modes differ only in WHEN the draft ships:
|
||||
// 'monthly' auto-flushes on the cadence day (scheduler), 'manual'
|
||||
// never auto-flushes (no period_end) and ships only via the admin
|
||||
// "Trigger invoice now" gesture. `_skipMonthlyRouting` is the escape
|
||||
// hatch used by internal helpers that need to mint a non-draft row
|
||||
// (e.g. the accumulator itself, or future test fixtures).
|
||||
if ((customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual')
|
||||
&& !payload._skipMonthlyRouting) {
|
||||
const draft = await appendToMonthlyDraft(payload, customer, adminId, trx);
|
||||
return { invoiceIds: draft?.id ? [draft.id] : [] };
|
||||
}
|
||||
|
||||
const profile = (await businessProfileService.getProfile()).profile;
|
||||
const currency = (payload.currency || profile?.default_currency || 'CHF').toUpperCase();
|
||||
const language = payload.language || customer.preferred_language || profile?.default_locale || 'de';
|
||||
|
||||
// Sequence number is claimed BELOW the installment auto-route so a
|
||||
// multi-installment save doesn't waste a number. When installments
|
||||
// are present, spawnInstallmentInvoices claims one number per
|
||||
// sibling and we never reach the single-row insert that would have
|
||||
// used `invoiceNumber` here.
|
||||
const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10);
|
||||
const scheduledSendAt = payload.scheduledSendAt ? new Date(payload.scheduledSendAt) : null;
|
||||
// Resolve net_days BEFORE computing the due date so Net 60 / 90
|
||||
// selections actually push the due date out. resolveNetDays honors
|
||||
// the split picker FK the editor sends, the legacy single FK, and
|
||||
// the crm_payment_default_net_days setting (see helper). The clock
|
||||
// starts on the SEND date when the invoice is scheduled, otherwise
|
||||
// the issue date — so a future send pushes the due date out too.
|
||||
const resolvedNetDays = await resolveNetDays(payload, trx);
|
||||
const dueDate = payload.dueDate || computeDueDate(scheduledSendAt || new Date(issueDate), resolvedNetDays)
|
||||
.toISOString().slice(0, 10);
|
||||
|
||||
// Re-compute totals from line items. Migration 119 — items with a
|
||||
// non-null `parent_position` are sub-items and their line totals do
|
||||
// NOT roll into net directly. Parent totals AUTO-RESOLVE from
|
||||
// priced sub-items: if any sub-item under a parent has unit_price > 0,
|
||||
// the parent's effective line_total_minor becomes the sum of those
|
||||
// sub-items, and the parent's own stored unit_price is ignored.
|
||||
// Mental model matches the editor — pricing on sub-items implies
|
||||
// "parent is a header, total derives from what's under it".
|
||||
const lineItems = Array.isArray(payload.lineItems) ? payload.lineItems : [];
|
||||
const items = lineItems.map((li, idx) => {
|
||||
const qty = ensureNumber(li.quantity, 1);
|
||||
const unit = ensureInt(li.unit_price_minor);
|
||||
const discount = ensureNumber(li.discount_percent, 0);
|
||||
const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100));
|
||||
const isSubItem = li.parent_position != null && li.parent_position !== '';
|
||||
return {
|
||||
position: ensureInt(li.position) || (idx + 1),
|
||||
quantity: qty,
|
||||
description: String(li.description || ''),
|
||||
unit_price_minor: unit,
|
||||
discount_percent: discount,
|
||||
line_total_minor: lineTotal,
|
||||
parent_position: isSubItem ? ensureInt(li.parent_position) : null,
|
||||
details_text: li.details_text || null,
|
||||
};
|
||||
});
|
||||
// Apply the migration-119 hierarchy resolver: rewrites parent
|
||||
// line_total_minor to sum-of-priced-sub-items where applicable.
|
||||
// Net is then summed across top-level (resolved) items.
|
||||
const { resolveParentTotalsFromSubItems } = getHierarchyHelpers();
|
||||
resolveParentTotalsFromSubItems(items);
|
||||
let netMinor = 0;
|
||||
for (const li of items) {
|
||||
if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor);
|
||||
}
|
||||
// Optional sub-cent reconciliation (crm_invoice_round_total). When on,
|
||||
// store the full-precision net rounded ONCE so the total matches
|
||||
// qty × unit arithmetic; the per-line rounding drift is surfaced as a
|
||||
// "Rundung" row at render time (storedNet − Σ line totals). Off by
|
||||
// default ⇒ net stays the sum of rounded lines, unchanged behaviour.
|
||||
const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true;
|
||||
if (roundTotal) {
|
||||
netMinor = cleanNetMinor(items, { parentKey: 'parent_position', positionKey: 'position' });
|
||||
}
|
||||
const vatRate = ensureNumber(payload.vatRate, 0);
|
||||
const vatMinor = Math.round(netMinor * vatRate / 100);
|
||||
const shippingMinor = ensureInt(payload.shippingAmountMinor);
|
||||
const totalMinor = netMinor + vatMinor + shippingMinor;
|
||||
|
||||
// Negative line items (Rabatt) are allowed, but the resulting
|
||||
// invoice total must not go below zero. Credit notes belong in
|
||||
// the Storno path (createStorno), which mints a separate
|
||||
// kind='storno' record with cancels_invoice_id set.
|
||||
if (totalMinor < 0) {
|
||||
throw new AppError(
|
||||
'Invoice total cannot be negative. To issue a credit note, cancel the original invoice with Storno.',
|
||||
400,
|
||||
'INVOICE_TOTAL_NEGATIVE',
|
||||
);
|
||||
}
|
||||
|
||||
const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId);
|
||||
|
||||
// Snapshot the selected payment-term template (net days / Skonto /
|
||||
// installment plan) onto the invoice itself. Mirrors how the quote
|
||||
// editor handles this — once snapshotted, edits to the template
|
||||
// don't retroactively change rendered invoices. Migration 113.
|
||||
let paymentTermTemplateId = null;
|
||||
let paymentTermSnapshot = null;
|
||||
let paymentNetDaysTemplateId = null;
|
||||
let paymentTimingTemplateId = null;
|
||||
// Migration 124 — prefer the two split FKs. Compose a snapshot from
|
||||
// them in the same shape pdfService + scheduler already consume.
|
||||
// Fall back to the legacy single FK when the caller still uses it.
|
||||
if (payload.paymentNetDaysTemplateId && payload.paymentTimingTemplateId) {
|
||||
const [netDays, timing] = await Promise.all([
|
||||
trx('payment_net_days_templates').where({ id: payload.paymentNetDaysTemplateId }).first(),
|
||||
trx('payment_timing_templates').where({ id: payload.paymentTimingTemplateId }).first(),
|
||||
]);
|
||||
if (netDays && timing) {
|
||||
paymentNetDaysTemplateId = netDays.id;
|
||||
paymentTimingTemplateId = timing.id;
|
||||
paymentTermSnapshot = JSON.stringify({
|
||||
description: timing.description || netDays.description || null,
|
||||
net_days: netDays.net_days,
|
||||
skonto_percent: netDays.skonto_percent,
|
||||
skonto_within_days: netDays.skonto_within_days,
|
||||
installments: typeof timing.installments === 'string'
|
||||
? (() => { try { return JSON.parse(timing.installments); } catch { return null; } })()
|
||||
: timing.installments || null,
|
||||
});
|
||||
}
|
||||
} else if (payload.paymentTermTemplateId) {
|
||||
const tpl = await trx('payment_term_templates')
|
||||
.where({ id: payload.paymentTermTemplateId }).first();
|
||||
if (tpl) {
|
||||
paymentTermTemplateId = tpl.id;
|
||||
paymentTermSnapshot = JSON.stringify({
|
||||
description: tpl.description || null,
|
||||
net_days: tpl.net_days,
|
||||
skonto_percent: tpl.skonto_percent,
|
||||
skonto_within_days: tpl.skonto_within_days,
|
||||
installments: typeof tpl.installments === 'string'
|
||||
? (() => { try { return JSON.parse(tpl.installments); } catch { return null; } })()
|
||||
: tpl.installments || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-installment auto-route. Priority:
|
||||
// 1. payload.installments (explicit override from the ad-hoc
|
||||
// editor panel — wins over any saved template)
|
||||
// 2. snapshot.installments (loaded from the picked payment-timing
|
||||
// template above)
|
||||
// If either yields ≥2 entries we delegate to spawnInstallmentInvoices
|
||||
// (the same loop used by quote→invoice conversion) and return the
|
||||
// array of created IDs. Single-installment plans fall through to
|
||||
// the single-row insert below.
|
||||
let installmentsForSpawn = null;
|
||||
if (Array.isArray(payload.installments) && payload.installments.length > 1) {
|
||||
installmentsForSpawn = payload.installments;
|
||||
} else if (paymentTermSnapshot) {
|
||||
const parsedSnap = typeof paymentTermSnapshot === 'string'
|
||||
? (() => { try { return JSON.parse(paymentTermSnapshot); } catch { return null; } })()
|
||||
: paymentTermSnapshot;
|
||||
if (parsedSnap && Array.isArray(parsedSnap.installments) && parsedSnap.installments.length > 1) {
|
||||
installmentsForSpawn = parsedSnap.installments;
|
||||
}
|
||||
}
|
||||
if (installmentsForSpawn) {
|
||||
return await spawnInstallmentInvoices({
|
||||
trx,
|
||||
eventId: payload.eventId || null,
|
||||
quoteId: payload.sourceQuoteId || null,
|
||||
customer,
|
||||
currency,
|
||||
language,
|
||||
lineItems: items,
|
||||
totals: {
|
||||
net: netMinor,
|
||||
vatRate,
|
||||
vat: vatMinor,
|
||||
shipping: shippingMinor,
|
||||
total: totalMinor,
|
||||
},
|
||||
installments: installmentsForSpawn,
|
||||
eventDate: payload.eventDate || null,
|
||||
adminId,
|
||||
ccPdfEmail: payload.ccPdfEmail || null,
|
||||
netDays: resolvedNetDays,
|
||||
eventName: payload.eventName || null,
|
||||
eventTimeStart: payload.eventTimeStart || null,
|
||||
eventTimeEnd: payload.eventTimeEnd || null,
|
||||
paymentNetDaysTemplateId,
|
||||
paymentTimingTemplateId,
|
||||
paymentTermSnapshot,
|
||||
dealUuid: await resolveDealUuid(trx, payload),
|
||||
});
|
||||
}
|
||||
|
||||
// Claim the sequence number HERE — after the installment auto-route
|
||||
// has been ruled out. Previously this was at the top of the function
|
||||
// which leaked one number per multi-installment save (the spawner
|
||||
// claims its own numbers and never used this one).
|
||||
// Pass trx so the sequence claim joins our outer transaction —
|
||||
// SQLite deadlocks otherwise (1-connection default).
|
||||
const invoiceNumber = await nextInvoiceNumber(trx);
|
||||
const row = {
|
||||
invoice_number: invoiceNumber,
|
||||
customer_account_id: payload.customerAccountId,
|
||||
source_quote_id: payload.sourceQuoteId || null,
|
||||
event_id: payload.eventId || null,
|
||||
// Inline event snapshot (migration 123). Mirrors quotes — the
|
||||
// snapshot survives an event rename so an archived invoice keeps
|
||||
// its original event label for accounting / audit. Optional;
|
||||
// standalone invoices created without an event will have these
|
||||
// as null and the renderer simply omits the for-clause.
|
||||
event_name: payload.eventName || null,
|
||||
event_date: payload.eventDate || null,
|
||||
event_time_start: payload.eventTimeStart || null,
|
||||
event_time_end: payload.eventTimeEnd || null,
|
||||
language,
|
||||
currency,
|
||||
issue_date: issueDate,
|
||||
due_date: dueDate,
|
||||
installment_index: ensureInt(payload.installmentIndex),
|
||||
installment_total: ensureInt(payload.installmentTotal) || 1,
|
||||
installment_label: payload.installmentLabel || null,
|
||||
installment_trigger: payload.installmentTrigger || null,
|
||||
status: scheduledSendAt && scheduledSendAt.getTime() > Date.now() ? 'scheduled' : (payload.sendNow ? 'scheduled' : 'scheduled'),
|
||||
scheduled_send_at: scheduledSendAt,
|
||||
net_amount_minor: netMinor,
|
||||
vat_rate: vatRate,
|
||||
vat_amount_minor: vatMinor,
|
||||
shipping_amount_minor: shippingMinor,
|
||||
total_amount_minor: totalMinor,
|
||||
cc_pdf_email: payload.ccPdfEmail || null,
|
||||
business_bank_account_id: bank?.id || null,
|
||||
qr_format: payload.qrFormat || null,
|
||||
payment_term_template_id: paymentTermTemplateId,
|
||||
payment_net_days_template_id: paymentNetDaysTemplateId,
|
||||
payment_timing_template_id: paymentTimingTemplateId,
|
||||
payment_term_snapshot: paymentTermSnapshot,
|
||||
// Per-invoice Skonto opt-out (migration 126). Defaults to false
|
||||
// — invoice inherits the snapshot/global Skonto config unless
|
||||
// admin explicitly ticks "Disable Skonto" in the editor.
|
||||
skonto_disabled: Boolean(payload.skontoDisabled),
|
||||
// Migration 140 — deal_uuid lineage. Priority: explicit payload
|
||||
// (used by spawnInstallmentInvoices and Storno/reissue callers to
|
||||
// force a specific value), source quote, source contract,
|
||||
// otherwise fresh mint.
|
||||
deal_uuid: await resolveDealUuid(trx, payload),
|
||||
created_by_admin_id: adminId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
// Migration 130 — snapshot the chosen output VAT code (immutable; the
|
||||
// accounting export emits exactly this rather than re-deriving from the map).
|
||||
if (payload.vatCode !== undefined && await hasColumnCached('invoices', 'vat_code')) {
|
||||
row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
|
||||
}
|
||||
const inserted = await trx('invoices').insert(row).returning('id');
|
||||
const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
if (items.length > 0) {
|
||||
const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers();
|
||||
validateLineItemHierarchy(items);
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, items);
|
||||
}
|
||||
|
||||
try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`); } catch (_) {}
|
||||
return { invoiceIds: [invoiceId] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan-out helper. Creates one invoice row per installment with the
|
||||
* right `scheduled_send_at`, sequential invoice numbers, and per-
|
||||
* slice totals. Used by:
|
||||
*
|
||||
* - quoteService.convertToEvent / convertToInvoiceOnly — quote
|
||||
* conversion with multi-installment payment plans.
|
||||
* - createInvoice (this file) — when the standalone editor path
|
||||
* submits an installment array.
|
||||
*
|
||||
* Expects to be called inside an existing transaction.
|
||||
*
|
||||
* Returns `{ invoiceIds: number[] }` — ordered by installment_index
|
||||
* so callers can navigate to the first or report N IDs.
|
||||
*
|
||||
* The legacy export name `scheduleInvoicesForEvent` is preserved as
|
||||
* an alias for backward compatibility with quoteService callers; new
|
||||
* code should reach for the clearer `spawnInstallmentInvoices`.
|
||||
*/
|
||||
async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, currency, language,
|
||||
lineItems, totals, installments, eventDate, adminId,
|
||||
ccPdfEmail, netDays,
|
||||
eventName, eventTimeStart, eventTimeEnd,
|
||||
paymentNetDaysTemplateId, paymentTimingTemplateId,
|
||||
paymentTermSnapshot, dealUuid, hold = false }) {
|
||||
// Monthly-billing intercept (migration 128). Quote → invoice
|
||||
// conversion for a monthly-mode customer doesn't fan out N
|
||||
// installment invoices — the customer pays one consolidated bill
|
||||
// per period. Append the line items to the running draft (creating
|
||||
// it if needed) and return early. The installment / cadence math
|
||||
// below is bypassed; the quote's payment timing is irrelevant once
|
||||
// items flow into the monthly accumulator.
|
||||
if (customer && customer.billing_cadence === 'monthly') {
|
||||
const draft = await appendToMonthlyDraft({
|
||||
customerAccountId: customer.id,
|
||||
lineItems: (lineItems || []).map((li) => ({
|
||||
position: li.position,
|
||||
quantity: li.quantity,
|
||||
unit_price_minor: li.unit_price_minor,
|
||||
discount_percent: li.discount_percent,
|
||||
description: li.description,
|
||||
parent_position: li.parent_position,
|
||||
details_text: li.details_text,
|
||||
})),
|
||||
vatRate: totals?.vatRate,
|
||||
}, customer, adminId, trx);
|
||||
return { invoiceIds: draft?.id ? [draft.id] : [] };
|
||||
}
|
||||
|
||||
// netDays drives the due-date offset on every scheduled invoice
|
||||
// created here. Callers in quoteService pass the converting quote's
|
||||
// payment-term net_days so Net 60 / 90 templates flow through; when
|
||||
// absent we fall back to the crm_payment_default_net_days setting
|
||||
// (then 30) rather than silently using 30, matching createInvoice.
|
||||
const resolvedNetDays = ensureInt(netDays)
|
||||
|| ensureInt(await getAppSetting('crm_payment_default_net_days', null, trx || db))
|
||||
|| 30;
|
||||
const total = installments.length;
|
||||
const acceptanceTime = new Date();
|
||||
const invoiceIds = [];
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const inst = installments[i];
|
||||
const percent = ensureNumber(inst.percent, 0);
|
||||
if (percent <= 0) continue;
|
||||
|
||||
// Each installment carries its own slice of the totals. Round to
|
||||
// minor units; last installment absorbs rounding drift so the
|
||||
// total exactly equals the quote total.
|
||||
let netSlice, vatSlice, shippingSlice, totalSlice;
|
||||
if (i === total - 1) {
|
||||
// We computed everything so far; remaining slice closes the gap.
|
||||
const accNet = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.net) * ensureNumber(x.percent, 0) / 100), 0);
|
||||
const accVat = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.vat) * ensureNumber(x.percent, 0) / 100), 0);
|
||||
const accShipping = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.shipping) * ensureNumber(x.percent, 0) / 100), 0);
|
||||
const accTotal = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.total) * ensureNumber(x.percent, 0) / 100), 0);
|
||||
netSlice = ensureInt(totals.net) - accNet;
|
||||
vatSlice = ensureInt(totals.vat) - accVat;
|
||||
shippingSlice = ensureInt(totals.shipping) - accShipping;
|
||||
totalSlice = ensureInt(totals.total) - accTotal;
|
||||
} else {
|
||||
netSlice = Math.round(ensureInt(totals.net) * percent / 100);
|
||||
vatSlice = Math.round(ensureInt(totals.vat) * percent / 100);
|
||||
shippingSlice = Math.round(ensureInt(totals.shipping) * percent / 100);
|
||||
totalSlice = Math.round(ensureInt(totals.total) * percent / 100);
|
||||
}
|
||||
|
||||
let scheduledSendAt = computeScheduledSendAt(inst.trigger, inst.offset_days, eventDate, acceptanceTime);
|
||||
// Per-customer billing cadence override: monthly / quarterly
|
||||
// customers don't pay per-event — snap to the next period boundary.
|
||||
if (customer && customer.billing_cadence && customer.billing_cadence !== 'per_event') {
|
||||
scheduledSendAt = snapToNextBillingCycle(scheduledSendAt, customer.billing_cadence, customer.billing_cycle_day);
|
||||
}
|
||||
|
||||
// `after_delivery` invoices wait for the admin to confirm photos
|
||||
// have actually been delivered before they fire — we can't infer
|
||||
// that automatically from a date. Mark them `pending_delivery`
|
||||
// with no scheduled_send_at; the scheduler only picks rows in
|
||||
// status `scheduled`, so they sit idle until the admin clicks
|
||||
// "Release for delivery" on the invoice detail page.
|
||||
const isDeliveryTrigger = inst.trigger === 'after_delivery';
|
||||
// `hold` (workflow draft-seam): the booking flow's review gate + explicit
|
||||
// send_document IS the release, so a held invoice is always `scheduled`
|
||||
// (editable + sendable via sendInvoice) regardless of trigger — never
|
||||
// `pending_delivery`, which sendInvoice refuses. Without hold, an
|
||||
// after_delivery invoice stays `pending_delivery` as before.
|
||||
const rowStatus = (isDeliveryTrigger && !hold) ? 'pending_delivery' : 'scheduled';
|
||||
// Held invoices carry no scheduled_send_at so the scheduler never auto-sends
|
||||
// them — they wait for send_document. after_delivery rows are likewise null
|
||||
// (the scheduler can't infer a delivery date).
|
||||
const rowScheduledSendAt = (isDeliveryTrigger || hold) ? null : scheduledSendAt;
|
||||
|
||||
const invoiceNumber = await nextInvoiceNumber(trx);
|
||||
const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10);
|
||||
|
||||
const row = {
|
||||
invoice_number: invoiceNumber,
|
||||
customer_account_id: customer.id,
|
||||
source_quote_id: quoteId,
|
||||
event_id: eventId,
|
||||
// Inline event snapshot carried over from the source quote
|
||||
// (migration 123). Mirrors how event_date is already carried —
|
||||
// a converted invoice should keep the event reference even if
|
||||
// the linked event is later renamed or deleted.
|
||||
event_name: eventName || null,
|
||||
event_date: eventDate || null,
|
||||
event_time_start: eventTimeStart || null,
|
||||
event_time_end: eventTimeEnd || null,
|
||||
language,
|
||||
currency,
|
||||
issue_date: scheduledSendAt.toISOString().slice(0, 10),
|
||||
due_date: dueDate,
|
||||
installment_index: i,
|
||||
installment_total: total,
|
||||
installment_label: inst.label || `Installment ${i + 1}/${total}`,
|
||||
installment_trigger: inst.trigger,
|
||||
status: rowStatus,
|
||||
scheduled_send_at: rowScheduledSendAt,
|
||||
net_amount_minor: netSlice,
|
||||
vat_rate: ensureNumber(totals.vatRate, 0),
|
||||
vat_amount_minor: vatSlice,
|
||||
shipping_amount_minor: shippingSlice,
|
||||
total_amount_minor: totalSlice,
|
||||
cc_pdf_email: ccPdfEmail || null,
|
||||
// Migration 124 — carry the split payment-term FKs over from
|
||||
// the source quote so the converted invoice is editable (when
|
||||
// it eventually unlocks) with the same orthogonal split. The
|
||||
// snapshot itself is the legal record; the FKs are convenience.
|
||||
payment_net_days_template_id: paymentNetDaysTemplateId || null,
|
||||
payment_timing_template_id: paymentTimingTemplateId || null,
|
||||
payment_term_snapshot: paymentTermSnapshot
|
||||
? (typeof paymentTermSnapshot === 'string'
|
||||
? paymentTermSnapshot
|
||||
: JSON.stringify(paymentTermSnapshot))
|
||||
: null,
|
||||
// Migration 140 — every installment sibling shares one deal_uuid
|
||||
// (passed in from the converting caller, ultimately the source
|
||||
// quote's value). Defensive fallback to a fresh UUID if the
|
||||
// caller didn't pass one — shouldn't happen on a migrated
|
||||
// install but keeps the column non-null.
|
||||
deal_uuid: dealUuid || crypto.randomUUID(),
|
||||
created_by_admin_id: adminId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
const inserted = await trx('invoices').insert(row).returning('id');
|
||||
const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
// Line items: copy from the quote so the customer sees what they
|
||||
// actually agreed to, not a generic "Gesamtbetrag" placeholder.
|
||||
// Two modes:
|
||||
// - Single-installment (100%): clone every quote line item
|
||||
// verbatim. The invoice totals already match the quote's.
|
||||
// - Multi-installment (split payment): clone the quote lines
|
||||
// but mark the invoice with the installment context. We pro-
|
||||
// rate by inserting one extra line at the bottom that adjusts
|
||||
// to the installment slice — keeps the per-line description
|
||||
// visible while the total still equals the pro-rata amount.
|
||||
const sourceLines = Array.isArray(lineItems) ? lineItems : [];
|
||||
if (sourceLines.length === 0) {
|
||||
// Fallback for the (rare) case where the quote has no line
|
||||
// items — fall back to the legacy "Installment N/M" line so
|
||||
// we still produce a sensible invoice.
|
||||
await trx('invoice_line_items').insert({
|
||||
invoice_id: invoiceId,
|
||||
position: 1,
|
||||
quantity: 1,
|
||||
description: inst.label || `Installment ${i + 1}/${total}`,
|
||||
unit_price_minor: netSlice,
|
||||
discount_percent: 0,
|
||||
line_total_minor: netSlice,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
} else {
|
||||
// Clone each quote line as-is, preserving its original `position`
|
||||
// so the sub-item hierarchy carries over. Source lines already
|
||||
// have `parent_position` populated by getQuoteById's self-join,
|
||||
// so the same value reused on the new invoice points at the
|
||||
// correct (also-cloned) parent. insertLineItemsHierarchical
|
||||
// resolves position → new parent_line_item_id during the
|
||||
// two-phase insert. Migration 119.
|
||||
const cloned = sourceLines.map((li) => ({
|
||||
position: ensureInt(li.position),
|
||||
quantity: li.quantity,
|
||||
description: li.description,
|
||||
unit_price_minor: ensureInt(li.unit_price_minor),
|
||||
discount_percent: ensureNumber(li.discount_percent, 0),
|
||||
line_total_minor: ensureInt(li.line_total_minor),
|
||||
parent_position: li.parent_position == null ? null : ensureInt(li.parent_position),
|
||||
details_text: li.details_text || null,
|
||||
}));
|
||||
const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers();
|
||||
validateLineItemHierarchy(cloned);
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, cloned);
|
||||
|
||||
// For split payments add an explicit "Installment X/Y (Z%)"
|
||||
// adjustment line that reconciles the cloned line totals to
|
||||
// the actual invoice net (which is the pro-rata slice). The
|
||||
// line carries the difference as a negative if the slice is
|
||||
// less than the quote total (typical), or positive on the
|
||||
// final installment if rounding nudged the other way.
|
||||
//
|
||||
// The adjustment ONLY considers top-level cloned lines —
|
||||
// sub-items don't contribute to net so they can't appear in
|
||||
// the reconciliation sum.
|
||||
if (total > 1) {
|
||||
const clonedSum = cloned
|
||||
.filter((x) => x.parent_position == null)
|
||||
.reduce((s, x) => s + ensureInt(x.line_total_minor), 0);
|
||||
const adjustment = netSlice - clonedSum;
|
||||
if (adjustment !== 0) {
|
||||
const installmentLabel = inst.label || `Installment ${i + 1}/${total}`;
|
||||
const maxPosition = cloned.reduce((m, x) => Math.max(m, x.position), 0);
|
||||
await trx('invoice_line_items').insert({
|
||||
invoice_id: invoiceId,
|
||||
position: maxPosition + 1,
|
||||
quantity: 1,
|
||||
description: `${installmentLabel} (${percent}% — ${i + 1}/${total})`,
|
||||
unit_price_minor: adjustment,
|
||||
discount_percent: 0,
|
||||
line_total_minor: adjustment,
|
||||
parent_line_item_id: null,
|
||||
details_text: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass `trx` so the audit insert rides the transaction's connection —
|
||||
// logging via the global db here deadlocks the single-connection SQLite
|
||||
// pool (this runs unattended from the booking flow's prepare_invoice).
|
||||
await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt },
|
||||
eventId, `admin:${adminId}`, trx);
|
||||
} catch (_) {}
|
||||
invoiceIds.push(invoiceId);
|
||||
}
|
||||
return { invoiceIds };
|
||||
}
|
||||
|
||||
// Backward-compat alias — older callers reference this name.
|
||||
const scheduleInvoicesForEvent = spawnInstallmentInvoices;
|
||||
module.exports = {
|
||||
createInvoice,
|
||||
spawnInstallmentInvoices,
|
||||
scheduleInvoicesForEvent,
|
||||
};
|
||||
@@ -1,334 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const businessProfileService = require('../businessProfileService');
|
||||
const { ensureInt, ensureNumber } = require('../../utils/numericHelpers');
|
||||
const { computeMonthlyCadenceDate, getHierarchyHelpers, nextInvoiceNumber } = require('./helpers');
|
||||
|
||||
|
||||
/**
|
||||
* Find or create the running "monthly draft" invoice for a customer.
|
||||
* One draft per customer per current billing period (`monthly_period_end >= today`).
|
||||
* Subsequent saves through createInvoice for the same monthly-mode
|
||||
* customer append line items onto this draft instead of minting fresh
|
||||
* invoices.
|
||||
*
|
||||
* Returns `{ id, row }` for the draft so the caller can append items
|
||||
* + recompute totals without a second query.
|
||||
*
|
||||
* Period bounds:
|
||||
* start = first calendar day of the month that contains today
|
||||
* end = computeMonthlyCadenceDate(year, month, cycle_day) where
|
||||
* year/month are picked so that the resolved date is in the
|
||||
* future. If today is already PAST the cadence day for the
|
||||
* current month, the period rolls to next month — admin
|
||||
* authoring items after the cadence is "starting the next
|
||||
* bill", not "appending to one that already fired".
|
||||
*/
|
||||
async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Manual cadence has no billing cycle: the draft accumulates
|
||||
// indefinitely and ships ONLY via the admin "Trigger invoice now"
|
||||
// gesture, so it carries NO period_end. The scheduler's auto-flush
|
||||
// filter is `monthly_period_end <= today`, which a NULL period_end
|
||||
// can never satisfy — keeping manual drafts out of the cron path.
|
||||
const isManual = customer.billing_cadence === 'manual';
|
||||
|
||||
// Resolve period_end: prefer the cadence in the current month, but
|
||||
// if it has already passed, roll to next month so the new draft
|
||||
// gathers items toward the NEXT bill.
|
||||
const cycleDay = ensureInt(customer.billing_cycle_day) || 1;
|
||||
let target = computeMonthlyCadenceDate(today.getFullYear(), today.getMonth(), cycleDay);
|
||||
if (target.getTime() < today.getTime()) {
|
||||
const nextMonth = today.getMonth() + 1;
|
||||
target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay);
|
||||
}
|
||||
const periodStart = isManual ? null : new Date(target.getFullYear(), target.getMonth(), 1);
|
||||
const periodEnd = isManual ? null : target;
|
||||
// Placeholder issue/due date for the empty draft row — recomputed at
|
||||
// issuance time. Manual drafts have no period_end, so fall back to today.
|
||||
const placeholderDate = (periodEnd || today).toISOString().slice(0, 10);
|
||||
|
||||
// Look up any existing open draft for this customer. We deliberately
|
||||
// do NOT filter by monthly_period_end here — only one draft can be
|
||||
// open per customer at a time (enforced by the partial unique index
|
||||
// created in migration 133). If the scheduler hasn't yet promoted an
|
||||
// expired draft, it's still the canonical landing spot for any new
|
||||
// items the admin queues; promoting it is the scheduler's job, not
|
||||
// ours. forUpdate() locks the row on Postgres so concurrent appenders
|
||||
// serialize on totals recomputation; SQLite's transaction write-lock
|
||||
// gives us the same guarantee implicitly.
|
||||
const existing = await trx('invoices')
|
||||
.where({
|
||||
customer_account_id: customer.id,
|
||||
is_monthly_draft: true,
|
||||
})
|
||||
.orderBy('id', 'desc')
|
||||
.forUpdate()
|
||||
.first();
|
||||
if (existing) {
|
||||
return { id: existing.id, row: existing, created: false };
|
||||
}
|
||||
|
||||
// None yet — mint one with zero line items + zero totals. The
|
||||
// caller appends items + recomputes immediately after.
|
||||
const profile = (await businessProfileService.getProfile()).profile;
|
||||
const currency = (customer.preferred_currency || profile?.default_currency || 'CHF').toUpperCase();
|
||||
const language = customer.preferred_language || profile?.default_locale || 'de';
|
||||
const invoiceNumber = await nextInvoiceNumber(trx);
|
||||
const bank = await businessProfileService.resolveBankAccountForCurrency(currency, null);
|
||||
|
||||
const row = {
|
||||
invoice_number: invoiceNumber,
|
||||
customer_account_id: customer.id,
|
||||
source_quote_id: null,
|
||||
event_id: null,
|
||||
language,
|
||||
currency,
|
||||
issue_date: placeholderDate,
|
||||
due_date: placeholderDate, // recomputed at issuance time
|
||||
installment_index: 0,
|
||||
installment_total: 1,
|
||||
status: 'scheduled',
|
||||
scheduled_send_at: null, // monthly pass sets this on cadence day
|
||||
net_amount_minor: 0,
|
||||
vat_rate: 0,
|
||||
vat_amount_minor: 0,
|
||||
shipping_amount_minor: 0,
|
||||
total_amount_minor: 0,
|
||||
business_bank_account_id: bank?.id || null,
|
||||
qr_format: null,
|
||||
is_monthly_draft: true,
|
||||
monthly_period_start: periodStart ? periodStart.toISOString().slice(0, 10) : null,
|
||||
monthly_period_end: periodEnd ? periodEnd.toISOString().slice(0, 10) : null,
|
||||
// Migration 140 — each monthly-draft cycle is its own deal (no
|
||||
// quote/contract chain). Fresh UUID at creation; subsequent line
|
||||
// appends just mutate this same row, so the uuid sticks.
|
||||
deal_uuid: crypto.randomUUID(),
|
||||
created_by_admin_id: adminId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
try {
|
||||
const inserted = await trx('invoices').insert(row).returning('id');
|
||||
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
return { id, row: { ...row, id }, created: true };
|
||||
} catch (err) {
|
||||
// Partial-unique-index violation: another transaction snuck a draft
|
||||
// in between our SELECT and INSERT. Re-SELECT the winner and return
|
||||
// it — concurrent callers converge on the same draft row instead
|
||||
// of double-billing the customer. The error string varies by
|
||||
// driver: Postgres → SQLSTATE 23505; better-sqlite3 → 'UNIQUE
|
||||
// constraint failed'; node-sqlite3 → 'SQLITE_CONSTRAINT'.
|
||||
const msg = String(err && err.message || '');
|
||||
const isUniqueViolation =
|
||||
err && err.code === '23505' ||
|
||||
/unique/i.test(msg) ||
|
||||
/sqlite_constraint/i.test(msg);
|
||||
if (!isUniqueViolation) throw err;
|
||||
const winner = await trx('invoices')
|
||||
.where({ customer_account_id: customer.id, is_monthly_draft: true })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
if (!winner) {
|
||||
// No row to return despite the unique-violation — this would
|
||||
// mean the winning transaction rolled back after we lost the
|
||||
// race. Surface the original error so the caller can retry.
|
||||
throw err;
|
||||
}
|
||||
return { id: winner.id, row: winner, created: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append line items from a `createInvoice`-shaped payload onto the
|
||||
* customer's running monthly-draft (migration 128). Used when the
|
||||
* customer is billing_cadence='monthly': the admin's editor save
|
||||
* lands here instead of minting a new invoice.
|
||||
*
|
||||
* Pulls the existing draft (or creates a fresh one for the current
|
||||
* period), appends the new line items continuing the position
|
||||
* sequence, recomputes totals across the merged set, and returns the
|
||||
* draft's id so the route layer can fetch + return it.
|
||||
*/
|
||||
async function appendToMonthlyDraft(payload, customer, adminId, trx) {
|
||||
const draft = await getOrCreateMonthlyDraft(customer, adminId, trx);
|
||||
|
||||
// Load existing line items so we can compute the next `position` and
|
||||
// re-sum totals across the merged set. The migration-119 hierarchy
|
||||
// helpers operate on the merged array so parent_position pointers
|
||||
// remain consistent.
|
||||
const existing = await trx('invoice_line_items')
|
||||
.where({ invoice_id: draft.id })
|
||||
.orderBy('position', 'asc');
|
||||
const nextPosition = existing.length
|
||||
? Math.max(...existing.map((li) => ensureInt(li.position))) + 1
|
||||
: 1;
|
||||
|
||||
const incoming = Array.isArray(payload.lineItems) ? payload.lineItems : [];
|
||||
const newItems = incoming.map((li, idx) => {
|
||||
const qty = ensureNumber(li.quantity, 1);
|
||||
const unit = ensureInt(li.unit_price_minor);
|
||||
const discount = ensureNumber(li.discount_percent, 0);
|
||||
const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100));
|
||||
const isSubItem = li.parent_position != null && li.parent_position !== '';
|
||||
return {
|
||||
position: nextPosition + idx,
|
||||
quantity: qty,
|
||||
description: String(li.description || ''),
|
||||
unit_price_minor: unit,
|
||||
discount_percent: discount,
|
||||
line_total_minor: lineTotal,
|
||||
parent_position: isSubItem ? ensureInt(li.parent_position) : null,
|
||||
details_text: li.details_text || null,
|
||||
};
|
||||
});
|
||||
|
||||
if (newItems.length > 0) {
|
||||
const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers();
|
||||
validateLineItemHierarchy(newItems);
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', draft.id, newItems);
|
||||
}
|
||||
|
||||
// Recompute totals across the entire draft so the running figures
|
||||
// shown on the customer-detail "Monthly queue" card stay accurate
|
||||
// as items accumulate. Mirrors createInvoice's totals path.
|
||||
const allItems = await trx('invoice_line_items')
|
||||
.where({ invoice_id: draft.id });
|
||||
let netMinor = 0;
|
||||
for (const li of allItems) {
|
||||
if (li.parent_line_item_id == null) netMinor += ensureInt(li.line_total_minor);
|
||||
}
|
||||
const vatRate = ensureNumber(payload.vatRate, draft.row.vat_rate || 0);
|
||||
const vatMinor = Math.round(netMinor * Number(vatRate) / 100);
|
||||
const shippingMinor = ensureInt(draft.row.shipping_amount_minor);
|
||||
const totalMinor = netMinor + vatMinor + shippingMinor;
|
||||
|
||||
await trx('invoices').where({ id: draft.id }).update({
|
||||
net_amount_minor: netMinor,
|
||||
vat_rate: vatRate,
|
||||
vat_amount_minor: vatMinor,
|
||||
total_amount_minor: totalMinor,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('monthly_billing_items_queued',
|
||||
{ invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
|
||||
return draft.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a single, fully-formed line item to the customer's running
|
||||
* monthly draft (migration 128 + 129). Used by customerHoursService
|
||||
* when an hour entry is logged for a monthly-mode customer — we want
|
||||
* the inserted `invoice_line_items.id` back so the entry can be
|
||||
* stamped with the cross-reference.
|
||||
*
|
||||
* `lineItem` is the shape consumed by appendToMonthlyDraft's internal
|
||||
* insertLineItemsHierarchical helper (description, quantity,
|
||||
* unit_price_minor, discount_percent, line_total_minor, etc.). The
|
||||
* `position` field is set internally — caller-supplied positions are
|
||||
* ignored to keep the accumulator's sequence intact.
|
||||
*
|
||||
* Returns { invoiceId, lineItemId } — the draft id plus the id of the
|
||||
* newly-appended row.
|
||||
*/
|
||||
async function appendOneLineItemToMonthlyDraft(customer, lineItem, adminId, trx) {
|
||||
// Reuse the accumulator path — it handles get-or-create + totals
|
||||
// recompute + activity log. We pass a single-item array.
|
||||
await appendToMonthlyDraft({
|
||||
customerAccountId: customer.id,
|
||||
lineItems: [lineItem],
|
||||
vatRate: 0, // hours logging doesn't ship with VAT today
|
||||
}, customer, adminId, trx);
|
||||
|
||||
// Look up the draft we just appended onto + its tail line item.
|
||||
// Newest insert wins by id desc; we filter by position match so
|
||||
// concurrent appends in another tx don't return the wrong row.
|
||||
const draft = await trx('invoices')
|
||||
.where({ customer_account_id: customer.id, is_monthly_draft: true })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
if (!draft) {
|
||||
// Defensive — appendToMonthlyDraft would have created one.
|
||||
throw new AppError('Monthly draft missing after append', 500);
|
||||
}
|
||||
const tail = await trx('invoice_line_items')
|
||||
.where({ invoice_id: draft.id })
|
||||
.orderBy('position', 'desc')
|
||||
.first();
|
||||
return { invoiceId: draft.id, lineItemId: tail?.id || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin override — issue the customer's running monthly draft NOW,
|
||||
* bypassing the cadence-day wait. Mirrors the scheduler's monthly
|
||||
* pass (migration 128): clears is_monthly_draft, sets the issue date
|
||||
* + scheduled_send_at to now, and fires sendInvoice inline so the
|
||||
* email goes out on the next email-queue tick (~60s) instead of
|
||||
* waiting for the next scheduler iteration.
|
||||
*
|
||||
* Refuses when:
|
||||
* - no draft exists (admin hasn't queued anything yet)
|
||||
* - the draft has zero line items (nothing to send — same as the
|
||||
* scheduler's empty-month skip path)
|
||||
*
|
||||
* Returns { invoiceId, invoiceNumber } so the route can surface the
|
||||
* resulting invoice on the response toast.
|
||||
*/
|
||||
/**
|
||||
* Read the customer's running monthly draft + its line items so the
|
||||
* customer-detail page can preview what will ship on the next cycle
|
||||
* day. Returns null when no open draft exists (admin hasn't queued
|
||||
* anything yet for the current period). Used by GET
|
||||
* /admin/customers/:id/monthly-draft.
|
||||
*/
|
||||
async function getMonthlyDraft(customerId) {
|
||||
const draft = await db('invoices')
|
||||
.where({ customer_account_id: customerId, is_monthly_draft: true })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
if (!draft) return null;
|
||||
const lineItems = await db('invoice_line_items as li')
|
||||
.leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id')
|
||||
.where('li.invoice_id', draft.id)
|
||||
.orderBy('li.position', 'asc')
|
||||
.select('li.*', 'parent.position as parent_position');
|
||||
return {
|
||||
id: draft.id,
|
||||
invoiceNumber: draft.invoice_number,
|
||||
currency: draft.currency,
|
||||
periodStart: draft.monthly_period_start,
|
||||
periodEnd: draft.monthly_period_end,
|
||||
netAmountMinor: draft.net_amount_minor,
|
||||
vatRate: draft.vat_rate == null ? null : Number(draft.vat_rate),
|
||||
vatAmountMinor: draft.vat_amount_minor,
|
||||
totalAmountMinor: draft.total_amount_minor,
|
||||
lineItems: lineItems.map((li) => ({
|
||||
id: li.id,
|
||||
position: li.position,
|
||||
quantity: Number(li.quantity),
|
||||
description: li.description,
|
||||
unitPriceMinor: ensureInt(li.unit_price_minor),
|
||||
discountPercent: Number(li.discount_percent || 0),
|
||||
lineTotalMinor: ensureInt(li.line_total_minor),
|
||||
parentPosition: li.parent_position == null ? null : ensureInt(li.parent_position),
|
||||
detailsText: li.details_text || '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
module.exports = {
|
||||
getOrCreateMonthlyDraft,
|
||||
appendToMonthlyDraft,
|
||||
appendOneLineItemToMonthlyDraft,
|
||||
getMonthlyDraft,
|
||||
};
|
||||
@@ -1,301 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../../database/db');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { nextDocumentNumber } = require('../../utils/documentSequences');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
|
||||
// Migration 119 line-item hierarchy helpers, shared with quoteService.
|
||||
// We import lazily inside the functions that use them to avoid a
|
||||
// require-cycle warning (quoteService also imports invoiceService for
|
||||
// the quote→invoice conversion path).
|
||||
function getHierarchyHelpers() {
|
||||
// eslint-disable-next-line global-require
|
||||
return require('../quoteService')._internal;
|
||||
}
|
||||
|
||||
// Atomic gap-free invoice number generator. See utils/documentSequences.js
|
||||
// for the locking story; migration 132 created the underlying table.
|
||||
// The previous SELECT-MAX-then-INSERT path raced under concurrent
|
||||
// admin creates and emitted a random `R-2026-AB12C3` after 5 retries,
|
||||
// breaking the §14 UStG single-sequence requirement.
|
||||
async function nextInvoiceNumber(trx) {
|
||||
return nextDocumentNumber('invoice', 'crm_invoices_number_format', 'R-{YEAR}-{SEQ:04d}', trx);
|
||||
}
|
||||
|
||||
function ensureCustomerCanBill(customer) {
|
||||
if (!customer) { throw new AppError('Customer not found', 404); }
|
||||
if (customer.is_active === false || customer.is_active === 0) {
|
||||
throw new AppError('Customer is deactivated', 409);
|
||||
}
|
||||
if (customer.feature_bills === false || customer.feature_bills === 0 || customer.feature_bills === '0') {
|
||||
throw new AppError('This customer has bills disabled', 409, 'CUSTOMER_FEATURE_DISABLED');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a trigger ('quote_accepted' | 'before_event' | ...) +
|
||||
* offset_days into a concrete date relative to the event.
|
||||
*/
|
||||
function computeScheduledSendAt(trigger, offsetDays, eventDate, baseDate = new Date()) {
|
||||
const ms = 24 * 60 * 60 * 1000;
|
||||
const offset = ensureInt(offsetDays) * ms;
|
||||
const eventTs = eventDate ? new Date(eventDate).getTime() : null;
|
||||
switch (trigger) {
|
||||
case 'quote_accepted':
|
||||
return new Date(baseDate.getTime() + offset);
|
||||
case 'before_event':
|
||||
case 'after_event':
|
||||
if (!eventTs) return new Date(baseDate.getTime() + offset);
|
||||
return new Date(eventTs + offset);
|
||||
case 'after_delivery':
|
||||
// Treat as event_date + 14 days as a sensible default; admin can
|
||||
// edit the scheduled_send_at on the invoice later.
|
||||
if (!eventTs) return new Date(baseDate.getTime() + 14 * ms + offset);
|
||||
return new Date(eventTs + 14 * ms + offset);
|
||||
case 'fixed_date':
|
||||
default:
|
||||
return new Date(baseDate.getTime() + offset);
|
||||
}
|
||||
}
|
||||
|
||||
function computeDueDate(scheduledSendAt, netDays = 30) {
|
||||
return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the net-days a new invoice's due date should be anchored to.
|
||||
* Single source of truth so the editor (split picker), legacy callers,
|
||||
* and quote→invoice conversion all land on the same number. Priority:
|
||||
*
|
||||
* 1. `payload.netDays` — explicit caller override (installment spawn
|
||||
* passes the snapshot's net_days here).
|
||||
* 2. Split picker (migration 124): payment_net_days_templates.net_days
|
||||
* via `payload.paymentNetDaysTemplateId`. This is what the bill
|
||||
* editor actually sends; the old code only read the legacy FK and
|
||||
* so silently ignored Net 60 / 90 selections.
|
||||
* 3. Legacy single FK: payment_term_templates.net_days via
|
||||
* `payload.paymentTermTemplateId`.
|
||||
* 4. The `crm_payment_default_net_days` setting (admin-configured).
|
||||
* 5. 30 — historical hard default.
|
||||
*/
|
||||
async function resolveNetDays(payload, trx = db) {
|
||||
if (payload && payload.netDays != null && payload.netDays !== '') {
|
||||
const n = ensureInt(payload.netDays);
|
||||
if (n) return n;
|
||||
}
|
||||
if (payload && payload.paymentNetDaysTemplateId) {
|
||||
const probe = await trx('payment_net_days_templates')
|
||||
.where({ id: payload.paymentNetDaysTemplateId })
|
||||
.select('net_days')
|
||||
.first();
|
||||
if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
|
||||
}
|
||||
if (payload && payload.paymentTermTemplateId) {
|
||||
const probe = await trx('payment_term_templates')
|
||||
.where({ id: payload.paymentTermTemplateId })
|
||||
.select('net_days')
|
||||
.first();
|
||||
if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
|
||||
}
|
||||
const setting = ensureInt(await getAppSetting('crm_payment_default_net_days'));
|
||||
if (setting) return setting;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Net-days for an already-persisted invoice row (no payload). Reads the
|
||||
* snapshot's net_days, then the crm_payment_default_net_days setting,
|
||||
* then 30. Used at send time to re-anchor the due date when the issue
|
||||
* date is stamped. Mirrors resolveNetDays' tail.
|
||||
*/
|
||||
async function resolveNetDaysForRow(invoice) {
|
||||
const snap = typeof invoice.payment_term_snapshot === 'string'
|
||||
? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })()
|
||||
: invoice.payment_term_snapshot;
|
||||
if (snap && snap.net_days != null) {
|
||||
const n = ensureInt(snap.net_days);
|
||||
if (n) return n;
|
||||
}
|
||||
const setting = ensureInt(await getAppSetting('crm_payment_default_net_days'));
|
||||
if (setting) return setting;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deal_uuid for a new invoice row (migration 140). Priority:
|
||||
*
|
||||
* 1. `payload.dealUuid` — explicit caller override. Used by
|
||||
* spawnInstallmentInvoices (all siblings share one uuid),
|
||||
* Storno (inherits from cancelled invoice), and reissue
|
||||
* (inherits from the cancelled original).
|
||||
* 2. The source quote's deal_uuid, if `payload.sourceQuoteId` is set.
|
||||
* 3. The source contract's deal_uuid, if `payload.sourceContractId`
|
||||
* is set.
|
||||
* 4. Fresh mint — standalone invoices that aren't part of any chain.
|
||||
*
|
||||
* Returns a UUID string. Never returns null.
|
||||
*/
|
||||
async function resolveDealUuid(trx, payload) {
|
||||
if (payload?.dealUuid) return payload.dealUuid;
|
||||
if (payload?.sourceQuoteId) {
|
||||
const q = await trx('quotes').where({ id: payload.sourceQuoteId }).first('deal_uuid');
|
||||
if (q?.deal_uuid) return q.deal_uuid;
|
||||
}
|
||||
if (payload?.sourceContractId) {
|
||||
const c = await trx('contracts').where({ id: payload.sourceContractId }).first('deal_uuid');
|
||||
if (c?.deal_uuid) return c.deal_uuid;
|
||||
}
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a baseline date to the next billing-cycle boundary for a
|
||||
* customer on a fixed cadence. Used by scheduleInvoicesForEvent so
|
||||
* monthly / quarterly customers don't get billed immediately on quote
|
||||
* acceptance — instead the invoice fires on `billing_cycle_day` of the
|
||||
* next period.
|
||||
*
|
||||
* `cycleDay` honours the sign-as-discriminator convention from
|
||||
* migration 128: positive 1..28 = that day of the month; negative
|
||||
* -1..-15 = that many days before end of month. Resolution is
|
||||
* delegated to `computeMonthlyCadenceDate` so the two helpers can't
|
||||
* disagree about what "-3 cycle day" means.
|
||||
*
|
||||
* Day numbers beyond the destination month's length are clamped
|
||||
* (e.g. day 31 in February rolls back to Feb 28/29). Negative days
|
||||
* are clamped to day 1 minimum (extreme values like -40 don't blow
|
||||
* past the start of the month).
|
||||
*
|
||||
* History: a prior version of this function did
|
||||
* `Math.max(1, Math.min(31, ensureInt(cycleDay) || 1))`, silently
|
||||
* clamping every negative value to 1 — so a customer configured
|
||||
* with cycle_day=-3 (last 3 days of month) got billed on day 1
|
||||
* instead. Audit finding: monthly cycle sign convention bug.
|
||||
*/
|
||||
function snapToNextBillingCycle(baseDate, cadence, cycleDay) {
|
||||
if (!cadence || cadence === 'per_event') return baseDate;
|
||||
const day = Number.isFinite(ensureInt(cycleDay)) ? ensureInt(cycleDay) : 1;
|
||||
const d = new Date(baseDate.getTime());
|
||||
|
||||
if (cadence === 'monthly') {
|
||||
// Move to the cycleDay in the next calendar month. If we're already
|
||||
// before cycleDay this month and the base date is in the same month,
|
||||
// we still move forward to NEXT month so accepting a quote on
|
||||
// Jan 5 (cycleDay=1) fires on Feb 1, not Jan 5.
|
||||
const nextMonth = d.getMonth() + 1;
|
||||
return computeMonthlyCadenceDate(d.getFullYear(), nextMonth, day);
|
||||
}
|
||||
|
||||
if (cadence === 'quarterly') {
|
||||
// First month of the next quarter. Quarter starts: Jan, Apr, Jul, Oct.
|
||||
const month = d.getMonth();
|
||||
const nextQuarterMonth = (Math.floor(month / 3) + 1) * 3; // 0,3,6,9
|
||||
return computeMonthlyCadenceDate(d.getFullYear(), nextQuarterMonth, day);
|
||||
}
|
||||
|
||||
return baseDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the canonical "cadence day" for a given (year, month) using
|
||||
* the customer's `billing_cycle_day`. Migration 128 introduced the
|
||||
* sign-as-discriminator convention:
|
||||
* positive 1..28 → that day of the month, clamped to month length
|
||||
* negative -1..-15 → that many days before end of month
|
||||
* Zero falls back to 1 (matches the service-layer clamp).
|
||||
*
|
||||
* Returns a JS Date at local-midnight on the resolved day. Callers
|
||||
* compare against today's date with day-resolution math; the time
|
||||
* component never matters for monthly-bill issuance.
|
||||
*/
|
||||
function computeMonthlyCadenceDate(year, month /* 0-based */, cycleDay) {
|
||||
const day = Number.isFinite(cycleDay) ? Math.trunc(cycleDay) : 1;
|
||||
const monthLen = new Date(year, month + 1, 0).getDate();
|
||||
let target;
|
||||
if (day > 0) {
|
||||
target = Math.min(day, monthLen);
|
||||
} else if (day < 0) {
|
||||
// Sign-as-discriminator: -N = N days before month end. Documented
|
||||
// in the admin UI hint as "Use negative -1..-15 for 'N days before
|
||||
// month end' (so -3 fires on the 28th of a 31-day month)".
|
||||
// Formula: monthLen + day → -3 + 31 = 28 ✓.
|
||||
// Clamped to day 1 minimum so extreme values (-40) don't blow
|
||||
// past the start of the month.
|
||||
target = Math.max(1, monthLen + day);
|
||||
} else {
|
||||
target = 1;
|
||||
}
|
||||
return new Date(year, month, target);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// updateInstallmentPlan — atomic post-spawn plan edit
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
// Statuses that are still pre-customer (no PDF has gone out the door).
|
||||
// Both `scheduled` and `pending_delivery` are reshapable; anything else
|
||||
// belongs to the audit trail and can't be silently mutated.
|
||||
const EDITABLE_INSTALLMENT_STATUSES = new Set(['scheduled', 'pending_delivery']);
|
||||
|
||||
const VALID_INSTALLMENT_TRIGGERS = new Set([
|
||||
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
|
||||
]);
|
||||
|
||||
// Module-cached issuer country code — refreshed on every business
|
||||
// profile save by listening to the same query React-Query revalidates.
|
||||
// For backend purposes we read it lazily once per process and cache
|
||||
// the resolved Intl locale; admins changing the country in Settings
|
||||
// take effect after the next backend restart, which is acceptable
|
||||
// (this isn't on a hot path).
|
||||
let _cachedIntlLocale = null;
|
||||
async function resolveIntlLocale(docLocale) {
|
||||
if (_cachedIntlLocale) return _cachedIntlLocale;
|
||||
try {
|
||||
const businessProfileService = require('../businessProfileService');
|
||||
const profile = (await businessProfileService.getProfile()).profile || {};
|
||||
const cc = (profile.country_code || '').toUpperCase();
|
||||
if (['CH', 'LI', 'DE', 'AT'].includes(cc)) {
|
||||
_cachedIntlLocale = 'de-CH';
|
||||
return _cachedIntlLocale;
|
||||
}
|
||||
} catch (_) { /* fall through to per-locale default */ }
|
||||
return docLocale === 'de' ? 'de-CH' : 'en-GB';
|
||||
}
|
||||
|
||||
function formatMajor(minor, currency, locale) {
|
||||
// Sync version — keeps the existing call-sites working. Reads the
|
||||
// module cache populated by the async warm-up on first send. When
|
||||
// the cache hasn't filled yet (first invocation in a process)
|
||||
// fall through to the legacy de-vs-en split; the cache fills after
|
||||
// the first send and every subsequent send uses the correct locale.
|
||||
const cached = _cachedIntlLocale;
|
||||
const intlLocale = cached || (locale === 'de' ? 'de-CH' : 'en-GB');
|
||||
// Best-effort warm-up — fire and forget; the next call hits cache.
|
||||
if (!cached) {
|
||||
resolveIntlLocale(locale).catch(() => { /* tolerate */ });
|
||||
}
|
||||
return new Intl.NumberFormat(intlLocale, {
|
||||
style: 'currency', currency: (currency || 'CHF').toUpperCase(),
|
||||
}).format(Number(minor || 0) / 100);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getHierarchyHelpers,
|
||||
nextInvoiceNumber,
|
||||
ensureCustomerCanBill,
|
||||
computeScheduledSendAt,
|
||||
computeDueDate,
|
||||
resolveNetDays,
|
||||
resolveNetDaysForRow,
|
||||
resolveDealUuid,
|
||||
snapToNextBillingCycle,
|
||||
computeMonthlyCadenceDate,
|
||||
EDITABLE_INSTALLMENT_STATUSES,
|
||||
VALID_INSTALLMENT_TRIGGERS,
|
||||
resolveIntlLocale,
|
||||
formatMajor,
|
||||
};
|
||||
@@ -1,381 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const { logActivity } = require('../../database/db');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { ensureInt, ensureNumber } = require('../../utils/numericHelpers');
|
||||
const { computeDueDate, computeScheduledSendAt, EDITABLE_INSTALLMENT_STATUSES, getHierarchyHelpers, nextInvoiceNumber, snapToNextBillingCycle, VALID_INSTALLMENT_TRIGGERS } = require('./helpers');
|
||||
|
||||
|
||||
/**
|
||||
* Compute one slice of a plan total. Matches the rounding rule used by
|
||||
* spawnInstallmentInvoices — every slice except the last is a rounded
|
||||
* percent share; the last slice absorbs rounding drift so the per-slice
|
||||
* sums exactly equal the plan total.
|
||||
*/
|
||||
function computeSliceTotals(installments, totals, i) {
|
||||
const lastIndex = installments.length - 1;
|
||||
const pct = ensureNumber(installments[i].percent, 0);
|
||||
if (i < lastIndex) {
|
||||
return {
|
||||
net: Math.round(ensureInt(totals.net) * pct / 100),
|
||||
vat: Math.round(ensureInt(totals.vat) * pct / 100),
|
||||
shipping: Math.round(ensureInt(totals.shipping) * pct / 100),
|
||||
total: Math.round(ensureInt(totals.total) * pct / 100),
|
||||
};
|
||||
}
|
||||
const acc = installments.slice(0, i).reduce((s, x) => {
|
||||
const p = ensureNumber(x.percent, 0);
|
||||
return {
|
||||
net: s.net + Math.round(ensureInt(totals.net) * p / 100),
|
||||
vat: s.vat + Math.round(ensureInt(totals.vat) * p / 100),
|
||||
shipping: s.shipping + Math.round(ensureInt(totals.shipping) * p / 100),
|
||||
total: s.total + Math.round(ensureInt(totals.total) * p / 100),
|
||||
};
|
||||
}, { net: 0, vat: 0, shipping: 0, total: 0 });
|
||||
return {
|
||||
net: ensureInt(totals.net) - acc.net,
|
||||
vat: ensureInt(totals.vat) - acc.vat,
|
||||
shipping: ensureInt(totals.shipping) - acc.shipping,
|
||||
total: ensureInt(totals.total) - acc.total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws AppError on invalid input. Exposed for the route layer to
|
||||
* surface as 400 before opening a transaction.
|
||||
*/
|
||||
function validateInstallmentPlanInput(installments) {
|
||||
if (!Array.isArray(installments) || installments.length === 0) {
|
||||
throw new AppError('installments must be a non-empty array', 400);
|
||||
}
|
||||
let sum = 0;
|
||||
for (let i = 0; i < installments.length; i++) {
|
||||
const inst = installments[i] || {};
|
||||
const pct = ensureNumber(inst.percent, NaN);
|
||||
if (!Number.isFinite(pct) || pct < 0 || pct > 100) {
|
||||
throw new AppError(`Row ${i + 1}: percent must be between 0 and 100`, 400);
|
||||
}
|
||||
if (!VALID_INSTALLMENT_TRIGGERS.has(inst.trigger)) {
|
||||
throw new AppError(`Row ${i + 1}: invalid trigger '${inst.trigger}'`, 400);
|
||||
}
|
||||
const off = ensureInt(inst.offset_days);
|
||||
if (!Number.isFinite(off)) {
|
||||
throw new AppError(`Row ${i + 1}: offset_days must be an integer`, 400);
|
||||
}
|
||||
sum += pct;
|
||||
}
|
||||
if (Math.abs(sum - 100) > 0.001) {
|
||||
throw new AppError(
|
||||
`Installment percents must sum to 100 (got ${sum})`,
|
||||
400,
|
||||
'PERCENT_SUM_INVALID',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic — spawnInstallmentInvoices appends a reconciliation line
|
||||
* with a stable description shape like "Anzahlung (30% — 1/3)". The
|
||||
* em-dash is U+2014 so the regex won't match plain hyphens used in
|
||||
* admin-authored line descriptions.
|
||||
*
|
||||
* We could harden this with an `is_reconciliation_line` column, but
|
||||
* the cost of a schema change isn't worth the residual edge (admins
|
||||
* don't edit reconciliation lines today).
|
||||
*/
|
||||
function isReconciliationLineItem(li) {
|
||||
if (!li || typeof li.description !== 'string') return false;
|
||||
return / \(\d+(?:\.\d+)?% — \d+\/\d+\)$/.test(li.description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace (or insert) the reconciliation line on an invoice so its
|
||||
* description matches the new label/percent and the line's amount
|
||||
* closes the gap between the cloned-quote-line subtotal and the
|
||||
* sibling's net slice. Symmetric with the inline logic in spawn.
|
||||
*
|
||||
* `topLineSubtotal` is the sum of non-reconciliation, top-level line
|
||||
* items already on the invoice — passed in so callers reading the row
|
||||
* once don't have to re-query.
|
||||
*/
|
||||
async function replaceReconciliationLine(
|
||||
trx, invoiceId, { label, percent, index, total, netSlice, topLineSubtotal },
|
||||
) {
|
||||
const all = await trx('invoice_line_items')
|
||||
.where({ invoice_id: invoiceId })
|
||||
.orderBy('position', 'asc');
|
||||
for (const li of all) {
|
||||
if (isReconciliationLineItem(li)) {
|
||||
await trx('invoice_line_items').where({ id: li.id }).del();
|
||||
}
|
||||
}
|
||||
if (total <= 1) return;
|
||||
|
||||
const nonRecon = all.filter((x) => !isReconciliationLineItem(x));
|
||||
const subtotal = topLineSubtotal != null
|
||||
? topLineSubtotal
|
||||
: nonRecon.filter((x) => x.parent_position == null)
|
||||
.reduce((s, x) => s + ensureInt(x.line_total_minor), 0);
|
||||
const adjustment = netSlice - subtotal;
|
||||
if (adjustment === 0) return;
|
||||
|
||||
const maxPosition = nonRecon.reduce(
|
||||
(m, x) => Math.max(m, ensureInt(x.position)), 0,
|
||||
);
|
||||
await trx('invoice_line_items').insert({
|
||||
invoice_id: invoiceId,
|
||||
position: maxPosition + 1,
|
||||
quantity: 1,
|
||||
description: `${label} (${percent}% — ${index + 1}/${total})`,
|
||||
unit_price_minor: adjustment,
|
||||
discount_percent: 0,
|
||||
line_total_minor: adjustment,
|
||||
parent_line_item_id: null,
|
||||
details_text: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reshape an installment plan after siblings have spawned.
|
||||
* The plan is the unit of edit: percents / count / triggers all change
|
||||
* together in one transaction. Mutating individual siblings stays on
|
||||
* the existing PUT /admin/invoices/:id path.
|
||||
*
|
||||
* Guards:
|
||||
* - dealUuid must exist + own ≥1 invoice (else 404)
|
||||
* - all siblings must be in EDITABLE_INSTALLMENT_STATUSES (else 409
|
||||
* `INVOICE_LOCKED`)
|
||||
* - no Storno on the deal (else 409 `PLAN_HAS_STORNO`)
|
||||
* - new plan validated by validateInstallmentPlanInput
|
||||
*
|
||||
* Algorithm:
|
||||
* - Plan total = sum of existing siblings' totals (captures any
|
||||
* per-sibling edits since spawn).
|
||||
* - Reused siblings (i < min(old, new)): UPDATE in place — preserves
|
||||
* id + invoice_number, so sequence numbers aren't burned.
|
||||
* - Extra new rows (new > old): INSERT — claims a fresh invoice_number
|
||||
* per row; clones canonical (non-reconciliation) line items from
|
||||
* existing[0] so each new sibling carries the quote lines.
|
||||
* - Trim rows (new < old): DELETE — claimed sequence numbers ARE lost
|
||||
* (document_sequences has no release path, and that's intentional
|
||||
* for §14 UStG continuity).
|
||||
*
|
||||
* Returns `{ invoiceIds, kept, created, deleted }`.
|
||||
*/
|
||||
async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) {
|
||||
if (!dealUuid) throw new AppError('dealUuid is required', 400);
|
||||
validateInstallmentPlanInput(installments);
|
||||
|
||||
const existing = await trx('invoices')
|
||||
.where({ deal_uuid: dealUuid })
|
||||
.orderBy('installment_index', 'asc');
|
||||
|
||||
if (existing.length === 0) {
|
||||
throw new AppError('No invoices found for this deal', 404);
|
||||
}
|
||||
const isMultiInstallment = existing.some((r) => ensureInt(r.installment_total) > 1);
|
||||
if (!isMultiInstallment) {
|
||||
throw new AppError(
|
||||
'This deal is not an installment plan',
|
||||
400,
|
||||
'NOT_INSTALLMENT_PLAN',
|
||||
);
|
||||
}
|
||||
for (const row of existing) {
|
||||
if (row.kind === 'storno') {
|
||||
throw new AppError(
|
||||
`Plan contains a Storno (${row.invoice_number}) — reshape refused`,
|
||||
409,
|
||||
'PLAN_HAS_STORNO',
|
||||
);
|
||||
}
|
||||
if (!EDITABLE_INSTALLMENT_STATUSES.has(row.status)) {
|
||||
throw new AppError(
|
||||
`Cannot reshape — invoice ${row.invoice_number} is '${row.status}'`,
|
||||
409,
|
||||
'INVOICE_LOCKED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totals = existing.reduce((acc, r) => ({
|
||||
net: acc.net + ensureInt(r.net_amount_minor),
|
||||
vat: acc.vat + ensureInt(r.vat_amount_minor),
|
||||
shipping: acc.shipping + ensureInt(r.shipping_amount_minor),
|
||||
total: acc.total + ensureInt(r.total_amount_minor),
|
||||
vatRate: ensureNumber(r.vat_rate, acc.vatRate),
|
||||
}), { net: 0, vat: 0, shipping: 0, total: 0, vatRate: 0 });
|
||||
|
||||
const sample = existing[0]; // canonical event + customer + payment-term shape
|
||||
|
||||
// netDays inferred from sample's issue → due gap so the new rows
|
||||
// honour the same payment-term the customer agreed to. Falls back
|
||||
// to 30 when either column is missing.
|
||||
const inferredNetDays = sample.due_date && sample.issue_date
|
||||
? Math.round((new Date(sample.due_date) - new Date(sample.issue_date)) / (24 * 60 * 60 * 1000))
|
||||
: 30;
|
||||
const netDays = Number.isFinite(inferredNetDays) && inferredNetDays > 0 ? inferredNetDays : 30;
|
||||
|
||||
const eventDate = sample.event_date || null;
|
||||
const customer = sample.customer_account_id
|
||||
? await trx('customer_accounts').where({ id: sample.customer_account_id }).first()
|
||||
: null;
|
||||
|
||||
// Cache canonical (non-reconciliation) line items from existing[0]
|
||||
// for cloning into any newly-created siblings.
|
||||
let canonicalLineItems = null;
|
||||
const acceptanceTime = new Date();
|
||||
const newCount = installments.length;
|
||||
const reusableCount = Math.min(existing.length, newCount);
|
||||
|
||||
const kept = [];
|
||||
const created = [];
|
||||
const deleted = [];
|
||||
|
||||
for (let i = 0; i < newCount; i++) {
|
||||
const inst = installments[i];
|
||||
const slice = computeSliceTotals(installments, totals, i);
|
||||
|
||||
let scheduledSendAt = computeScheduledSendAt(
|
||||
inst.trigger, inst.offset_days, eventDate, acceptanceTime,
|
||||
);
|
||||
if (customer && customer.billing_cadence && customer.billing_cadence !== 'per_event') {
|
||||
scheduledSendAt = snapToNextBillingCycle(
|
||||
scheduledSendAt, customer.billing_cadence, customer.billing_cycle_day,
|
||||
);
|
||||
}
|
||||
const isDeliveryTrigger = inst.trigger === 'after_delivery';
|
||||
const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled';
|
||||
const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt;
|
||||
const dueDate = computeDueDate(scheduledSendAt, netDays).toISOString().slice(0, 10);
|
||||
const label = inst.label || `Installment ${i + 1}/${newCount}`;
|
||||
|
||||
if (i < reusableCount) {
|
||||
const existingRow = existing[i];
|
||||
await trx('invoices').where({ id: existingRow.id }).update({
|
||||
installment_index: i,
|
||||
installment_total: newCount,
|
||||
installment_label: label,
|
||||
installment_trigger: inst.trigger,
|
||||
status: rowStatus,
|
||||
scheduled_send_at: rowScheduledSendAt,
|
||||
issue_date: scheduledSendAt.toISOString().slice(0, 10),
|
||||
due_date: dueDate,
|
||||
net_amount_minor: slice.net,
|
||||
vat_amount_minor: slice.vat,
|
||||
shipping_amount_minor: slice.shipping,
|
||||
total_amount_minor: slice.total,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await replaceReconciliationLine(trx, existingRow.id, {
|
||||
label, percent: inst.percent, index: i, total: newCount, netSlice: slice.net,
|
||||
});
|
||||
kept.push(existingRow.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// New sibling — clone canonical lines from existing[0] on first
|
||||
// use, then reuse the cached copy for any further new siblings.
|
||||
if (canonicalLineItems === null) {
|
||||
const sourceLines = await trx('invoice_line_items')
|
||||
.where({ invoice_id: existing[0].id })
|
||||
.orderBy('position', 'asc');
|
||||
canonicalLineItems = sourceLines.filter((li) => !isReconciliationLineItem(li));
|
||||
}
|
||||
|
||||
const invoiceNumber = await nextInvoiceNumber(trx);
|
||||
const row = {
|
||||
invoice_number: invoiceNumber,
|
||||
customer_account_id: sample.customer_account_id,
|
||||
source_quote_id: sample.source_quote_id,
|
||||
event_id: sample.event_id,
|
||||
event_name: sample.event_name,
|
||||
event_date: sample.event_date,
|
||||
event_time_start: sample.event_time_start,
|
||||
event_time_end: sample.event_time_end,
|
||||
language: sample.language,
|
||||
currency: sample.currency,
|
||||
issue_date: scheduledSendAt.toISOString().slice(0, 10),
|
||||
due_date: dueDate,
|
||||
installment_index: i,
|
||||
installment_total: newCount,
|
||||
installment_label: label,
|
||||
installment_trigger: inst.trigger,
|
||||
status: rowStatus,
|
||||
scheduled_send_at: rowScheduledSendAt,
|
||||
net_amount_minor: slice.net,
|
||||
vat_rate: ensureNumber(sample.vat_rate, 0),
|
||||
vat_amount_minor: slice.vat,
|
||||
shipping_amount_minor: slice.shipping,
|
||||
total_amount_minor: slice.total,
|
||||
cc_pdf_email: sample.cc_pdf_email || null,
|
||||
payment_net_days_template_id: sample.payment_net_days_template_id || null,
|
||||
payment_timing_template_id: sample.payment_timing_template_id || null,
|
||||
payment_term_snapshot: sample.payment_term_snapshot || null,
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
const inserted = await trx('invoices').insert(row).returning('id');
|
||||
const newId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
if (canonicalLineItems.length > 0) {
|
||||
const cloned = canonicalLineItems.map((li) => ({
|
||||
position: ensureInt(li.position),
|
||||
quantity: li.quantity,
|
||||
description: li.description,
|
||||
unit_price_minor: ensureInt(li.unit_price_minor),
|
||||
discount_percent: ensureNumber(li.discount_percent, 0),
|
||||
line_total_minor: ensureInt(li.line_total_minor),
|
||||
parent_position: li.parent_position == null ? null : ensureInt(li.parent_position),
|
||||
details_text: li.details_text || null,
|
||||
}));
|
||||
const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers();
|
||||
validateLineItemHierarchy(cloned);
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', newId, cloned);
|
||||
}
|
||||
|
||||
await replaceReconciliationLine(trx, newId, {
|
||||
label, percent: inst.percent, index: i, total: newCount, netSlice: slice.net,
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('invoice_scheduled', {
|
||||
invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape',
|
||||
}, sample.event_id, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
|
||||
created.push(newId);
|
||||
}
|
||||
|
||||
// Trim extras (only fires when newCount < existing.length).
|
||||
for (let i = newCount; i < existing.length; i++) {
|
||||
const oldRow = existing[i];
|
||||
await trx('invoice_line_items').where({ invoice_id: oldRow.id }).del();
|
||||
await trx('invoices').where({ id: oldRow.id }).del();
|
||||
deleted.push(oldRow.id);
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('installment_plan_updated', {
|
||||
dealUuid, newCount,
|
||||
kept: kept.length, created: created.length, deleted: deleted.length,
|
||||
}, sample.event_id, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
invoiceIds: [...kept, ...created],
|
||||
kept, created, deleted,
|
||||
};
|
||||
}
|
||||
module.exports = {
|
||||
computeSliceTotals,
|
||||
validateInstallmentPlanInput,
|
||||
isReconciliationLineItem,
|
||||
replaceReconciliationLine,
|
||||
updateInstallmentPlan,
|
||||
};
|
||||
@@ -1,525 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { formatShortDate } = require('../../utils/dateFormatter');
|
||||
const emailProcessor = require('../emailProcessor');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
const { formatMajor } = require('./helpers');
|
||||
const { applyReminder, resolveAdminEmailForInvoice, resolvePerReminderFeeMinor, resolveSkontoPercentForInvoice } = require('./reminders');
|
||||
|
||||
|
||||
/**
|
||||
* Record a payment against an invoice. Supports partial payments
|
||||
* (multiple rows accumulate into `paid_amount_minor`). Status flips
|
||||
* to `paid` once the running total meets or exceeds total_amount_minor.
|
||||
*/
|
||||
async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, notes, skontoApplied }, adminId) {
|
||||
const invoice = await db('invoices').where({ id }).first();
|
||||
if (!invoice) throw new AppError('Invoice not found', 404);
|
||||
if (invoice.status === 'cancelled') {
|
||||
throw new AppError('Cannot mark a cancelled invoice as paid', 409);
|
||||
}
|
||||
const amount = ensureInt(amountMinor);
|
||||
if (amount <= 0) {
|
||||
throw new AppError('amount must be > 0', 400);
|
||||
}
|
||||
// Skonto bookkeeping (migration 126). When the admin ticks "Paid
|
||||
// with Skonto" we store both the flag AND the absolute discount
|
||||
// in minor units. Computing the discount here (instead of in the
|
||||
// renderer at report time) means the value is frozen against
|
||||
// later template/percentage edits — the tax-report row stays
|
||||
// accurate for years.
|
||||
const skontoFlag = Boolean(skontoApplied);
|
||||
const skontoAmountMinor = skontoFlag
|
||||
? Math.max(0, ensureInt(invoice.total_amount_minor) - amount)
|
||||
: null;
|
||||
|
||||
const markResult = await db.transaction(async (trx) => {
|
||||
await trx('invoice_payment_log').insert({
|
||||
invoice_id: id,
|
||||
amount_minor: amount,
|
||||
paid_at: paidAt ? new Date(paidAt) : new Date(),
|
||||
payment_method: paymentMethod || null,
|
||||
reference: reference || null,
|
||||
notes: notes || null,
|
||||
recorded_by_admin_id: adminId,
|
||||
skonto_applied: skontoFlag,
|
||||
skonto_amount_minor: skontoAmountMinor,
|
||||
created_at: new Date(),
|
||||
});
|
||||
const sumRow = await trx('invoice_payment_log').where({ invoice_id: id }).sum('amount_minor as total').first();
|
||||
const total = ensureInt(sumRow?.total || 0);
|
||||
// Consider the invoice paid when the recorded payments cover the
|
||||
// invoice total. The late fee is NOT added to the threshold here
|
||||
// — admins frequently waive it once the customer actually pays
|
||||
// (and chasing the extra 25 CHF after a 1500 CHF invoice clears
|
||||
// makes nobody happy). Admin can record a separate payment_log
|
||||
// row if they did collect the fee; status flips to paid the
|
||||
// moment the principal is covered.
|
||||
//
|
||||
// Skonto path (migration 126): when the admin flagged this
|
||||
// payment as Skonto-applied, the discounted amount equals the
|
||||
// expected payment — flip to 'paid' even though paid_amount_minor
|
||||
// is strictly less than total_amount_minor. Without this branch
|
||||
// the invoice would sit in 'sent' or 'overdue' forever despite
|
||||
// being legitimately settled.
|
||||
const skontoEffectiveTotal = skontoFlag
|
||||
? ensureInt(invoice.total_amount_minor) - (skontoAmountMinor || 0)
|
||||
: ensureInt(invoice.total_amount_minor);
|
||||
const isFull = total >= skontoEffectiveTotal;
|
||||
|
||||
const update = {
|
||||
paid_amount_minor: total,
|
||||
payment_method: paymentMethod || invoice.payment_method,
|
||||
payment_reference: reference || invoice.payment_reference,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (isFull) {
|
||||
update.status = 'paid';
|
||||
update.paid_at = paidAt ? new Date(paidAt) : new Date();
|
||||
}
|
||||
await trx('invoices').where({ id }).update(update);
|
||||
|
||||
try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment',
|
||||
{ invoiceId: id, amountMinor: amount, totalPaidMinor: total },
|
||||
invoice.event_id || null, `admin:${adminId}`); } catch (_) {}
|
||||
|
||||
// Migration 127 — admin payment-received notification. Fires only
|
||||
// on the transition into 'paid' so admins don't get duplicate
|
||||
// emails when additional payment-log rows are recorded after the
|
||||
// invoice already cleared (rare but possible — e.g. late-fee
|
||||
// top-up). Queued after the transaction so a failed email never
|
||||
// rolls back a recorded payment. Carried Skonto context lets the
|
||||
// template show the discount line conditionally.
|
||||
if (isFull && invoice.status !== 'paid') {
|
||||
try {
|
||||
await queueInvoicePaidAdminNotification({
|
||||
invoice,
|
||||
paidTotalMinor: total,
|
||||
paymentMethod: paymentMethod || invoice.payment_method || null,
|
||||
paymentReference: reference || invoice.payment_reference || null,
|
||||
paidAt: paidAt ? new Date(paidAt) : new Date(),
|
||||
skontoApplied: skontoFlag,
|
||||
skontoAmountMinor: skontoAmountMinor || 0,
|
||||
});
|
||||
} catch (err) {
|
||||
// Notification is best-effort — don't surface a 500 to the
|
||||
// admin when the recorded payment itself succeeded.
|
||||
logger.warn('invoice_paid admin notification failed to queue', { invoiceId: id, err: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return { paidTotalMinor: total, status: isFull ? 'paid' : invoice.status };
|
||||
});
|
||||
|
||||
// Fire invoice.paid for the workflow engine ONLY on the transition into
|
||||
// 'paid' (mirrors the admin-notification guard above). After the commit so a
|
||||
// workflow side effect can never roll back the recorded payment.
|
||||
if (markResult.status === 'paid' && invoice.status !== 'paid') {
|
||||
try {
|
||||
await require('../workflows').emitWorkflowEvent('invoice.paid', {
|
||||
entityType: 'invoice',
|
||||
entityId: id,
|
||||
payload: {
|
||||
invoiceId: id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
eventId: invoice.event_id || null,
|
||||
customerAccountId: invoice.customer_account_id,
|
||||
paidTotalMinor: markResult.paidTotalMinor,
|
||||
},
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
return markResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh payment-check token for an invoice and queue the
|
||||
* admin email with three signed action buttons. Throttled to once
|
||||
* per 24h per invoice via invoices.last_payment_check_at.
|
||||
*
|
||||
* Returns { token, sent: bool, reason? } so callers can log /
|
||||
* surface the outcome.
|
||||
*/
|
||||
/**
|
||||
* Queue the admin "payment received" notification (migration 127).
|
||||
* Called from markPaid the first time an invoice transitions into
|
||||
* `status='paid'`. Resolves the admin's address via the same chain
|
||||
* the payment-check email uses (created_by_admin_id → business
|
||||
* profile fallback). Silently no-ops when no admin email can be
|
||||
* resolved — caller logs the warn line.
|
||||
*/
|
||||
async function queueInvoicePaidAdminNotification({
|
||||
invoice, paidTotalMinor, paymentMethod, paymentReference,
|
||||
paidAt, skontoApplied, skontoAmountMinor,
|
||||
}) {
|
||||
const adminContact = await resolveAdminEmailForInvoice(invoice);
|
||||
if (!adminContact?.email) {
|
||||
logger.warn('invoice_paid notification skipped — no admin email resolved',
|
||||
{ invoiceId: invoice.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await db('business_profile').where({ id: 1 }).first();
|
||||
const locale = invoice.language || profile?.default_locale || 'de';
|
||||
|
||||
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
|
||||
// Resolve the Skonto percentage at notification time so the
|
||||
// template can render "Paid with Skonto X%" without a second query.
|
||||
// Same resolver the rest of the Skonto surfaces use — null when
|
||||
// skonto_disabled is true or no Skonto is configured.
|
||||
const skontoPercent = skontoApplied
|
||||
? await resolveSkontoPercentForInvoice(invoice)
|
||||
: null;
|
||||
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email,
|
||||
'invoice_paid_admin_notification', {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer?.company_name
|
||||
|| customer?.display_name
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email || '',
|
||||
event_name: invoice.event_name || '',
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
|
||||
paid_amount: formatMajor(paidTotalMinor, invoice.currency, locale),
|
||||
payment_method: paymentMethod || '',
|
||||
payment_reference: paymentReference || '',
|
||||
paid_at: formatShortDate(paidAt),
|
||||
skonto_applied: !!skontoApplied,
|
||||
skonto_percent: skontoApplied && skontoPercent ? skontoPercent : '',
|
||||
skonto_discount_amount: skontoApplied
|
||||
? formatMajor(skontoAmountMinor, invoice.currency, locale)
|
||||
: '',
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id },
|
||||
invoice.event_id || null, 'system');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) {
|
||||
const invoice = await db('invoices').where({ id: invoiceId }).first();
|
||||
if (!invoice) return { sent: false, reason: 'not_found' };
|
||||
if (!['sent', 'overdue'].includes(invoice.status)) {
|
||||
return { sent: false, reason: `wrong_status_${invoice.status}` };
|
||||
}
|
||||
const now = new Date();
|
||||
if (!skipThrottle && invoice.last_payment_check_at) {
|
||||
const last = new Date(invoice.last_payment_check_at).getTime();
|
||||
if (now.getTime() - last < 24 * 60 * 60 * 1000) {
|
||||
return { sent: false, reason: 'throttled_24h' };
|
||||
}
|
||||
}
|
||||
|
||||
const adminContact = await resolveAdminEmailForInvoice(invoice);
|
||||
if (!adminContact?.email) {
|
||||
logger.warn('Payment-check email skipped — no admin email resolved', { invoiceId });
|
||||
return { sent: false, reason: 'no_admin_email' };
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
await db('invoice_payment_check_tokens').insert({
|
||||
invoice_id: invoiceId,
|
||||
token,
|
||||
expires_at: expiresAt,
|
||||
created_at: now,
|
||||
});
|
||||
await db('invoices').where({ id: invoiceId }).update({
|
||||
last_payment_check_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
|
||||
const profile = await db('business_profile').where({ id: 1 }).first();
|
||||
const locale = invoice.language || profile?.default_locale || 'de';
|
||||
|
||||
// Determine whether the customer reminder will include a Mahngebühr
|
||||
// if the admin selects "Not paid" / "Partial" — surfaced to the
|
||||
// email so the admin sees the consequence before clicking.
|
||||
const reminderFeeMinor = await resolvePerReminderFeeMinor(invoice);
|
||||
const nextLevel = (invoice.reminder_level || 0) + 1;
|
||||
const willChargeFee = reminderFeeMinor > 0 && nextLevel >= 2;
|
||||
|
||||
const baseUrl = process.env.FRONTEND_URL
|
||||
|| (await getAppSetting('app_frontend_url'))
|
||||
|| 'https://app.example.com';
|
||||
const buildUrl = (action) =>
|
||||
`${baseUrl.replace(/\/$/, '')}/payment-check/${token}?action=${action}`;
|
||||
|
||||
// Outstanding = gross total + late fee − already paid. The admin
|
||||
// is being asked about what's STILL OWED, not the original gross
|
||||
// figure — so surface outstanding + paid in the email context.
|
||||
// Partial payments logged earlier (e.g. via a previous admin
|
||||
// payment-check click) are reflected, so the admin doesn't get
|
||||
// asked "did the customer pay CHF 234?" when they already paid
|
||||
// CHF 134 of it.
|
||||
const paidMinor = Number(invoice.paid_amount_minor || 0);
|
||||
const lateFeeAlreadyMinor = Number(invoice.late_fee_amount_minor || 0);
|
||||
const outstandingMinor = Math.max(0,
|
||||
Number(invoice.total_amount_minor || 0) + lateFeeAlreadyMinor - paidMinor);
|
||||
const hasPartial = paidMinor > 0;
|
||||
|
||||
// Resolve Skonto for the optional 4th button (migration 126). Only
|
||||
// surface the button when (a) Skonto is configured for this invoice
|
||||
// AND (b) the customer paid within the Skonto window — past the
|
||||
// window the discount is moot. Both checks are visible to the
|
||||
// template so the email can hide the button conditionally.
|
||||
const skontoPercent = await resolveSkontoPercentForInvoice(invoice);
|
||||
const hasSkonto = !!skontoPercent && skontoPercent > 0;
|
||||
const skontoDiscountedTotalMinor = hasSkonto
|
||||
? Math.round(Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100))
|
||||
: null;
|
||||
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email,
|
||||
'invoice_payment_check_admin', {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer?.company_name
|
||||
|| customer?.display_name
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email || '',
|
||||
event_name: invoice.event_name || '',
|
||||
due_date: formatShortDate(invoice.due_date),
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
|
||||
paid_amount: formatMajor(paidMinor, invoice.currency, locale),
|
||||
outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale),
|
||||
has_partial_payment: hasPartial,
|
||||
paid_url: buildUrl('paid_full'),
|
||||
partial_url: buildUrl('partial'),
|
||||
unpaid_url: buildUrl('unpaid'),
|
||||
// Skonto button — template uses {{#if has_skonto}} to render the
|
||||
// fourth button only when the invoice qualifies.
|
||||
has_skonto: hasSkonto,
|
||||
skonto_percent: hasSkonto ? skontoPercent : '',
|
||||
skonto_amount: hasSkonto
|
||||
? formatMajor(skontoDiscountedTotalMinor, invoice.currency, locale)
|
||||
: '',
|
||||
skonto_url: hasSkonto ? buildUrl('paid_with_skonto') : '',
|
||||
late_fee_due: willChargeFee,
|
||||
late_fee_amount: formatMajor(reminderFeeMinor, invoice.currency, locale),
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) },
|
||||
invoice.event_id || null, 'scheduler');
|
||||
} catch (_) {}
|
||||
|
||||
return { token, sent: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a payment-check token and return the invoice context
|
||||
* the public page needs. Token must exist, not be expired, not
|
||||
* already used.
|
||||
*/
|
||||
async function getPaymentCheckByToken(token) {
|
||||
const row = await db('invoice_payment_check_tokens').where({ token }).first();
|
||||
if (!row) throw new AppError('Token not found', 404);
|
||||
if (row.used_at) {
|
||||
const err = new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED');
|
||||
err.usedAt = row.used_at;
|
||||
err.usedAction = row.used_action;
|
||||
throw err;
|
||||
}
|
||||
if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) {
|
||||
throw new AppError('This link has expired', 410, 'TOKEN_EXPIRED');
|
||||
}
|
||||
const invoice = await db('invoices').where({ id: row.invoice_id }).first();
|
||||
if (!invoice) throw new AppError('Invoice not found', 404);
|
||||
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
|
||||
|
||||
const outstandingMinor = Math.max(0,
|
||||
Number(invoice.total_amount_minor || 0) + Number(invoice.late_fee_amount_minor || 0)
|
||||
- Number(invoice.paid_amount_minor || 0));
|
||||
|
||||
// Surface the Skonto state so the public page can decide whether to
|
||||
// render the "Paid with Skonto" action card (migration 126). Only
|
||||
// applies when the invoice's payment terms actually carry a Skonto
|
||||
// percentage — admin shouldn't see the option on an invoice that
|
||||
// never offered the discount.
|
||||
const skontoPercent = await resolveSkontoPercentForInvoice(invoice);
|
||||
const hasSkonto = !!skontoPercent && skontoPercent > 0;
|
||||
const skontoDiscountedTotalMinor = hasSkonto
|
||||
? Math.round(Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customer: {
|
||||
label: customer?.company_name
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.display_name || customer?.email || '',
|
||||
email: customer?.email,
|
||||
},
|
||||
issueDate: invoice.issue_date,
|
||||
dueDate: invoice.due_date,
|
||||
totalMinor: invoice.total_amount_minor,
|
||||
paidMinor: invoice.paid_amount_minor,
|
||||
lateFeeMinor: invoice.late_fee_amount_minor,
|
||||
outstandingMinor,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
reminderLevel: invoice.reminder_level,
|
||||
expiresAt: row.expires_at,
|
||||
hasSkonto,
|
||||
skontoPercent: hasSkonto ? skontoPercent : null,
|
||||
skontoDiscountedTotalMinor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the admin's payment-check action and fire the downstream
|
||||
* consequences:
|
||||
* - 'paid_full' → markPaid for the outstanding amount, no reminder.
|
||||
* - 'partial' → markPaid for the amount supplied, then fire the
|
||||
* next reminder for the remainder.
|
||||
* - 'unpaid' → fire the next reminder (level 1 or 2) with the
|
||||
* existing Mahngebühr logic in applyReminder.
|
||||
*
|
||||
* Atomic: token consumption + invoice status update happen in one
|
||||
* transaction. The reminder email is queued AFTER the txn commits
|
||||
* to avoid emailing a customer about a payment that never
|
||||
* actually committed.
|
||||
*/
|
||||
async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminId }) {
|
||||
// 'paid_with_skonto' (migration 126) is a fourth admin action — the
|
||||
// customer settled the bill within the early-payment-discount window,
|
||||
// so the recorded payment equals total minus the configured Skonto %.
|
||||
// Same token-consumption semantics as 'paid_full'.
|
||||
if (!['paid_full', 'paid_with_skonto', 'partial', 'unpaid'].includes(action)) {
|
||||
throw new AppError('Invalid action', 400);
|
||||
}
|
||||
|
||||
const row = await db('invoice_payment_check_tokens').where({ token }).first();
|
||||
if (!row) throw new AppError('Token not found', 404);
|
||||
if (row.used_at) {
|
||||
throw new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED');
|
||||
}
|
||||
if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) {
|
||||
throw new AppError('This link has expired', 410, 'TOKEN_EXPIRED');
|
||||
}
|
||||
const invoice = await db('invoices').where({ id: row.invoice_id }).first();
|
||||
if (!invoice) throw new AppError('Invoice not found', 404);
|
||||
|
||||
const outstandingMinor = Math.max(0,
|
||||
Number(invoice.total_amount_minor || 0) + Number(invoice.late_fee_amount_minor || 0)
|
||||
- Number(invoice.paid_amount_minor || 0));
|
||||
|
||||
if (action === 'partial') {
|
||||
const amt = ensureInt(amountMinor);
|
||||
if (amt <= 0) throw new AppError('partial amount must be > 0', 400);
|
||||
if (amt > outstandingMinor) throw new AppError('partial amount exceeds outstanding', 400);
|
||||
}
|
||||
|
||||
// Consume the token first — atomic with status update so a
|
||||
// double-click can't fire the action twice.
|
||||
const now = new Date();
|
||||
const updated = await db('invoice_payment_check_tokens')
|
||||
.where({ id: row.id })
|
||||
.whereNull('used_at')
|
||||
.update({
|
||||
used_at: now,
|
||||
used_action: action,
|
||||
used_amount_minor: action === 'partial' ? ensureInt(amountMinor) : null,
|
||||
used_ip: ip || null,
|
||||
});
|
||||
if (updated === 0) {
|
||||
// Lost a race with another consumer.
|
||||
throw new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED');
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('invoice_payment_check_recorded',
|
||||
{ invoiceId: invoice.id, action, amountMinor: amountMinor || null },
|
||||
invoice.event_id || null,
|
||||
adminId ? `admin:${adminId}` : 'public:payment-check');
|
||||
} catch (_) {}
|
||||
|
||||
// --- Apply the action -----------------------------------------
|
||||
if (action === 'paid_full') {
|
||||
await markPaid(invoice.id, {
|
||||
amountMinor: outstandingMinor,
|
||||
paymentMethod: invoice.payment_method || 'bank_transfer',
|
||||
reference: invoice.payment_reference || null,
|
||||
notes: 'Confirmed via admin payment-check link',
|
||||
}, adminId || invoice.created_by_admin_id);
|
||||
return { applied: 'paid_full' };
|
||||
}
|
||||
|
||||
if (action === 'paid_with_skonto') {
|
||||
// Resolve the Skonto percentage at click time so admins can't
|
||||
// accidentally double-discount after the template changed. Same
|
||||
// resolution chain pdfService uses: invoice snapshot → source
|
||||
// quote snapshot → global crm_invoices_skonto_percent_default.
|
||||
const skontoPercent = await resolveSkontoPercentForInvoice(invoice);
|
||||
if (!skontoPercent || skontoPercent <= 0) {
|
||||
throw new AppError('No Skonto configured on this invoice', 409, 'SKONTO_NOT_CONFIGURED');
|
||||
}
|
||||
const discountedTotalMinor = Math.round(
|
||||
Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100),
|
||||
);
|
||||
// Outstanding-aware: if the customer already paid part of the
|
||||
// bill (rare on the Skonto path, but possible after a partial),
|
||||
// record only the remaining slice up to the discounted total.
|
||||
const paidMinor = Number(invoice.paid_amount_minor || 0);
|
||||
const remainingMinor = Math.max(0, discountedTotalMinor - paidMinor);
|
||||
if (remainingMinor <= 0) {
|
||||
throw new AppError('Invoice already paid past the Skonto threshold', 409);
|
||||
}
|
||||
await markPaid(invoice.id, {
|
||||
amountMinor: remainingMinor,
|
||||
paymentMethod: invoice.payment_method || 'bank_transfer',
|
||||
reference: invoice.payment_reference || null,
|
||||
notes: `Confirmed via admin payment-check link (Skonto ${skontoPercent}% applied)`,
|
||||
skontoApplied: true,
|
||||
}, adminId || invoice.created_by_admin_id);
|
||||
return { applied: 'paid_with_skonto', skontoPercent };
|
||||
}
|
||||
|
||||
if (action === 'partial') {
|
||||
const amt = ensureInt(amountMinor);
|
||||
await markPaid(invoice.id, {
|
||||
amountMinor: amt,
|
||||
paymentMethod: invoice.payment_method || 'bank_transfer',
|
||||
reference: invoice.payment_reference || null,
|
||||
notes: 'Partial payment confirmed via admin payment-check link',
|
||||
}, adminId || invoice.created_by_admin_id);
|
||||
// Then fire the customer reminder for the remainder, unless
|
||||
// markPaid flipped the invoice to paid (i.e. the partial
|
||||
// amount equalled the outstanding).
|
||||
const refreshed = await db('invoices').where({ id: invoice.id }).first();
|
||||
if (refreshed.status !== 'paid') {
|
||||
const nextLevel = (refreshed.reminder_level || 0) + 1;
|
||||
if (nextLevel <= 3) {
|
||||
const lineItems = await db('invoice_line_items')
|
||||
.where({ invoice_id: invoice.id }).orderBy('position', 'asc');
|
||||
await applyReminder(refreshed, lineItems, nextLevel, adminId);
|
||||
}
|
||||
}
|
||||
return { applied: 'partial' };
|
||||
}
|
||||
|
||||
// 'unpaid'
|
||||
const nextLevel = (invoice.reminder_level || 0) + 1;
|
||||
if (nextLevel > 3) {
|
||||
// Already at max reminder — admin has to take this offline.
|
||||
return { applied: 'unpaid', reminderSkipped: 'max_level_reached' };
|
||||
}
|
||||
const lineItems = await db('invoice_line_items')
|
||||
.where({ invoice_id: invoice.id }).orderBy('position', 'asc');
|
||||
await applyReminder(invoice, lineItems, nextLevel, adminId);
|
||||
return { applied: 'unpaid', reminderLevel: nextLevel };
|
||||
}
|
||||
module.exports = {
|
||||
markPaid,
|
||||
queueInvoicePaidAdminNotification,
|
||||
queuePaymentCheckEmail,
|
||||
getPaymentCheckByToken,
|
||||
recordPaymentCheckAction,
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const { db, withRetry } = require('../../database/db');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function listInvoices({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
|
||||
return await withRetry(async () => {
|
||||
let query = db('invoices')
|
||||
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
|
||||
// Surface the source contract's human contract_number (mirror of
|
||||
// the src_quote JOIN in getInvoiceById) so list rows + detail
|
||||
// page can render "From contract LBM-C-2026-0010" instead of
|
||||
// the bare DB id "#10". LEFT join — most invoices have no
|
||||
// source contract.
|
||||
.leftJoin('contracts as src_contract', 'invoices.source_contract_id', 'src_contract.id')
|
||||
.select(
|
||||
'invoices.*',
|
||||
'customer_accounts.email as customer_email',
|
||||
'customer_accounts.display_name as customer_display_name',
|
||||
'customer_accounts.first_name as customer_first_name',
|
||||
'customer_accounts.last_name as customer_last_name',
|
||||
// Same isPassive-source as getInvoiceById — surfaced so list
|
||||
// rows can render the Passive badge inline without an N+1
|
||||
// round-trip.
|
||||
'customer_accounts.password_hash as customer_password_hash',
|
||||
'customer_accounts.company_name as customer_company_name',
|
||||
'src_contract.contract_number as source_contract_number',
|
||||
);
|
||||
|
||||
if (Array.isArray(filters.status) && filters.status.length > 0) {
|
||||
query = query.whereIn('invoices.status', filters.status);
|
||||
}
|
||||
if (filters.customerAccountId) {
|
||||
query = query.where('invoices.customer_account_id', filters.customerAccountId);
|
||||
}
|
||||
// Hide monthly drafts (migration 128) from the default list — they
|
||||
// live on the customer detail page's "Monthly billing queue" card.
|
||||
// Callers that explicitly want them (the customer-detail summary
|
||||
// fetch) pass `includeMonthlyDrafts: true`.
|
||||
if (!filters.includeMonthlyDrafts) {
|
||||
query = query.where(function () {
|
||||
this.where('invoices.is_monthly_draft', false)
|
||||
.orWhereNull('invoices.is_monthly_draft');
|
||||
});
|
||||
}
|
||||
if (filters.sourceQuoteId) {
|
||||
query = query.where('invoices.source_quote_id', filters.sourceQuoteId);
|
||||
}
|
||||
if (filters.unpaidOnly) {
|
||||
query = query.whereIn('invoices.status', ['scheduled', 'sent', 'overdue']);
|
||||
}
|
||||
if (filters.q && String(filters.q).trim()) {
|
||||
const term = `%${String(filters.q).trim()}%`;
|
||||
query = query.andWhere(function() {
|
||||
this.where('invoices.invoice_number', 'like', term)
|
||||
.orWhere('customer_accounts.email', 'like', term)
|
||||
.orWhere('customer_accounts.company_name', 'like', term);
|
||||
});
|
||||
}
|
||||
const countRow = await query.clone().clearSelect().clearOrder().count('invoices.id as total').first();
|
||||
const total = ensureInt(countRow?.total || 0);
|
||||
|
||||
switch (sort) {
|
||||
// "Newest" / "Oldest" means newest/oldest by CREATION time, not
|
||||
// by issue_date. Issue_date is admin-controlled (used for tax
|
||||
// accruals, retro-dating, future-dating) so it can drift from
|
||||
// actual chronology — sorting by it makes a just-created invoice
|
||||
// disappear into the middle of the list whenever its issue_date
|
||||
// is set to something other than today. created_at always
|
||||
// reflects when the row landed in the DB. id is the tiebreaker
|
||||
// for rows that share a created_at second.
|
||||
case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break;
|
||||
case 'issue_asc': query = query.orderBy('invoices.issue_date', 'asc').orderBy('invoices.id', 'asc'); break;
|
||||
case 'issue_desc': query = query.orderBy('invoices.issue_date', 'desc').orderBy('invoices.id', 'desc'); break;
|
||||
case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break;
|
||||
case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break;
|
||||
case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break;
|
||||
case 'value_desc': query = query.orderBy('invoices.total_amount_minor', 'desc'); break;
|
||||
case 'customer_asc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
|
||||
.orderBy('invoices.id', 'desc');
|
||||
break;
|
||||
case 'customer_desc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
|
||||
.orderBy('invoices.id', 'desc');
|
||||
break;
|
||||
case 'newest':
|
||||
default:
|
||||
query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc');
|
||||
break;
|
||||
}
|
||||
|
||||
const offset = Math.max(0, (page - 1) * pageSize);
|
||||
query = query.offset(offset).limit(pageSize);
|
||||
const rows = await query;
|
||||
return { rows, total, page, pageSize };
|
||||
});
|
||||
}
|
||||
|
||||
async function getInvoiceById(id) {
|
||||
return await withRetry(async () => {
|
||||
// LEFT JOIN customer_accounts so transformInvoice has populated
|
||||
// customer_email / company etc. — mirrors getQuoteById.
|
||||
const invoice = await db('invoices')
|
||||
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
|
||||
// Join the source quote so the detail view can display its
|
||||
// human-readable number ("LBM-Q-2026-0006") instead of just
|
||||
// the numeric id ("#6"). LEFT join — most invoices come from
|
||||
// a quote conversion but standalone invoices don't have one.
|
||||
.leftJoin('quotes as src_quote', 'invoices.source_quote_id', 'src_quote.id')
|
||||
// Migration 130 lineage: source contract's human contract_number
|
||||
// so the detail view shows "From contract LBM-C-2026-0010"
|
||||
// instead of "#10". Same LEFT-join shape as src_quote.
|
||||
.leftJoin('contracts as src_contract', 'invoices.source_contract_id', 'src_contract.id')
|
||||
// Self-joins for Storno lineage so the detail view can render
|
||||
// "Cancelled by Stornorechnung S-XXXX" / "This Stornorechnung
|
||||
// cancels invoice R-XXXX" using the human invoice_number rather
|
||||
// than the bare DB row id. Same pattern as source_quote_number.
|
||||
.leftJoin('invoices as cancels_inv', 'invoices.cancels_invoice_id', 'cancels_inv.id')
|
||||
.leftJoin('invoices as cancellation_storno', 'invoices.cancellation_storno_id', 'cancellation_storno.id')
|
||||
.where('invoices.id', id)
|
||||
.select(
|
||||
'invoices.*',
|
||||
'customer_accounts.email as customer_email',
|
||||
'customer_accounts.display_name as customer_display_name',
|
||||
'customer_accounts.first_name as customer_first_name',
|
||||
'customer_accounts.last_name as customer_last_name',
|
||||
'customer_accounts.company_name as customer_company_name',
|
||||
// Surfaced so the route's transformInvoice can compute the
|
||||
// customer.isPassive flag (passwordHash == null). The hash
|
||||
// itself never leaves the API — transformInvoice drops it
|
||||
// and only exposes the boolean.
|
||||
'customer_accounts.password_hash as customer_password_hash',
|
||||
'src_quote.quote_number as source_quote_number',
|
||||
'src_contract.contract_number as source_contract_number',
|
||||
'cancels_inv.invoice_number as cancels_invoice_number',
|
||||
'cancellation_storno.invoice_number as cancellation_storno_number',
|
||||
)
|
||||
.first();
|
||||
if (!invoice) return null;
|
||||
// Self-join so each row also carries `parent_position` (the position
|
||||
// of its parent line item, when it's a sub-item). The editor needs
|
||||
// position-based references to rebuild the hierarchy in the UI;
|
||||
// parent_line_item_id is the DB-level relationship but isn't
|
||||
// stable in the payload the editor sends back. Migration 119.
|
||||
const lineItems = await db('invoice_line_items as li')
|
||||
.leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id')
|
||||
.where('li.invoice_id', id)
|
||||
.orderBy('li.position', 'asc')
|
||||
.select('li.*', 'parent.position as parent_position');
|
||||
const payments = await db('invoice_payment_log').where({ invoice_id: id }).orderBy('paid_at', 'asc');
|
||||
return { invoice, lineItems, payments };
|
||||
});
|
||||
}
|
||||
module.exports = {
|
||||
listInvoices,
|
||||
getInvoiceById,
|
||||
};
|
||||
@@ -1,264 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { formatShortDate } = require('../../utils/dateFormatter');
|
||||
const { resolveBillingRecipients } = require('../_billingRecipients');
|
||||
const pdfService = require('../pdfService');
|
||||
const emailProcessor = require('../emailProcessor');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const { formatMajor } = require('./helpers');
|
||||
const { getInvoiceById } = require('./queries');
|
||||
const { buildInvoiceRenderContext } = require('./render');
|
||||
|
||||
|
||||
/**
|
||||
* Manually trigger a reminder email. The scheduler does this
|
||||
* automatically; this is the "Send reminder now" button on the
|
||||
* invoice detail page.
|
||||
*/
|
||||
async function sendReminder(id, levelOverride, adminId) {
|
||||
const data = await getInvoiceById(id);
|
||||
if (!data) throw new AppError('Invoice not found', 404);
|
||||
const { invoice, lineItems } = data;
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||||
throw new AppError(`Cannot remind on status '${invoice.status}'`, 409);
|
||||
}
|
||||
const newLevel = levelOverride || (invoice.reminder_level + 1);
|
||||
if (newLevel > 3) {
|
||||
throw new AppError('Reminder level exhausted', 409);
|
||||
}
|
||||
return await applyReminder(invoice, lineItems, newLevel, adminId);
|
||||
}
|
||||
|
||||
// Per-reminder Mahngebühr in minor units (0 when disabled). Flat amount OR a
|
||||
// percentage of the invoice gross, per crm_invoices_late_fee_type. Charged from
|
||||
// the 2nd reminder onwards. ⚠️ A late fee is only enforceable if the concrete
|
||||
// amount is stated in the AGB — verify with a Treuhänder (the admin UI says so).
|
||||
// Net per-reminder Mahngebühr (flat amount or % of invoice gross), 0 disabled.
|
||||
async function resolveLateFeeNetMinor(invoice) {
|
||||
if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0;
|
||||
const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat';
|
||||
let fee;
|
||||
if (type === 'percent') {
|
||||
const pct = Number(await getAppSetting('crm_invoices_late_fee_percent')) || 0;
|
||||
fee = Math.round(Number(invoice.total_amount_minor || 0) * pct / 100);
|
||||
} else {
|
||||
fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500;
|
||||
}
|
||||
return Math.max(0, fee);
|
||||
}
|
||||
|
||||
// VAT rate on the fee — jurisdiction-dependent (CH: yes; DE/AT: no), so
|
||||
// toggle-gated AND org-VAT-gated: 0 when the org has no default VAT rate, so
|
||||
// enabling the toggle on a non-VAT org adds nothing.
|
||||
async function resolveLateFeeVatRate() {
|
||||
if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) !== true) return 0;
|
||||
const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default');
|
||||
return Number(profile?.vat_rate_default) || 0;
|
||||
}
|
||||
|
||||
// Gross per-reminder fee (net + VAT) — for the admin payment-check preview.
|
||||
async function resolvePerReminderFeeMinor(invoice) {
|
||||
const net = await resolveLateFeeNetMinor(invoice);
|
||||
if (net <= 0) return 0;
|
||||
const rate = await resolveLateFeeVatRate();
|
||||
return rate > 0 ? net + Math.round(net * rate / 100) : net;
|
||||
}
|
||||
|
||||
async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
|
||||
|
||||
// Per fee-bearing reminder (levels 2..level): 2nd = 1×, 3rd = 2×, computed
|
||||
// from `level` so re-applying the same level never stacks. The fee is dunning
|
||||
// STATE on the row (gross + the VAT portion) — it is NOT shown on the
|
||||
// immutable invoice; it appears on the separate Mahnung document below.
|
||||
let lateFeeGross = invoice.late_fee_amount_minor || 0;
|
||||
let lateFeeVat = invoice.late_fee_vat_minor || 0;
|
||||
if (level >= 2) {
|
||||
const net = await resolveLateFeeNetMinor(invoice);
|
||||
const rate = await resolveLateFeeVatRate();
|
||||
const vatPer = rate > 0 ? Math.round(net * rate / 100) : 0;
|
||||
lateFeeGross = (level - 1) * (net + vatPer);
|
||||
lateFeeVat = (level - 1) * vatPer;
|
||||
}
|
||||
const newTotal = Number(invoice.total_amount_minor || 0) + lateFeeGross;
|
||||
|
||||
const update = {
|
||||
status: 'overdue',
|
||||
reminder_level: level,
|
||||
last_reminder_sent_at: new Date(),
|
||||
late_fee_amount_minor: lateFeeGross,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (await hasColumnCached('invoices', 'late_fee_vat_minor')) update.late_fee_vat_minor = lateFeeVat;
|
||||
await db('invoices').where({ id: invoice.id }).update(update);
|
||||
|
||||
// Fire invoice.overdue at the status→overdue flip. Deduped per (workflow,
|
||||
// invoice), so across the reminder ladder it triggers a flow at most once.
|
||||
// Best-effort / fail-closed.
|
||||
try {
|
||||
await require('../workflows').emitWorkflowEvent('invoice.overdue', {
|
||||
entityType: 'invoice',
|
||||
entityId: invoice.id,
|
||||
payload: {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
eventId: invoice.event_id || null,
|
||||
customerAccountId: invoice.customer_account_id,
|
||||
customerEmail: customer?.email || null,
|
||||
dueDate: invoice.due_date,
|
||||
reminderLevel: level,
|
||||
totalMinor: invoice.total_amount_minor,
|
||||
currency: invoice.currency,
|
||||
},
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
// Render the MAHNUNG (reminder letter). The original invoice PDF is left
|
||||
// UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a
|
||||
// 'mahnung' kind: same line items + the Mahngebühr row + the new total, with
|
||||
// a "Mahnung" title and no QR (it would encode the old amount).
|
||||
const fresh = await db('invoices').where({ id: invoice.id }).first();
|
||||
const ctx = await buildInvoiceRenderContext(fresh, lineItems);
|
||||
ctx.doc.kind = 'mahnung';
|
||||
ctx.doc.reminderLevel = level;
|
||||
ctx.doc.lateFeeMinor = lateFeeGross;
|
||||
ctx.totals.lateFeeAmountMinor = lateFeeGross;
|
||||
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const year = new Date(fresh.issue_date).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'mahnung', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const mahnungPath = path.join(root, `${fresh.invoice_number}_mahnung_L${level}.pdf`);
|
||||
fs.writeFileSync(mahnungPath, buffer);
|
||||
|
||||
// days_overdue floors at 1 (a "0 days overdue" reminder reads as broken).
|
||||
const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000);
|
||||
const daysOverdue = Math.max(1, rawDaysOverdue);
|
||||
const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second';
|
||||
const locale = ctx.locale || invoice.language || 'de';
|
||||
const outstandingMinor = Math.max(0, newTotal - Number(invoice.paid_amount_minor || 0));
|
||||
|
||||
// Attach the (unchanged) original invoice PDF + the new Mahnung.
|
||||
const attachments = [];
|
||||
if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) {
|
||||
attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' });
|
||||
}
|
||||
attachments.push({ filename: `${fresh.invoice_number}_Mahnung.pdf`, contentPath: mahnungPath, contentType: 'application/pdf' });
|
||||
|
||||
const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
|
||||
try {
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
|
||||
new_total_amount: formatMajor(newTotal, invoice.currency, locale),
|
||||
outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale),
|
||||
paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, locale),
|
||||
late_fee_amount: formatMajor(lateFeeGross, invoice.currency, locale),
|
||||
due_date: formatShortDate(invoice.due_date),
|
||||
days_overdue: daysOverdue,
|
||||
cc: reminderCc,
|
||||
attachments,
|
||||
// Dunning reminders are relationship mail — hold to business hours.
|
||||
}, { respectBusinessHours: true });
|
||||
} catch (err) {
|
||||
// Don't leave the just-rendered Mahnung PDF orphaned on disk if queueing the
|
||||
// email failed — it would only be reachable via the next reminder anyway.
|
||||
try { fs.unlinkSync(mahnungPath); } catch (_) { /* best-effort cleanup */ }
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross },
|
||||
invoice.event_id || null, `admin:${adminId || 'system'}`);
|
||||
} catch (_) {}
|
||||
|
||||
return { level, lateFeeMinor: lateFeeGross };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Payment-check workflow (admin-confirmed reminders)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the admin email address that should receive the payment-
|
||||
* check prompt. Priority:
|
||||
* 1. created_by_admin_id's email (the admin who issued the invoice)
|
||||
* 2. First admin user with bills.manage permission
|
||||
* 3. business_profile.email as a last resort
|
||||
* Returns null when nothing usable is found — caller logs + skips.
|
||||
*/
|
||||
/**
|
||||
* Resolve the effective Skonto percentage for an invoice at the
|
||||
* current moment. Resolution chain (matches pdfService rendering):
|
||||
* 1. invoice.payment_term_snapshot.skonto_percent
|
||||
* 2. source quote's payment_term_snapshot.skonto_percent
|
||||
* 3. global crm_invoices_skonto_percent_default
|
||||
* Returns null when nothing is configured.
|
||||
*
|
||||
* Lifted into a helper so the payment-check action and the email
|
||||
* template (which both need to know "does this invoice qualify for a
|
||||
* Paid-with-Skonto button?") share one source of truth.
|
||||
*/
|
||||
async function resolveSkontoPercentForInvoice(invoice) {
|
||||
// Per-invoice opt-out (migration 126) wins over every other source.
|
||||
// Admin sets this on Storni / replacement invoices / payment-plan
|
||||
// installments that shouldn't qualify for the discount even when
|
||||
// the global default offers it.
|
||||
if (invoice.skonto_disabled) return null;
|
||||
// Per-customer opt-out (migration 112) — a customer that negotiated
|
||||
// "no Skonto" as a contract term never qualifies, so the admin
|
||||
// doesn't have to tick the per-invoice toggle on every invoice.
|
||||
// Falls through customer → invoice → snapshot → quote → global.
|
||||
if (invoice.customer_account_id) {
|
||||
const cust = await db('customer_accounts')
|
||||
.where({ id: invoice.customer_account_id })
|
||||
.select('skonto_disabled')
|
||||
.first();
|
||||
if (cust && cust.skonto_disabled) return null;
|
||||
}
|
||||
const parseSnap = (raw) => {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return raw;
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
};
|
||||
const invSnap = parseSnap(invoice.payment_term_snapshot);
|
||||
if (invSnap?.skonto_percent != null && Number(invSnap.skonto_percent) > 0) {
|
||||
return Number(invSnap.skonto_percent);
|
||||
}
|
||||
if (invoice.source_quote_id) {
|
||||
const q = await db('quotes').where({ id: invoice.source_quote_id }).select('payment_term_snapshot').first();
|
||||
const qSnap = parseSnap(q?.payment_term_snapshot);
|
||||
if (qSnap?.skonto_percent != null && Number(qSnap.skonto_percent) > 0) {
|
||||
return Number(qSnap.skonto_percent);
|
||||
}
|
||||
}
|
||||
const defaultPct = Number(await getAppSetting('crm_invoices_skonto_percent_default'));
|
||||
return Number.isFinite(defaultPct) && defaultPct > 0 ? defaultPct : null;
|
||||
}
|
||||
|
||||
async function resolveAdminEmailForInvoice(invoice) {
|
||||
if (invoice.created_by_admin_id) {
|
||||
const admin = await db('admin_users').where({ id: invoice.created_by_admin_id }).first();
|
||||
if (admin?.email) return { email: admin.email, name: admin.username || admin.email };
|
||||
}
|
||||
// Fallback: business_profile.email.
|
||||
const profile = await db('business_profile').where({ id: 1 }).first();
|
||||
if (profile?.email) return { email: profile.email, name: profile.company_name || profile.email };
|
||||
return null;
|
||||
}
|
||||
module.exports = {
|
||||
sendReminder,
|
||||
resolveLateFeeNetMinor,
|
||||
resolveLateFeeVatRate,
|
||||
resolvePerReminderFeeMinor,
|
||||
applyReminder,
|
||||
resolveSkontoPercentForInvoice,
|
||||
resolveAdminEmailForInvoice,
|
||||
};
|
||||
@@ -1,354 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const { db } = require('../../database/db');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { cleanNetMinor } = require('../../utils/invoiceRounding');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const businessProfileService = require('../businessProfileService');
|
||||
const { buildIssuerBlock, buildRecipientBlock } = require('../_renderContext');
|
||||
const pdfService = require('../pdfService');
|
||||
const { ensureInt, ensureNumber } = require('../../utils/numericHelpers');
|
||||
const { getHierarchyHelpers } = require('./helpers');
|
||||
const { getInvoiceById } = require('./queries');
|
||||
|
||||
|
||||
async function buildInvoiceRenderContext(invoice, lineItems) {
|
||||
const { profile } = await businessProfileService.getProfile();
|
||||
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
|
||||
const bank = invoice.business_bank_account_id
|
||||
? await db('business_bank_accounts').where({ id: invoice.business_bank_account_id }).first()
|
||||
: await businessProfileService.resolveBankAccountForCurrency(invoice.currency);
|
||||
|
||||
// Resolve the PDF logo to a verified absolute disk path. The
|
||||
// helper exhaustively tries:
|
||||
// 1. business_profile.logo_path
|
||||
// 2. app_settings.branding_logo_path (absolute multer path)
|
||||
// 3. app_settings.branding_logo_url (URL path)
|
||||
// …and for each, generates ~7 candidate disk locations before
|
||||
// giving up. Returns null + logs a detailed warning when nothing
|
||||
// resolves. Already-verified path means the renderer never has
|
||||
// to second-guess.
|
||||
const { resolveLogoFile } = require('../../utils/resolveLogoFile');
|
||||
const resolvedLogoPath = await resolveLogoFile(profile);
|
||||
|
||||
// QR format resolution order (per-invoice override → profile
|
||||
// default → none) gated by the global enable toggle. The earlier
|
||||
// version had an operator-precedence bug that effectively dropped
|
||||
// the profile default; this rewrites it as plain if/else for
|
||||
// readability + correctness.
|
||||
const qrGloballyEnabled = (await getAppSetting('crm_invoices_qr_enabled')) !== false;
|
||||
let resolvedQrFormat = 'none';
|
||||
if (qrGloballyEnabled) {
|
||||
resolvedQrFormat = invoice.qr_format || profile?.default_qr_format || 'none';
|
||||
}
|
||||
|
||||
// Resolve the payment-term snapshot to thread Skonto + net-days into
|
||||
// the PDF's "Zahlungsbedingungen" block. Three sources, in priority
|
||||
// order:
|
||||
// 1. The invoice's OWN snapshot (migration 113 — set when admin
|
||||
// picks a template directly in the New Invoice form).
|
||||
// 2. The originating quote's snapshot, if this invoice was
|
||||
// created from one.
|
||||
// 3. The global CRM defaults (settings tab) — `crm_invoices_*`.
|
||||
// Both layers above are wrapped in `paymentTerm` exactly as
|
||||
// quoteService builds it so pdfService.drawPaymentBlock renders
|
||||
// the same block on both document types.
|
||||
let paymentTerm = null;
|
||||
|
||||
// Invoice-level snapshot wins when set.
|
||||
if (invoice.payment_term_snapshot) {
|
||||
const snapshot = typeof invoice.payment_term_snapshot === 'string'
|
||||
? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })()
|
||||
: invoice.payment_term_snapshot;
|
||||
if (snapshot) {
|
||||
paymentTerm = {
|
||||
description: snapshot.description,
|
||||
netDays: snapshot.net_days,
|
||||
skontoPercent: snapshot.skonto_percent,
|
||||
skontoWithinDays: snapshot.skonto_within_days,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Load the source quote once — used for the payment-term snapshot
|
||||
// fallback AND for the "Bezug: Angebot Q-..." reference line on
|
||||
// the invoice PDF. We deliberately keep invoice numbers on a
|
||||
// strict monotonic sequence (tax compliance) and surface the link
|
||||
// as a text reference rather than mirroring the number.
|
||||
let sourceQuote = null;
|
||||
if (invoice.source_quote_id) {
|
||||
sourceQuote = await db('quotes').where({ id: invoice.source_quote_id }).first();
|
||||
if (!paymentTerm && sourceQuote?.payment_term_snapshot) {
|
||||
const snapshot = typeof sourceQuote.payment_term_snapshot === 'string'
|
||||
? (() => { try { return JSON.parse(sourceQuote.payment_term_snapshot); } catch { return null; } })()
|
||||
: sourceQuote.payment_term_snapshot;
|
||||
if (snapshot) {
|
||||
paymentTerm = {
|
||||
description: snapshot.description,
|
||||
netDays: snapshot.net_days,
|
||||
skontoPercent: snapshot.skonto_percent,
|
||||
skontoWithinDays: snapshot.skonto_within_days,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Globally-default Skonto values, always loaded. Used either to
|
||||
// FILL a partial source-quote snapshot OR to seed the whole
|
||||
// paymentTerm when there's no source quote. Both reads survive
|
||||
// missing rows (returns null), unset values (NaN guarded), and
|
||||
// string-encoded numbers from app_settings.
|
||||
const defaultSkontoPercentRaw = await getAppSetting('crm_invoices_skonto_percent_default');
|
||||
const defaultSkontoDaysRaw = await getAppSetting('crm_invoices_skonto_business_days');
|
||||
const defaultSkontoPercent = Number.isFinite(Number(defaultSkontoPercentRaw)) && Number(defaultSkontoPercentRaw) > 0
|
||||
? Number(defaultSkontoPercentRaw) : null;
|
||||
const defaultSkontoDays = Number.isFinite(Number(defaultSkontoDaysRaw)) && Number(defaultSkontoDaysRaw) > 0
|
||||
? parseInt(defaultSkontoDaysRaw, 10) : null;
|
||||
|
||||
if (paymentTerm) {
|
||||
// The source quote's snapshot may carry only some of the Skonto
|
||||
// fields (e.g. when the template predates Skonto support); fill
|
||||
// missing parts from the global defaults so the PDF still shows
|
||||
// the row whenever there's enough info to render it.
|
||||
if (paymentTerm.skontoPercent == null && defaultSkontoPercent != null) {
|
||||
paymentTerm.skontoPercent = defaultSkontoPercent;
|
||||
}
|
||||
if (paymentTerm.skontoWithinDays == null && defaultSkontoDays != null) {
|
||||
paymentTerm.skontoWithinDays = defaultSkontoDays;
|
||||
}
|
||||
} else {
|
||||
// Ad-hoc invoice (no source quote). Build the paymentTerm from
|
||||
// the global defaults. Renders only when BOTH percent + days are
|
||||
// set + > 0 (pdfService.drawPaymentBlock guards on that).
|
||||
paymentTerm = {
|
||||
description: null,
|
||||
netDays: 30,
|
||||
skontoPercent: defaultSkontoPercent,
|
||||
skontoWithinDays: defaultSkontoDays,
|
||||
};
|
||||
}
|
||||
|
||||
// Per-invoice Skonto opt-out (migration 126). The
|
||||
// `resolveSkontoPercentForInvoice` helper above already respects
|
||||
// this for payment-tracking surfaces, but the PDF render path was
|
||||
// assembling `paymentTerm.skontoPercent/Days` from the snapshot or
|
||||
// global defaults and ignoring the flag — so ticking "Disable
|
||||
// Skonto" on the invoice cleared it from "Paid with Skonto" buttons
|
||||
// but still printed the discount row on the PDF. Zero out both
|
||||
// fields here so pdfService.drawPaymentBlock's
|
||||
// `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays`
|
||||
// guard suppresses the row. The per-customer opt-out (migration 112)
|
||||
// is honoured here too — a customer flagged skonto_disabled never
|
||||
// prints the discount row, mirroring resolveSkontoPercentForInvoice.
|
||||
if (invoice.skonto_disabled || customer?.skonto_disabled) {
|
||||
paymentTerm.skontoPercent = null;
|
||||
paymentTerm.skontoWithinDays = null;
|
||||
}
|
||||
|
||||
// Global date format from Settings → General (general_date_format).
|
||||
// Stored as JSON `{ format, locale }`; missing or malformed entries
|
||||
// fall back to DD.MM.YYYY in the renderer.
|
||||
let dateFormat = null;
|
||||
try {
|
||||
const raw = await getAppSetting('general_date_format');
|
||||
if (raw && typeof raw === 'object' && raw.format) dateFormat = raw;
|
||||
else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
|
||||
} catch (_) { /* fall back to default */ }
|
||||
|
||||
// Sub-cent reconciliation (crm_invoice_round_total). "Betrag Netto"
|
||||
// shows the sum of the visible line totals so it foots with the items;
|
||||
// the stored net may be the clean (rounded-once) value, and the gap is
|
||||
// shown as a "Rundung" row. Legacy/unrounded invoices have equal
|
||||
// values ⇒ adjustment 0, no row. Suppressed on Storno/Mahnung: those
|
||||
// negate the stored net and flip line-total signs at render, so the
|
||||
// forward "storedNet − Σ lines" derivation doesn't apply.
|
||||
const isReversalDoc = invoice.kind === 'storno' || invoice.kind === 'mahnung';
|
||||
const displayedNetMinor = isReversalDoc
|
||||
? ensureInt(invoice.net_amount_minor)
|
||||
: lineItems.reduce(
|
||||
(s, li) => (li.parent_line_item_id == null && (li.parent_position == null || li.parent_position === '')
|
||||
? s + ensureInt(li.line_total_minor) : s),
|
||||
0,
|
||||
);
|
||||
const roundingAdjustmentMinor = isReversalDoc
|
||||
? 0
|
||||
: ensureInt(invoice.net_amount_minor) - displayedNetMinor;
|
||||
|
||||
return {
|
||||
locale: invoice.language || profile?.default_locale || 'de',
|
||||
currency: invoice.currency,
|
||||
qrFormat: resolvedQrFormat,
|
||||
dateFormat,
|
||||
// Shared issuer + recipient builders. Invoices skip the quote-only
|
||||
// payment-block toggles; the invoice PDF always shows the payment
|
||||
// block. See backend/src/services/_renderContext.js.
|
||||
issuer: buildIssuerBlock(profile, resolvedLogoPath),
|
||||
recipient: buildRecipientBlock(profile, customer),
|
||||
bank: bank ? {
|
||||
accountHolder: bank.account_holder || profile?.company_name,
|
||||
iban: bank.iban, bic: bank.bic, currency: bank.currency,
|
||||
} : null,
|
||||
paymentTerm,
|
||||
lineItems: lineItems.map((li) => ({
|
||||
quantity: li.quantity,
|
||||
description: li.description,
|
||||
unitPriceMinor: li.unit_price_minor,
|
||||
discountPercent: li.discount_percent,
|
||||
lineTotalMinor: li.line_total_minor,
|
||||
// Migration 119 — hierarchy + notes flow through to PDF.
|
||||
parentLineItemId: li.parent_line_item_id || null,
|
||||
parentPosition: li.parent_position == null ? null : Number(li.parent_position),
|
||||
detailsText: li.details_text || null,
|
||||
})),
|
||||
totals: {
|
||||
netAmountMinor: displayedNetMinor,
|
||||
roundingAdjustmentMinor,
|
||||
vatRate: invoice.vat_rate,
|
||||
// Migration 130 — VAT-code snapshot (so re-editing preserves it).
|
||||
vatCode: invoice.vat_code ?? null,
|
||||
vatAmountMinor: invoice.vat_amount_minor,
|
||||
shippingAmountMinor: invoice.shipping_amount_minor,
|
||||
totalAmountMinor: invoice.total_amount_minor,
|
||||
// The Mahngebühr is shown on the separate Mahnung document, NEVER on
|
||||
// the (immutable) invoice — so the invoice render always reports 0. The
|
||||
// Mahnung render path (applyReminder) overrides this with the tracked fee.
|
||||
lateFeeAmountMinor: 0,
|
||||
},
|
||||
doc: {
|
||||
// Document type discriminator. `'invoice'` (default) renders
|
||||
// the standard invoice layout. `'storno'` switches the title
|
||||
// to "Stornorechnung", forces the mandatory "Storno zu …"
|
||||
// reference line, displays signed totals, and suppresses the
|
||||
// payment terms / IBAN / QR-bill sections (cancellation
|
||||
// documents aren't payment instruments).
|
||||
kind: invoice.kind || 'invoice',
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
issueDate: invoice.issue_date,
|
||||
dueDate: invoice.due_date,
|
||||
totalAmountMinor: invoice.total_amount_minor,
|
||||
lateFeeMinor: 0,
|
||||
// Reminder level — drives Skonto suppression on second
|
||||
// reminders (no early-payment discount once the customer
|
||||
// is in dunning).
|
||||
reminderLevel: invoice.reminder_level || 0,
|
||||
// PDF renderer draws "Bezug: Angebot Q-..." under the title
|
||||
// when set. Empty/null suppresses the line (standalone invoice).
|
||||
sourceQuoteNumber: sourceQuote?.quote_number || null,
|
||||
// When this invoice replaces a previously-cancelled one
|
||||
// (migration 114, reissue workflow), the renderer stamps a
|
||||
// second reference line: "Bezug: Ersetzt Rechnung R-XXXX vom
|
||||
// DATE".
|
||||
replacesInvoice: await (async () => {
|
||||
if (!invoice.replaces_invoice_id) return null;
|
||||
const prior = await db('invoices')
|
||||
.where({ id: invoice.replaces_invoice_id })
|
||||
.select('invoice_number', 'issue_date').first();
|
||||
return prior
|
||||
? { number: prior.invoice_number, issueDate: prior.issue_date }
|
||||
: null;
|
||||
})(),
|
||||
// Storno reference — populated only on `kind='storno'` rows.
|
||||
// The renderer turns it into the mandatory "Storno zu Rechnung
|
||||
// R-XXXX vom DATE" line under the title. Drives §14c-defensible
|
||||
// traceability: the customer sees explicitly what was reversed.
|
||||
cancelsInvoice: await (async () => {
|
||||
if (!invoice.cancels_invoice_id) return null;
|
||||
const prior = await db('invoices')
|
||||
.where({ id: invoice.cancels_invoice_id })
|
||||
.select('invoice_number', 'issue_date').first();
|
||||
return prior
|
||||
? { number: prior.invoice_number, issueDate: prior.issue_date }
|
||||
: null;
|
||||
})(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function renderInvoicePdfBuffer(invoiceId) {
|
||||
const data = await getInvoiceById(invoiceId);
|
||||
if (!data) throw new AppError('Invoice not found', 404);
|
||||
// Imported (historical) invoices store the original PDF on disk
|
||||
// — short-circuit the renderer and stream the file untouched so
|
||||
// legal documents stay byte-identical to the source. Path is
|
||||
// stored relative to STORAGE_PATH but we accept absolute too.
|
||||
if (data.invoice.imported_pdf_path) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getStoragePath } = require('../../config/storage');
|
||||
const raw = String(data.invoice.imported_pdf_path).trim();
|
||||
const candidates = [
|
||||
path.isAbsolute(raw) ? raw : null,
|
||||
path.join(getStoragePath(), raw.replace(/^\/+/, '')),
|
||||
].filter(Boolean);
|
||||
const found = candidates.find((p) => {
|
||||
try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; }
|
||||
});
|
||||
if (!found) {
|
||||
throw new AppError('Imported invoice PDF is missing on disk', 410);
|
||||
}
|
||||
return fs.readFileSync(found);
|
||||
}
|
||||
const ctx = await buildInvoiceRenderContext(data.invoice, data.lineItems);
|
||||
return await pdfService.renderInvoiceToBuffer(ctx);
|
||||
}
|
||||
|
||||
async function renderInvoicePdfFromPayload(payload) {
|
||||
const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first();
|
||||
const lineItems = Array.isArray(payload.lineItems) ? payload.lineItems : [];
|
||||
// Migration 119 — preview must match the saved-invoice math:
|
||||
// - Compute every row's raw line_total_minor (qty × unit × discount).
|
||||
// - Then resolveParentTotalsFromSubItems rewrites each parent's
|
||||
// line_total to the sum of its priced sub-items (parent's own
|
||||
// unit_price is ignored when any sub-item has a price).
|
||||
// - Net sums TOP-LEVEL items only (parent_position == null).
|
||||
// Without these two steps, the preview shows the parent at 0 and
|
||||
// double-counts sub-items into net, neither of which matches the
|
||||
// values the renderer would produce for the persisted invoice.
|
||||
const items = lineItems.map((li, idx) => {
|
||||
const qty = ensureNumber(li.quantity, 1);
|
||||
const unit = ensureInt(li.unit_price_minor);
|
||||
const discount = ensureNumber(li.discount_percent, 0);
|
||||
const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100));
|
||||
return { ...li, position: li.position || idx + 1, line_total_minor: lineTotal };
|
||||
});
|
||||
const { resolveParentTotalsFromSubItems } = getHierarchyHelpers();
|
||||
resolveParentTotalsFromSubItems(items);
|
||||
let netMinor = 0;
|
||||
for (const it of items) {
|
||||
if (it.parent_position == null || it.parent_position === '') {
|
||||
netMinor += ensureInt(it.line_total_minor);
|
||||
}
|
||||
}
|
||||
// Match the saved-invoice math: clean-net reconciliation when the
|
||||
// crm_invoice_round_total setting is on (see createInvoice).
|
||||
const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true;
|
||||
if (roundTotal) {
|
||||
netMinor = cleanNetMinor(items, { parentKey: 'parent_position', positionKey: 'position' });
|
||||
}
|
||||
const vatRate = ensureNumber(payload.vatRate, 0);
|
||||
const vatMinor = Math.round(netMinor * vatRate / 100);
|
||||
const shippingMinor = ensureInt(payload.shippingAmountMinor);
|
||||
const totalMinor = netMinor + vatMinor + shippingMinor;
|
||||
const fakeInvoice = {
|
||||
invoice_number: 'PREVIEW',
|
||||
customer_account_id: payload.customerAccountId,
|
||||
language: payload.language || customer?.preferred_language || 'de',
|
||||
currency: (payload.currency || 'CHF').toUpperCase(),
|
||||
issue_date: payload.issueDate || new Date().toISOString().slice(0, 10),
|
||||
due_date: payload.dueDate || new Date(Date.now() + 30 * 86400e3).toISOString().slice(0, 10),
|
||||
business_bank_account_id: payload.businessBankAccountId,
|
||||
qr_format: payload.qrFormat,
|
||||
net_amount_minor: netMinor,
|
||||
vat_rate: vatRate,
|
||||
vat_amount_minor: vatMinor,
|
||||
shipping_amount_minor: shippingMinor,
|
||||
total_amount_minor: totalMinor,
|
||||
};
|
||||
const ctx = await buildInvoiceRenderContext(fakeInvoice, items);
|
||||
return await pdfService.renderInvoiceToBuffer(ctx);
|
||||
}
|
||||
module.exports = {
|
||||
buildInvoiceRenderContext,
|
||||
renderInvoicePdfBuffer,
|
||||
renderInvoicePdfFromPayload,
|
||||
};
|
||||
@@ -1,173 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
const { queuePaymentCheckEmail } = require('./payments');
|
||||
const { sendInvoice } = require('./sending');
|
||||
|
||||
|
||||
/**
|
||||
* Cron tick — find scheduled invoices ready to send + invoices past
|
||||
* due date that need a reminder. Called by invoiceSchedulerService.
|
||||
*/
|
||||
async function runScheduledTasks() {
|
||||
const now = new Date();
|
||||
|
||||
// 1. Flush scheduled invoices.
|
||||
const ready = await db('invoices')
|
||||
.where({ status: 'scheduled' })
|
||||
.andWhere(function() {
|
||||
this.whereNotNull('scheduled_send_at').andWhere('scheduled_send_at', '<=', now);
|
||||
})
|
||||
.limit(20);
|
||||
for (const inv of ready) {
|
||||
try {
|
||||
await sendInvoice(inv.id, null);
|
||||
} catch (err) {
|
||||
logger.error('Scheduled invoice send failed', { invoiceId: inv.id, err: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Monthly-bill issuance (migration 128).
|
||||
//
|
||||
// Walk every monthly draft whose period_end is today-or-earlier.
|
||||
// - If the draft has zero line items, skip silently (empty month
|
||||
// per user spec — no invoice issued, no email, just a log).
|
||||
// - Otherwise flip is_monthly_draft=false and arm scheduled_send_at
|
||||
// to `now` so the next flush-pass picks it up and runs the
|
||||
// standard sendInvoice path. Keeping the issuance one tick away
|
||||
// from this pass means email queueing + activity log + dunning
|
||||
// schedule all stay on the existing well-trodden code paths
|
||||
// instead of duplicating logic here.
|
||||
const monthlyToday = new Date(now);
|
||||
monthlyToday.setHours(0, 0, 0, 0);
|
||||
const dueDrafts = await db('invoices')
|
||||
.where({ is_monthly_draft: true })
|
||||
.andWhere('monthly_period_end', '<=', monthlyToday.toISOString().slice(0, 10))
|
||||
.limit(50);
|
||||
for (const draft of dueDrafts) {
|
||||
try {
|
||||
const items = await db('invoice_line_items').where({ invoice_id: draft.id }).limit(1);
|
||||
if (items.length === 0) {
|
||||
// Empty month — leave the draft alone (admin may still add
|
||||
// items between now and end-of-day) OR mark it consumed so
|
||||
// the next save creates a fresh period draft. We pick the
|
||||
// latter: clear is_monthly_draft so the next createInvoice
|
||||
// for this customer mints a new period.
|
||||
//
|
||||
// Status is 'skipped', not 'cancelled': the latter implies
|
||||
// an admin (or Storno) deliberately voided a real invoice;
|
||||
// an empty monthly period is a "nothing happened" non-event
|
||||
// that we still record for audit-trail continuity. Listing
|
||||
// queries that aggregate cancelled rows (e.g. the Bills list
|
||||
// cancellation footnote) should not pull skipped rows in.
|
||||
await db('invoices').where({ id: draft.id }).update({
|
||||
is_monthly_draft: false,
|
||||
status: 'skipped',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
logger.info('Monthly bill skipped — no items queued', {
|
||||
invoiceId: draft.id, customerId: draft.customer_account_id,
|
||||
});
|
||||
try {
|
||||
await logActivity('monthly_bill_skipped_empty',
|
||||
{ invoiceId: draft.id, customerId: draft.customer_account_id },
|
||||
null, 'scheduler');
|
||||
} catch (_) {}
|
||||
continue;
|
||||
}
|
||||
// Arm for the flush pass: clear the draft flag, set the send
|
||||
// time to now, recompute due_date from issue_date + the global
|
||||
// crm_invoices_net_days_default (best-effort; admin can override
|
||||
// by editing the draft before the cadence day).
|
||||
const issueDate = monthlyToday.toISOString().slice(0, 10);
|
||||
await db('invoices').where({ id: draft.id }).update({
|
||||
is_monthly_draft: false,
|
||||
issue_date: issueDate,
|
||||
scheduled_send_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
try {
|
||||
await logActivity('monthly_bill_issued',
|
||||
{ invoiceId: draft.id, customerId: draft.customer_account_id,
|
||||
periodEnd: draft.monthly_period_end },
|
||||
null, 'scheduler');
|
||||
} catch (_) {}
|
||||
} catch (err) {
|
||||
logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Overdue payment-check prompts (if reminders enabled).
|
||||
//
|
||||
// NEW behavior (migration 115/116): instead of auto-firing the
|
||||
// customer reminder when an invoice goes overdue, we email the
|
||||
// ADMIN with three signed-token action buttons:
|
||||
// - Paid in full → markPaid for the outstanding amount
|
||||
// - Partial → admin enters amount; partial + reminder
|
||||
// - Not paid yet → reminder fires (with Mahngebühr at level 2)
|
||||
//
|
||||
// The reminder thresholds still gate when the prompt fires:
|
||||
// - level 0 invoice past firstCutoff → prompt for level-1 path
|
||||
// - level 1 invoice past secondCutoff → prompt for level-2 path
|
||||
// Throttled to one email per 24h per invoice via
|
||||
// invoices.last_payment_check_at.
|
||||
const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled');
|
||||
// Mutual exclusion with the workflow engine: the hardcoded ladder stands down
|
||||
// only when the invoice_dunning built-in is ENABLED (then the engine fires the
|
||||
// payment-check emails). A disabled built-in leaves this ladder running — so
|
||||
// the flow can ship disabled without dunning going dark, and disabling the
|
||||
// flow reverts to the ladder. Fails closed → ladder stays on if the subsystem
|
||||
// is down.
|
||||
let engineDrivesDunning = false;
|
||||
try {
|
||||
engineDrivesDunning = await require('../workflows').isBuiltinFlowActive('invoice_dunning');
|
||||
} catch (_) { /* workflows tables absent / flag system down → ladder stays on */ }
|
||||
if (remindersEnabled !== false && !engineDrivesDunning) {
|
||||
const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
|
||||
const secondDays = ensureInt(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
|
||||
|
||||
const firstCutoff = new Date(now.getTime() - firstDays * 86400000);
|
||||
const secondCutoff = new Date(now.getTime() - secondDays * 86400000);
|
||||
|
||||
// Pre-reminder check (would-be-level-1).
|
||||
// `kind='invoice'` filter keeps Stornorechnungen out of the
|
||||
// dunning ladder — they have no due_date and no payment
|
||||
// expectation; reminding on them would be a customer-facing
|
||||
// bug.
|
||||
const firstBatch = await db('invoices')
|
||||
.where('kind', 'invoice')
|
||||
.whereIn('status', ['sent', 'overdue'])
|
||||
.where('reminder_level', 0)
|
||||
.where('due_date', '<=', firstCutoff)
|
||||
.limit(20);
|
||||
for (const inv of firstBatch) {
|
||||
try {
|
||||
await queuePaymentCheckEmail(inv.id);
|
||||
} catch (err) {
|
||||
logger.error('Payment-check email failed', { invoiceId: inv.id, err: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-reminder check (would-be-level-2, including Mahngebühr).
|
||||
const secondBatch = await db('invoices')
|
||||
.where('kind', 'invoice')
|
||||
.whereIn('status', ['sent', 'overdue'])
|
||||
.where('reminder_level', 1)
|
||||
.where('due_date', '<=', secondCutoff)
|
||||
.limit(20);
|
||||
for (const inv of secondBatch) {
|
||||
try {
|
||||
await queuePaymentCheckEmail(inv.id);
|
||||
} catch (err) {
|
||||
logger.error('Payment-check email (level 2) failed', { invoiceId: inv.id, err: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = {
|
||||
runScheduledTasks,
|
||||
};
|
||||
@@ -1,654 +0,0 @@
|
||||
// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { formatShortDate } = require('../../utils/dateFormatter');
|
||||
const { resolveBillingRecipients } = require('../_billingRecipients');
|
||||
const pdfService = require('../pdfService');
|
||||
const emailProcessor = require('../emailProcessor');
|
||||
const { ensureInt, ensureNumber } = require('../../utils/numericHelpers');
|
||||
const { computeDueDate, ensureCustomerCanBill, formatMajor, getHierarchyHelpers, nextInvoiceNumber, resolveNetDaysForRow } = require('./helpers');
|
||||
const { getInvoiceById } = require('./queries');
|
||||
const { createInvoice } = require('./create');
|
||||
const { buildInvoiceRenderContext } = require('./render');
|
||||
|
||||
|
||||
/**
|
||||
* Send an invoice email + PDF. Flips status scheduled → sent.
|
||||
*/
|
||||
async function sendInvoice(id, adminId) {
|
||||
const data = await getInvoiceById(id);
|
||||
if (!data) throw new AppError('Invoice not found', 404);
|
||||
const { invoice, lineItems } = data;
|
||||
// Stornorechnungen go through their own send path — different
|
||||
// email template, different variables, different PDF render
|
||||
// branch. The scheduler's flush loop hits this entry point for
|
||||
// every row in status='scheduled', so the dispatch lives here.
|
||||
if (invoice.kind === 'storno') {
|
||||
return await sendStorno(id, adminId);
|
||||
}
|
||||
if (!['scheduled', 'sent', 'overdue'].includes(invoice.status)) {
|
||||
throw new AppError(`Cannot send invoice with status '${invoice.status}'`, 409);
|
||||
}
|
||||
// Monthly-draft guard (migration 128). Rows flagged
|
||||
// is_monthly_draft=true accumulate line items across the period
|
||||
// and must ONLY be issued via triggerMonthlyBillNow / the scheduled
|
||||
// monthly flush — both clear the flag before re-entering this
|
||||
// function. Without this guard, admin clicks on a draft's Send
|
||||
// button would ship the running accumulator early AND leave the
|
||||
// flag set, so subsequent createInvoice calls would silently
|
||||
// append onto the same already-sent row.
|
||||
if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) {
|
||||
throw new AppError(
|
||||
'This invoice is a monthly draft — use "Trigger invoice now" on the customer detail page, or wait for the scheduled cycle day.',
|
||||
409, 'MONTHLY_DRAFT_NOT_SENDABLE',
|
||||
);
|
||||
}
|
||||
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
|
||||
ensureCustomerCanBill(customer);
|
||||
|
||||
// Re-sync the invoice's language from the customer's current
|
||||
// preferred_language at send time when the invoice has never been
|
||||
// sent. Picks up admin language changes made between create and
|
||||
// send (notable for monthly drafts that accumulate for ~30 days,
|
||||
// and for any standalone scheduled invoice where admin updated the
|
||||
// customer record after authoring). Sent / overdue invoices keep
|
||||
// their existing language because they're legal records — the
|
||||
// rendered PDF is the source of truth from the moment it ships.
|
||||
if (invoice.status === 'scheduled' && customer.preferred_language
|
||||
&& customer.preferred_language !== invoice.language) {
|
||||
await db('invoices').where({ id }).update({
|
||||
language: customer.preferred_language,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
invoice.language = customer.preferred_language;
|
||||
}
|
||||
|
||||
// Stamp the issue date at the moment the invoice actually goes out.
|
||||
// A scheduled invoice's issue_date is provisional — set to the
|
||||
// authoring day at creation — but the legal issue date is when it
|
||||
// ships. Anchoring it here keeps the printed invoice date, the Skonto
|
||||
// window (a relative "pay within N working days" counted from that
|
||||
// date) and the net-days due date all consistent with the send date.
|
||||
// Only on the first send (status 'scheduled'); 'sent' / 'overdue'
|
||||
// rows are immutable legal records and keep their stamped date.
|
||||
if (invoice.status === 'scheduled') {
|
||||
const sendDateIso = new Date().toISOString().slice(0, 10);
|
||||
const netDays = await resolveNetDaysForRow(invoice);
|
||||
// Re-anchor the due date too, but only when it was machine-set: if
|
||||
// the stored due_date still equals the auto formula off the OLD
|
||||
// base (scheduled_send_at, else the old issue_date), the admin never
|
||||
// hand-edited it and we slide it to the new issue date. A divergent
|
||||
// value means a manual override (the editor's "Override due date"
|
||||
// toggle) — leave it untouched.
|
||||
const oldBase = invoice.scheduled_send_at
|
||||
? new Date(invoice.scheduled_send_at)
|
||||
: new Date(invoice.issue_date);
|
||||
const oldAutoDue = computeDueDate(oldBase, netDays).toISOString().slice(0, 10);
|
||||
const storedDue = invoice.due_date
|
||||
? new Date(invoice.due_date).toISOString().slice(0, 10)
|
||||
: null;
|
||||
const updates = { issue_date: sendDateIso, updated_at: new Date() };
|
||||
if (storedDue && storedDue === oldAutoDue) {
|
||||
updates.due_date = computeDueDate(new Date(sendDateIso), netDays).toISOString().slice(0, 10);
|
||||
}
|
||||
await db('invoices').where({ id }).update(updates);
|
||||
invoice.issue_date = updates.issue_date;
|
||||
if (updates.due_date) invoice.due_date = updates.due_date;
|
||||
}
|
||||
|
||||
const ctx = await buildInvoiceRenderContext(invoice, lineItems);
|
||||
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
|
||||
|
||||
// Persist PDF snapshot.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const year = new Date(invoice.issue_date).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const pdfPath = path.join(root, `${invoice.invoice_number}.pdf`);
|
||||
fs.writeFileSync(pdfPath, buffer);
|
||||
|
||||
const newStatus = invoice.status === 'overdue' ? 'overdue' : 'sent';
|
||||
await db('invoices').where({ id }).update({
|
||||
status: newStatus, sent_at: new Date(), pdf_path: pdfPath, updated_at: new Date(),
|
||||
});
|
||||
|
||||
const { to: invoiceTo, cc: invoiceCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, invoiceTo, 'invoice_sent', {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
event_name: invoice.event_name || '',
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale),
|
||||
due_date: formatShortDate(invoice.due_date),
|
||||
installment_label: invoice.installment_label || '',
|
||||
installment_index: invoice.installment_index + 1,
|
||||
installment_total: invoice.installment_total,
|
||||
cc: invoiceCc,
|
||||
attachments: [{
|
||||
filename: `${invoice.invoice_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
contentType: 'application/pdf',
|
||||
}],
|
||||
});
|
||||
|
||||
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {}
|
||||
|
||||
// Fire the workflow engine's invoice.sent trigger (after the row is updated +
|
||||
// the email queued). Idempotent per invoice id; no-op when the workflows flag
|
||||
// is off. Never throws into the send path.
|
||||
try {
|
||||
await require('../workflows').emitWorkflowEvent('invoice.sent', {
|
||||
entityType: 'invoice',
|
||||
entityId: id,
|
||||
payload: {
|
||||
invoiceId: id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
eventId: invoice.event_id || null,
|
||||
customerAccountId: invoice.customer_account_id,
|
||||
customerEmail: invoiceTo,
|
||||
dueDate: invoice.due_date,
|
||||
issueDate: invoice.issue_date,
|
||||
totalMinor: invoice.total_amount_minor,
|
||||
currency: invoice.currency,
|
||||
},
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
return { sent: true, pdfPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise a Stornorechnung (cancellation invoice) for an already-
|
||||
* issued original. Atomic:
|
||||
* 1. Insert a new `invoices` row with `kind='storno'`, totals
|
||||
* negated, no due_date / payment terms / bank account / QR,
|
||||
* and `cancels_invoice_id` pointing at the original.
|
||||
* 2. Snapshot the original's line items at full positive amounts
|
||||
* (the sign is carried by the row-level totals; the renderer
|
||||
* flips line totals visually for `kind='storno'`). Preserves
|
||||
* the migration-119 sub-item hierarchy via parent_position →
|
||||
* parent_line_item_id resolution in `insertLineItemsHierarchical`.
|
||||
* 3. Flip the original to `status='cancelled'` and pin its
|
||||
* `cancellation_storno_id` so the admin detail view can render
|
||||
* a "Cancelled by Storno S-XXXX" banner.
|
||||
*
|
||||
* Returns the Storno's id. The caller is responsible for actually
|
||||
* sending it (sendStorno) — splitting the create/send seam means
|
||||
* a failed PDF render or email queue doesn't roll back the
|
||||
* cancellation itself; the storno sits in `status='scheduled'`
|
||||
* and the cron picks it up.
|
||||
*/
|
||||
async function createStorno(originalId, adminId, trx = db) {
|
||||
const original = await trx('invoices').where({ id: originalId }).first();
|
||||
if (!original) throw new AppError('Invoice not found', 404);
|
||||
if (original.kind === 'storno') {
|
||||
throw new AppError('Cannot Storno a Storno', 409, 'IS_STORNO');
|
||||
}
|
||||
if (original.status === 'scheduled') {
|
||||
throw new AppError(
|
||||
'This invoice has not been sent yet — Storno only applies to issued documents.',
|
||||
409,
|
||||
'USE_EDIT_INSTEAD',
|
||||
);
|
||||
}
|
||||
if (original.status === 'cancelled') {
|
||||
throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED');
|
||||
}
|
||||
|
||||
// Generate the Storno's sequence number from the same gap-free
|
||||
// series as regular invoices (single sequence — decision locked
|
||||
// with the maintainer; satisfies §14 (4) Nr. 4 UStG).
|
||||
// Pass trx so the sequence claim joins the caller's transaction —
|
||||
// SQLite deadlocks otherwise (1-connection default).
|
||||
const stornoNumber = await nextInvoiceNumber(trx);
|
||||
const now = new Date();
|
||||
const issueDate = now.toISOString().slice(0, 10);
|
||||
|
||||
// Insert the Storno row. Totals negated for accounting integrity
|
||||
// (tax report aggregates by row-level totals, so a Storno
|
||||
// contributes correctly without the renderer needing to flip
|
||||
// signs at report time). Line items below stay positive — the
|
||||
// renderer applies the sign at presentation time.
|
||||
const insertedRow = await trx('invoices').insert({
|
||||
kind: 'storno',
|
||||
invoice_number: stornoNumber,
|
||||
customer_account_id: original.customer_account_id,
|
||||
event_id: original.event_id,
|
||||
// Inline event snapshot — copy so the Storno carries the same
|
||||
// event label as the invoice it reverses (migration 123). The
|
||||
// bookkeeper expects to see both documents under the same event.
|
||||
event_name: original.event_name || null,
|
||||
event_date: original.event_date || null,
|
||||
event_time_start: original.event_time_start || null,
|
||||
event_time_end: original.event_time_end || null,
|
||||
source_quote_id: null,
|
||||
// Migration 124 — carry the split FKs through onto the Storno row
|
||||
// so the lineage stays consistent if anyone audits the
|
||||
// cancellation document and checks the picker state.
|
||||
payment_net_days_template_id: original.payment_net_days_template_id || null,
|
||||
payment_timing_template_id: original.payment_timing_template_id || null,
|
||||
currency: original.currency,
|
||||
language: original.language,
|
||||
vat_rate: original.vat_rate,
|
||||
// Migration 130 — carry the original's VAT-code snapshot onto the Storno so
|
||||
// both documents export the same code. Conditional spread = safe on pre-130
|
||||
// DBs (undefined → omitted).
|
||||
...(original.vat_code ? { vat_code: original.vat_code } : {}),
|
||||
shipping_amount_minor: -ensureInt(original.shipping_amount_minor || 0),
|
||||
net_amount_minor: -ensureInt(original.net_amount_minor),
|
||||
vat_amount_minor: -ensureInt(original.vat_amount_minor),
|
||||
total_amount_minor: -ensureInt(original.total_amount_minor),
|
||||
late_fee_amount_minor: 0,
|
||||
paid_amount_minor: 0,
|
||||
status: 'scheduled',
|
||||
scheduled_send_at: now,
|
||||
issue_date: issueDate,
|
||||
// Storni have no payment due — mirror issue_date to satisfy the
|
||||
// schema's NOT NULL constraint on due_date. The field is dead data
|
||||
// for kind='storno' rows: the PDF renderer suppresses the due-date
|
||||
// line, and the dunning scheduler filters kind='invoice'.
|
||||
due_date: issueDate,
|
||||
reminder_level: 0,
|
||||
cc_pdf_email: original.cc_pdf_email,
|
||||
// No payment block on a Storno — it's not a payment instrument.
|
||||
business_bank_account_id: null,
|
||||
qr_format: null,
|
||||
payment_term_template_id: null,
|
||||
// Lineage.
|
||||
cancels_invoice_id: original.id,
|
||||
replaces_invoice_id: null,
|
||||
cancellation_storno_id: null,
|
||||
// Migration 140 — Storno belongs to the same deal as the invoice
|
||||
// it cancels; both render together in the lineage view.
|
||||
deal_uuid: original.deal_uuid || crypto.randomUUID(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}).returning('id');
|
||||
const stornoId = Array.isArray(insertedRow)
|
||||
? (insertedRow[0]?.id ?? insertedRow[0])
|
||||
: insertedRow;
|
||||
|
||||
// Snapshot the original's line items (positive amounts — the
|
||||
// Storno's sign convention lives on the row-level totals + the
|
||||
// renderer flip).
|
||||
const lineItems = await trx('invoice_line_items as li')
|
||||
.leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id')
|
||||
.where('li.invoice_id', originalId)
|
||||
.orderBy('li.position', 'asc')
|
||||
.select('li.*', 'parent.position as parent_position');
|
||||
if (lineItems.length > 0) {
|
||||
const cloned = lineItems.map((li) => ({
|
||||
position: ensureInt(li.position),
|
||||
quantity: li.quantity,
|
||||
description: li.description,
|
||||
unit_price_minor: ensureInt(li.unit_price_minor),
|
||||
discount_percent: ensureNumber(li.discount_percent, 0),
|
||||
line_total_minor: ensureInt(li.line_total_minor),
|
||||
parent_position: li.parent_position == null ? null : ensureInt(li.parent_position),
|
||||
details_text: li.details_text || null,
|
||||
}));
|
||||
const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers();
|
||||
validateLineItemHierarchy(cloned);
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', stornoId, cloned);
|
||||
}
|
||||
|
||||
// Flip the original to cancelled + link the Storno.
|
||||
await trx('invoices').where({ id: originalId }).update({
|
||||
status: 'cancelled',
|
||||
cancellation_storno_id: stornoId,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('invoice_cancelled_via_storno',
|
||||
{ invoiceId: originalId, stornoId, stornoNumber },
|
||||
original.event_id || null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
|
||||
return stornoId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Stornorechnung — renders the PDF, persists it on disk,
|
||||
* flips the row to `status='sent'`, and queues the `storno_issued`
|
||||
* email to the customer with the PDF attached.
|
||||
*
|
||||
* Mirrors sendInvoice's shape so the scheduler's flush loop can
|
||||
* delegate uniformly. The email template ships in Phase 3
|
||||
* (renames the dormant `invoice_cancelled` seed); if the worker
|
||||
* picks up the job before the template lands it logs the missing
|
||||
* template — the row stays in `sent` either way.
|
||||
*/
|
||||
async function sendStorno(stornoId, adminId) {
|
||||
const data = await getInvoiceById(stornoId);
|
||||
if (!data) throw new AppError('Storno not found', 404);
|
||||
const { invoice: storno, lineItems } = data;
|
||||
if (storno.kind !== 'storno') {
|
||||
throw new AppError(`Expected kind='storno', got '${storno.kind}'`, 409);
|
||||
}
|
||||
if (storno.status === 'sent') return { status: 'sent' };
|
||||
|
||||
const customer = await db('customer_accounts').where({ id: storno.customer_account_id }).first();
|
||||
ensureCustomerCanBill(customer);
|
||||
|
||||
const ctx = await buildInvoiceRenderContext(storno, lineItems);
|
||||
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
|
||||
|
||||
// Persist PDF snapshot alongside regular invoices.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const year = new Date(storno.issue_date).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const pdfPath = path.join(root, `${storno.invoice_number}.pdf`);
|
||||
fs.writeFileSync(pdfPath, buffer);
|
||||
|
||||
await db('invoices').where({ id: stornoId }).update({
|
||||
status: 'sent',
|
||||
sent_at: new Date(),
|
||||
pdf_path: pdfPath,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
// Look up the original so we can include both numbers in the
|
||||
// email body — customers' bookkeepers expect to see the pair.
|
||||
const originalRow = storno.cancels_invoice_id
|
||||
? await db('invoices').where({ id: storno.cancels_invoice_id })
|
||||
.select('invoice_number', 'issue_date').first()
|
||||
: null;
|
||||
|
||||
const { to: stornoTo, cc: stornoCc } = resolveBillingRecipients(customer, storno.cc_pdf_email);
|
||||
await emailProcessor.queueEmail(storno.event_id || null, stornoTo, 'storno_issued', {
|
||||
storno_number: storno.invoice_number,
|
||||
original_invoice_number: originalRow?.invoice_number || '',
|
||||
original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '',
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale),
|
||||
cc: stornoCc,
|
||||
attachments: [{
|
||||
filename: `${storno.invoice_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
contentType: 'application/pdf',
|
||||
}],
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('storno_sent',
|
||||
{ stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null },
|
||||
storno.event_id || null, `admin:${adminId || 'system'}`);
|
||||
} catch (_) {}
|
||||
|
||||
return { status: 'sent', stornoId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue an invoice — the legally-correct alternative to post-send
|
||||
* editing.
|
||||
* 1. If the original is still live (sent / overdue / paid),
|
||||
* generate a Stornorechnung for it via `createStorno` and
|
||||
* immediately send it to the customer (sendStorno). The
|
||||
* original flips to `status='cancelled'` and its
|
||||
* `cancellation_storno_id` is pinned.
|
||||
* 2. Create a fresh `scheduled` invoice with a new sequence
|
||||
* number, line items snapshotted from the original, and
|
||||
* `replaces_invoice_id` pointing at the original so the
|
||||
* renderer can stamp "Bezug: Ersetzt Rechnung R-XXXX".
|
||||
*
|
||||
* If the original is ALREADY cancelled (admin previously cancelled
|
||||
* it via Storno on its own), the cancel step is skipped — only the
|
||||
* replacement is created. `scheduled` originals are rejected
|
||||
* (USE_EDIT_INSTEAD) since drafts don't need legal cancellation.
|
||||
*/
|
||||
async function reissueInvoice(id, adminId) {
|
||||
const original = await db('invoices').where({ id }).first();
|
||||
if (!original) throw new AppError('Invoice not found', 404);
|
||||
if (original.kind === 'storno') {
|
||||
throw new AppError('Cannot reissue a Storno document', 409, 'IS_STORNO');
|
||||
}
|
||||
if (original.status === 'scheduled') {
|
||||
throw new AppError(
|
||||
'This invoice has not been sent yet — use Edit instead of Cancel & reissue.',
|
||||
409,
|
||||
'USE_EDIT_INSTEAD',
|
||||
);
|
||||
}
|
||||
|
||||
// Cancel via Storno first if still live. We deliberately commit
|
||||
// the Storno BEFORE creating the replacement so a failed sendStorno
|
||||
// doesn't roll back the cancellation; the storno sits in
|
||||
// status='scheduled' and the cron picks it up. Same resiliency
|
||||
// contract as cancelInvoice.
|
||||
let stornoId = null;
|
||||
if (original.status !== 'cancelled') {
|
||||
stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx));
|
||||
try { await sendStorno(stornoId, adminId); } catch (err) {
|
||||
logger.warn('sendStorno during reissue failed — scheduler will retry', { stornoId, err: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Build the replacement. Same shape as the original — re-uses
|
||||
// createInvoice so totals are recomputed authoritatively from
|
||||
// line items (any rounding drift gets normalised). Self-join
|
||||
// carries parent_position so migration-119 sub-items survive.
|
||||
return await db.transaction(async (trx) => {
|
||||
const lineItems = await trx('invoice_line_items as li')
|
||||
.leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id')
|
||||
.where('li.invoice_id', id)
|
||||
.orderBy('li.position', 'asc')
|
||||
.select('li.*', 'parent.position as parent_position');
|
||||
const liPayload = lineItems.map((li) => ({
|
||||
position: li.position,
|
||||
quantity: Number(li.quantity),
|
||||
description: li.description,
|
||||
unit_price_minor: Number(li.unit_price_minor),
|
||||
discount_percent: Number(li.discount_percent || 0),
|
||||
parent_position: li.parent_position == null ? null : Number(li.parent_position),
|
||||
details_text: li.details_text || null,
|
||||
}));
|
||||
|
||||
const { invoiceIds: reissuedIds } = await createInvoice({
|
||||
customerAccountId: original.customer_account_id,
|
||||
sourceQuoteId: original.source_quote_id || null,
|
||||
eventId: original.event_id || null,
|
||||
language: original.language,
|
||||
currency: original.currency,
|
||||
vatRate: original.vat_rate,
|
||||
shippingAmountMinor: original.shipping_amount_minor,
|
||||
ccPdfEmail: original.cc_pdf_email,
|
||||
businessBankAccountId: original.business_bank_account_id,
|
||||
qrFormat: original.qr_format,
|
||||
paymentTermTemplateId: original.payment_term_template_id,
|
||||
// Reissue always produces a standalone invoice even when the
|
||||
// customer is on monthly billing — folding the reissued items
|
||||
// into the current period's running draft would conflate two
|
||||
// unrelated billing periods. The escape hatch keeps the
|
||||
// standard createInvoice flow.
|
||||
_skipMonthlyRouting: true,
|
||||
// Carry the split picker (migration 124) + event snapshot
|
||||
// (migration 123) onto the reissued draft so the admin doesn't
|
||||
// have to re-set them after a Cancel & reissue. createInvoice
|
||||
// already accepts these on both code paths.
|
||||
paymentNetDaysTemplateId: original.payment_net_days_template_id || null,
|
||||
paymentTimingTemplateId: original.payment_timing_template_id || null,
|
||||
eventName: original.event_name || null,
|
||||
eventDate: original.event_date || null,
|
||||
eventTimeStart: original.event_time_start || null,
|
||||
eventTimeEnd: original.event_time_end || null,
|
||||
// No installment metadata — reissue defaults to a single
|
||||
// standalone invoice. If the admin needs the same split they
|
||||
// can run the original conversion again from the quote.
|
||||
lineItems: liPayload,
|
||||
// Migration 140 — reissue inherits the cancelled original's
|
||||
// deal_uuid so Storno + replacement + cancelled all group
|
||||
// under one deal lineage view.
|
||||
dealUuid: original.deal_uuid || null,
|
||||
}, adminId, trx);
|
||||
// Reissue always produces a single invoice (no installments
|
||||
// forced), so the array length is 1.
|
||||
const newId = reissuedIds[0];
|
||||
|
||||
await trx('invoices').where({ id: newId }).update({
|
||||
replaces_invoice_id: id,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('invoice_reissued',
|
||||
{ originalInvoiceId: id, newInvoiceId: newId, stornoId },
|
||||
original.event_id || null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
|
||||
return { id: newId, replaces: id, stornoId };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a `pending_delivery` invoice for sending. Used when the
|
||||
* photographer has actually delivered the photos and is ready to
|
||||
* collect the final installment — flips the status to `scheduled`
|
||||
* with `scheduled_send_at = now`, then immediately calls sendInvoice
|
||||
* so the email goes out without waiting for the next scheduler tick.
|
||||
*
|
||||
* Refuses to act on rows that aren't pending — admins should use
|
||||
* sendInvoice / sendReminder for the normal `scheduled`/`sent` flow.
|
||||
*/
|
||||
async function releaseForDelivery(id, adminId) {
|
||||
const invoice = await db('invoices').where({ id }).first();
|
||||
if (!invoice) throw new AppError('Invoice not found', 404);
|
||||
if (invoice.status !== 'pending_delivery') {
|
||||
throw new AppError(
|
||||
`Invoice is not awaiting delivery (status: '${invoice.status}')`,
|
||||
409,
|
||||
'NOT_PENDING_DELIVERY',
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
await db('invoices').where({ id }).update({
|
||||
status: 'scheduled',
|
||||
scheduled_send_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
try {
|
||||
await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
// Fire immediately rather than waiting for the next scheduler
|
||||
// tick — admin clicked the button because they want it out now.
|
||||
return await sendInvoice(id, adminId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an invoice. The behaviour depends on whether the document
|
||||
* was ever issued:
|
||||
*
|
||||
* - `scheduled` (draft, no PDF emitted): soft cancel — status
|
||||
* flips to 'cancelled', nothing leaves the system. No Storno is
|
||||
* generated because no document exists for the customer to
|
||||
* reverse.
|
||||
*
|
||||
* - `sent` / `overdue` / `paid` (issued): generate a
|
||||
* Stornorechnung (cancellation invoice) with its own sequence
|
||||
* number, attach a signed PDF, and email it to the customer.
|
||||
* Original flips to 'cancelled' and pins its
|
||||
* `cancellation_storno_id` for the admin lineage view. This is
|
||||
* the only §14c-defensible cancellation path under DACH tax law
|
||||
* once an invoice has been delivered to the recipient.
|
||||
*
|
||||
* Note we allow `paid` here on purpose — bookkeepers cancel
|
||||
* paid invoices when issuing refunds. The actual money
|
||||
* movement (refund, carry-forward as Anzahlung) is handled
|
||||
* separately; the Storno is the document leg.
|
||||
*
|
||||
* - `cancelled` (already): 409, `ALREADY_CANCELLED`.
|
||||
*
|
||||
* Returns `{ cancelled: true, stornoId? }` so the caller can
|
||||
* surface "Storno S-XXXX wurde erzeugt" feedback when applicable.
|
||||
*/
|
||||
async function cancelInvoice(id, adminId) {
|
||||
const invoice = await db('invoices').where({ id }).first();
|
||||
if (!invoice) throw new AppError('Invoice not found', 404);
|
||||
if (invoice.kind === 'storno') {
|
||||
throw new AppError('Cannot cancel a Storno document', 409, 'IS_STORNO');
|
||||
}
|
||||
if (invoice.status === 'cancelled') {
|
||||
throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED');
|
||||
}
|
||||
|
||||
// Draft path: nothing was issued, soft cancel and we're done.
|
||||
if (invoice.status === 'scheduled') {
|
||||
await db('invoices').where({ id }).update({
|
||||
status: 'cancelled', updated_at: new Date(),
|
||||
});
|
||||
try {
|
||||
await logActivity('invoice_cancelled',
|
||||
{ invoiceId: id, viaStorno: false },
|
||||
invoice.event_id || null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
return { cancelled: true, stornoId: null };
|
||||
}
|
||||
|
||||
// Issued path: Storno required. Commit createStorno in its own
|
||||
// transaction so a failed sendStorno doesn't roll back the
|
||||
// cancellation; the scheduler picks up an unsent Storno on the
|
||||
// next tick.
|
||||
const stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx));
|
||||
try { await sendStorno(stornoId, adminId); } catch (err) {
|
||||
logger.warn('sendStorno after cancelInvoice failed — scheduler will retry', { stornoId, err: err.message });
|
||||
}
|
||||
return { cancelled: true, stornoId };
|
||||
}
|
||||
|
||||
async function triggerMonthlyBillNow(customerId, adminId) {
|
||||
const draft = await db('invoices')
|
||||
.where({ customer_account_id: customerId, is_monthly_draft: true })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
if (!draft) {
|
||||
throw new AppError('No pending monthly bill for this customer', 409, 'NO_MONTHLY_DRAFT');
|
||||
}
|
||||
const items = await db('invoice_line_items').where({ invoice_id: draft.id }).limit(1);
|
||||
if (items.length === 0) {
|
||||
throw new AppError('Monthly draft is empty — nothing to bill', 409, 'EMPTY_DRAFT');
|
||||
}
|
||||
|
||||
// Arm the draft: clear the discriminator, pin issue_date to today,
|
||||
// and set scheduled_send_at to now so the flush pass + sendInvoice
|
||||
// path treats it like any other ready-to-send invoice. Logged as a
|
||||
// distinct activity so the audit trail shows admin override vs the
|
||||
// scheduler's automatic fire.
|
||||
const issueDate = new Date().toISOString().slice(0, 10);
|
||||
await db('invoices').where({ id: draft.id }).update({
|
||||
is_monthly_draft: false,
|
||||
issue_date: issueDate,
|
||||
scheduled_send_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
try {
|
||||
await logActivity('monthly_bill_triggered_manually',
|
||||
{ invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
|
||||
// Inline send so admin gets immediate feedback (PDF stored, status
|
||||
// flipped to 'sent', email queued). A failure here doesn't roll
|
||||
// back the arming — the scheduler will pick it up on the next tick.
|
||||
try {
|
||||
await sendInvoice(draft.id, adminId);
|
||||
} catch (err) {
|
||||
logger.warn('triggerMonthlyBillNow: inline send failed — scheduler will retry',
|
||||
{ invoiceId: draft.id, err: err.message });
|
||||
}
|
||||
return { invoiceId: draft.id, invoiceNumber: draft.invoice_number };
|
||||
}
|
||||
module.exports = {
|
||||
sendInvoice,
|
||||
createStorno,
|
||||
sendStorno,
|
||||
reissueInvoice,
|
||||
releaseForDelivery,
|
||||
cancelInvoice,
|
||||
triggerMonthlyBillNow,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* mfaService — TOTP (RFC 6238) multi-factor auth for admin accounts (#738).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - generate/verify TOTP secrets (otplib, standard SHA1/6-digit/30s so
|
||||
* Google Authenticator / Authy / 1Password all work);
|
||||
* - encrypt the secret at rest (AES-256-GCM) so a DB leak alone doesn't
|
||||
* yield working authenticator seeds;
|
||||
* - generate/verify one-time recovery codes, hashed (bcrypt) and single-use;
|
||||
* - build the otpauth:// URI + QR data-URL for enrollment.
|
||||
*
|
||||
* The encryption key is derived (scrypt) from MFA_ENCRYPTION_KEY when set,
|
||||
* otherwise from JWT_SECRET. Rotating either invalidates stored secrets —
|
||||
* the same blast radius as rotating JWT_SECRET already has for sessions, and
|
||||
* `reset-admin-mfa.js` is the recovery path.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
// Standard TOTP params; window:1 tolerates ±1 step (30s) of clock drift.
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
const ISSUER = 'PicPeak';
|
||||
const RECOVERY_CODE_COUNT = 10;
|
||||
const RECOVERY_CODE_BYTES = 10; // ~80 bits of entropy per code
|
||||
const RECOVERY_BCRYPT_ROUNDS = 10;
|
||||
|
||||
const ENC_ALGO = 'aes-256-gcm';
|
||||
const ENC_SALT = 'picpeak-mfa-secret-v1'; // fixed: derivation must be stable
|
||||
|
||||
function getEncryptionKey() {
|
||||
const material = process.env.MFA_ENCRYPTION_KEY || process.env.JWT_SECRET;
|
||||
if (!material) {
|
||||
throw new Error('mfaService: MFA_ENCRYPTION_KEY or JWT_SECRET must be set');
|
||||
}
|
||||
return crypto.scryptSync(material, ENC_SALT, 32);
|
||||
}
|
||||
|
||||
/** Generate a fresh base32 TOTP secret. */
|
||||
function generateSecret() {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
/** AES-256-GCM encrypt a secret → "iv.tag.ciphertext" (all base64url). */
|
||||
function encryptSecret(plainSecret) {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [iv, tag, ct].map((b) => b.toString('base64url')).join('.');
|
||||
}
|
||||
|
||||
/** Reverse of encryptSecret. Throws on tamper/wrong key. */
|
||||
function decryptSecret(stored) {
|
||||
const key = getEncryptionKey();
|
||||
const [ivB64, tagB64, ctB64] = String(stored).split('.');
|
||||
if (!ivB64 || !tagB64 || !ctB64) {
|
||||
throw new Error('mfaService: malformed encrypted secret');
|
||||
}
|
||||
const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
|
||||
const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]);
|
||||
return pt.toString('utf8');
|
||||
}
|
||||
|
||||
/** Verify a 6-digit TOTP code against the (plaintext) secret. */
|
||||
function verifyTotp(code, plainSecret) {
|
||||
if (!code || !plainSecret) return false;
|
||||
try {
|
||||
return authenticator.verify({ token: String(code).replace(/\s+/g, ''), secret: plainSecret });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a code against a STORED (encrypted) secret. */
|
||||
function verifyTotpEncrypted(code, storedSecret) {
|
||||
try {
|
||||
return verifyTotp(code, decryptSecret(storedSecret));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** otpauth:// URI for an authenticator app. */
|
||||
function buildOtpauthUri(accountName, plainSecret) {
|
||||
return authenticator.keyuri(accountName, ISSUER, plainSecret);
|
||||
}
|
||||
|
||||
/** QR code (PNG data URL) for the otpauth URI. */
|
||||
async function buildQrDataUrl(otpauthUri) {
|
||||
return QRCode.toDataURL(otpauthUri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
|
||||
}
|
||||
|
||||
/** Format a raw code as human-friendly groups, e.g. "abcd-efgh-jk". */
|
||||
function formatRecoveryCode(raw) {
|
||||
return raw.match(/.{1,4}/g).join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate RECOVERY_CODE_COUNT one-time codes. Returns the plaintext codes
|
||||
* (shown to the admin ONCE) and their bcrypt hashes (persisted).
|
||||
*/
|
||||
async function generateRecoveryCodes() {
|
||||
const plain = [];
|
||||
const hashed = [];
|
||||
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
||||
// base32-ish, lowercase, no ambiguous chars
|
||||
const raw = crypto.randomBytes(RECOVERY_CODE_BYTES)
|
||||
.toString('base64')
|
||||
.replace(/[^a-zA-Z0-9]/g, '')
|
||||
.toLowerCase()
|
||||
.slice(0, 10);
|
||||
const code = formatRecoveryCode(raw);
|
||||
plain.push(code);
|
||||
hashed.push(await bcrypt.hash(code, RECOVERY_BCRYPT_ROUNDS));
|
||||
}
|
||||
return { plain, hashed };
|
||||
}
|
||||
|
||||
function normalizeRecoveryInput(code) {
|
||||
return String(code || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a submitted recovery code against the stored hash array. On match,
|
||||
* returns the remaining hashes (matched one removed — single use). On miss,
|
||||
* matched:false and the array unchanged.
|
||||
*
|
||||
* @param {string[]} storedHashes
|
||||
* @returns {Promise<{matched: boolean, remainingHashes: string[]}>}
|
||||
*/
|
||||
async function consumeRecoveryCode(code, storedHashes) {
|
||||
const input = normalizeRecoveryInput(code);
|
||||
const hashes = Array.isArray(storedHashes) ? storedHashes : [];
|
||||
if (!input) return { matched: false, remainingHashes: hashes };
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await bcrypt.compare(input, hashes[i])) {
|
||||
const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1));
|
||||
return { matched: true, remainingHashes: remaining };
|
||||
}
|
||||
}
|
||||
return { matched: false, remainingHashes: hashes };
|
||||
}
|
||||
|
||||
/** True when an admin row has MFA enabled (coerces SQLite/PG boolean shapes). */
|
||||
function isEnrolled(admin) {
|
||||
const v = admin && admin.two_factor_enabled;
|
||||
return v === true || v === 1 || v === '1';
|
||||
}
|
||||
|
||||
/** Parse the DB column (JSON text) into an array of hashes. */
|
||||
function parseRecoveryCodes(raw) {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSecret,
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
verifyTotp,
|
||||
verifyTotpEncrypted,
|
||||
buildOtpauthUri,
|
||||
buildQrDataUrl,
|
||||
generateRecoveryCodes,
|
||||
consumeRecoveryCode,
|
||||
parseRecoveryCodes,
|
||||
isEnrolled,
|
||||
formatRecoveryCode,
|
||||
ISSUER,
|
||||
RECOVERY_CODE_COUNT,
|
||||
};
|
||||
@@ -11,14 +11,14 @@ const logger = require('../utils/logger');
|
||||
function normalizeFiles(files) {
|
||||
// Handle null, undefined, or falsy values
|
||||
if (!files) {
|
||||
logger.info('[normalizeFiles] No files provided');
|
||||
console.log('[normalizeFiles] No files provided');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(files)) {
|
||||
const validFiles = files.filter(Boolean);
|
||||
logger.info(`[normalizeFiles] Normalized ${validFiles.length} files from array`);
|
||||
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from array`);
|
||||
return validFiles;
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ function normalizeFiles(files) {
|
||||
try {
|
||||
if (typeof files === 'object' && typeof files[Symbol.iterator] === 'function') {
|
||||
const validFiles = Array.from(files).filter(Boolean);
|
||||
logger.info(`[normalizeFiles] Normalized ${validFiles.length} files from iterable`);
|
||||
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from iterable`);
|
||||
return validFiles;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[normalizeFiles] Failed to iterate files object:', err.message);
|
||||
console.warn('[normalizeFiles] Failed to iterate files object:', err.message);
|
||||
}
|
||||
|
||||
// Handle plain objects (multer fieldname mapping)
|
||||
@@ -39,16 +39,16 @@ function normalizeFiles(files) {
|
||||
const validFiles = Object.values(files)
|
||||
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
||||
.filter(Boolean);
|
||||
logger.info(`[normalizeFiles] Normalized ${validFiles.length} files from object`);
|
||||
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from object`);
|
||||
return validFiles;
|
||||
} catch (err) {
|
||||
logger.warn('[normalizeFiles] Failed to process files object:', err.message);
|
||||
console.warn('[normalizeFiles] Failed to process files object:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Unexpected type
|
||||
logger.warn('[normalizeFiles] Unexpected files type:', typeof files);
|
||||
console.warn('[normalizeFiles] Unexpected files type:', typeof files);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
try {
|
||||
await fs.access(tempPath);
|
||||
} catch (accessErr) {
|
||||
logger.error(`Temp file not accessible: ${tempPath}`, {
|
||||
console.error(`Temp file not accessible: ${tempPath}`, {
|
||||
originalname: file?.originalname,
|
||||
error: accessErr.message
|
||||
});
|
||||
@@ -156,7 +156,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
};
|
||||
}
|
||||
} catch (metadataError) {
|
||||
logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
|
||||
console.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +167,14 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
} catch (uploadErr) {
|
||||
logger.error(`Failed to upload ${file.originalname} → ${finalKey}:`, uploadErr);
|
||||
console.error(`Failed to upload ${file.originalname} → ${finalKey}:`, uploadErr);
|
||||
throw new Error(`Failed to upload to storage: ${uploadErr.message}`);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch (unlinkErr) {
|
||||
if (unlinkErr?.code !== 'ENOENT') {
|
||||
logger.warn(`Failed to clean up temp upload ${tempPath}:`, {
|
||||
console.warn(`Failed to clean up temp upload ${tempPath}:`, {
|
||||
error: unlinkErr.message,
|
||||
code: unlinkErr.code
|
||||
});
|
||||
@@ -262,9 +262,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
type: photoType
|
||||
});
|
||||
|
||||
logger.info(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
|
||||
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
|
||||
} catch (error) {
|
||||
logger.error(`Error processing file ${file.originalname}:`, {
|
||||
console.error(`Error processing file ${file.originalname}:`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
originalname: file.originalname,
|
||||
@@ -277,7 +277,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
try {
|
||||
await trx.rollback();
|
||||
} catch (rollbackErr) {
|
||||
logger.error('Failed to rollback transaction:', rollbackErr);
|
||||
console.error('Failed to rollback transaction:', rollbackErr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
'use strict';
|
||||
|
||||
// Portable ".picpeak" export — a single, self-describing archive that can be
|
||||
// downloaded from one instance and re-uploaded to another via the web UI only
|
||||
// (see picpeakImportService for the receiving half).
|
||||
//
|
||||
// Deliberately ENGINE-NEUTRAL: instead of a native pg_dump / sqlite .backup
|
||||
// (which can only ever restore into the same engine and version), each table is
|
||||
// written as NDJSON. The target rebuilds its own schema by running migrations,
|
||||
// then loads these rows into it — so an older backup restores cleanly onto a
|
||||
// newer target (forward-only), and pg↔pg / sqlite↔sqlite both work.
|
||||
//
|
||||
// This module is purely additive: it introduces a new artifact and touches no
|
||||
// existing backup/restore path.
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const archiver = require('archiver');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const logger = require('../utils/logger');
|
||||
const packageJson = require('../../package.json');
|
||||
|
||||
// Bump only on a breaking change to the on-disk layout below.
|
||||
const PICPEAK_FORMAT_VERSION = 1;
|
||||
|
||||
// Never exported as data — the target owns these (its own migrations set them).
|
||||
const EXCLUDED_TABLES = new Set(['knex_migrations', 'knex_migrations_lock']);
|
||||
|
||||
// Storage subdirs holding non-recalculable blobs — always included.
|
||||
const DOC_DIRS = ['business-docs', 'uploads'];
|
||||
// Original gallery photos — only when includePhotos is true (large; otherwise
|
||||
// the admin re-uploads originals per gallery and previews are re-rendered).
|
||||
const PHOTO_DIRS = ['events/active', 'events/archived'];
|
||||
|
||||
const isPostgres = () => knexConfig.client === 'pg';
|
||||
|
||||
// db.raw returns `{ rows: [...] }` on Postgres and a bare array on SQLite.
|
||||
const rawRows = (result) => (isPostgres() ? result.rows : result);
|
||||
|
||||
// All user tables, minus knex bookkeeping. Introspected at runtime so the
|
||||
// export never rots as tables are added (no hardcoded list to maintain).
|
||||
async function listDataTables() {
|
||||
let names;
|
||||
if (isPostgres()) {
|
||||
const result = await db.raw(`
|
||||
SELECT table_name AS name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
names = rawRows(result).map((r) => r.name);
|
||||
} else {
|
||||
const result = await db.raw(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`);
|
||||
names = rawRows(result).map((r) => r.name);
|
||||
}
|
||||
return names.filter((n) => !EXCLUDED_TABLES.has(n));
|
||||
}
|
||||
|
||||
// The latest applied migration — recorded in the manifest so the importer can
|
||||
// refuse a backup that is NEWER than the target (forward-only guarantee).
|
||||
async function getLatestMigration() {
|
||||
try {
|
||||
const rows = await db('knex_migrations').orderBy('id', 'desc').limit(1);
|
||||
return rows[0]?.name || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Write one table to <dataDir>/<table>.ndjson (one JSON object per line).
|
||||
// Returns { rowCount, checksum } for the manifest. JSON.stringify serialises
|
||||
// Dates to ISO strings, which re-import cleanly on both engines.
|
||||
//
|
||||
// Uses a plain select rather than knex `.stream()`: streaming on Postgres pulls
|
||||
// in the optional `pg-query-stream` dependency (not bundled), so it throws on
|
||||
// pg. A select works on both engines with no extra dependency. Rows are DB
|
||||
// metadata (blobs live on disk under files/), so holding a table in memory is
|
||||
// fine for the instance sizes PicPeak targets.
|
||||
async function writeTableNdjson(table, dataDir) {
|
||||
const outPath = path.join(dataDir, `${table}.ndjson`);
|
||||
const hash = crypto.createHash('sha256');
|
||||
const rows = await db(table).select('*');
|
||||
const lines = rows.map((row) => {
|
||||
const line = JSON.stringify(row);
|
||||
hash.update(`${line}\n`);
|
||||
return line;
|
||||
});
|
||||
await fsp.writeFile(outPath, lines.length ? `${lines.join('\n')}\n` : '', 'utf8');
|
||||
return { rowCount: rows.length, checksum: hash.digest('hex') };
|
||||
}
|
||||
|
||||
// Recursively collect files under a storage subdir as { abs, rel } where rel is
|
||||
// relative to the storage root (so the importer restores the same layout).
|
||||
async function collectDir(subdir, storageRoot, acc) {
|
||||
const abs = path.join(storageRoot, subdir);
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(abs, { withFileTypes: true });
|
||||
} catch (_) {
|
||||
return; // subdir may not exist on this install — skip silently
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = path.join(subdir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectDir(childRel, storageRoot, acc);
|
||||
} else if (entry.isFile()) {
|
||||
acc.push({ abs: path.join(storageRoot, childRel), rel: childRel });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectFiles(includePhotos) {
|
||||
const storageRoot = getStoragePath();
|
||||
const dirs = includePhotos ? [...DOC_DIRS, ...PHOTO_DIRS] : [...DOC_DIRS];
|
||||
const acc = [];
|
||||
for (const d of dirs) {
|
||||
await collectDir(d, storageRoot, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a .picpeak archive.
|
||||
* @param {Object} opts
|
||||
* @param {boolean} [opts.includePhotos=false] include original gallery photos
|
||||
* @param {string} [opts.outDir] where to write the file (defaults to a temp dir)
|
||||
* @returns {Promise<{ filePath: string, manifest: object }>}
|
||||
*/
|
||||
async function createPicpeak({ includePhotos = false, outDir } = {}) {
|
||||
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-export-'));
|
||||
const dataDir = path.join(staging, 'data');
|
||||
await fsp.mkdir(dataDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// 1. Dump every table to NDJSON, tracking counts + checksums.
|
||||
const tables = await listDataTables();
|
||||
const tableMeta = {};
|
||||
for (const table of tables) {
|
||||
tableMeta[table] = await writeTableNdjson(table, dataDir);
|
||||
}
|
||||
|
||||
// 2. Gather the non-recalculable blobs (PDFs, business-docs, uploads, and
|
||||
// optionally original photos).
|
||||
const files = await collectFiles(includePhotos);
|
||||
|
||||
// 3. Manifest — everything the importer needs to validate + reconstruct.
|
||||
const manifest = {
|
||||
format: PICPEAK_FORMAT_VERSION,
|
||||
kind: 'picpeak-backup',
|
||||
created_at: new Date().toISOString(),
|
||||
app_version: packageJson.version || null,
|
||||
database: {
|
||||
engine: isPostgres() ? 'pg' : 'sqlite',
|
||||
latest_migration: await getLatestMigration(),
|
||||
},
|
||||
options: { includePhotos: !!includePhotos },
|
||||
tables: tableMeta,
|
||||
file_count: files.length,
|
||||
// NOTE: contains secrets (SMTP password, admin hashes, API keys) in plain
|
||||
// text — the download surface must warn about this.
|
||||
contains_secrets: true,
|
||||
};
|
||||
await fsp.writeFile(
|
||||
path.join(staging, 'manifest.json'),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// 4. Zip staging (manifest + data/) plus the blobs under files/. The final
|
||||
// .picpeak lands in outDir (caller-managed) or a fresh temp dir; either
|
||||
// way the NDJSON scratch (which holds plaintext secrets) is always
|
||||
// removed in `finally` below.
|
||||
const targetDir = outDir || (await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-out-')));
|
||||
await fsp.mkdir(targetDir, { recursive: true });
|
||||
const stamp = manifest.created_at.replace(/[:.]/g, '-');
|
||||
const filePath = path.join(targetDir, `picpeak-backup-${stamp}.picpeak`);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(filePath);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
output.on('close', resolve);
|
||||
output.on('error', reject);
|
||||
archive.on('error', reject);
|
||||
// Surface archiver warnings (e.g. a file vanished mid-run) instead of
|
||||
// silently shipping an incomplete archive.
|
||||
archive.on('warning', (err) => reject(err));
|
||||
archive.pipe(output);
|
||||
archive.file(path.join(staging, 'manifest.json'), { name: 'manifest.json' });
|
||||
archive.directory(dataDir, 'data');
|
||||
for (const f of files) {
|
||||
archive.file(f.abs, { name: path.posix.join('files', f.rel.split(path.sep).join('/')) });
|
||||
}
|
||||
archive.finalize();
|
||||
});
|
||||
} catch (err) {
|
||||
// Archiver failed → the partial .picpeak holds plaintext secrets and is
|
||||
// useless; remove our own temp out dir so it isn't orphaned. A
|
||||
// caller-supplied outDir is left untouched.
|
||||
if (!outDir) await fsp.rm(targetDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[picpeak-export] wrote ${filePath} (${tables.length} tables, ${files.length} files, includePhotos=${!!includePhotos})`
|
||||
);
|
||||
return { filePath, manifest };
|
||||
} finally {
|
||||
// Always remove the NDJSON scratch dir — it contains a plaintext dump of
|
||||
// every table (secrets included). The final .picpeak is elsewhere.
|
||||
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PICPEAK_FORMAT_VERSION,
|
||||
EXCLUDED_TABLES,
|
||||
createPicpeak,
|
||||
// exported for reuse/testing
|
||||
listDataTables,
|
||||
collectFiles,
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
'use strict';
|
||||
|
||||
// Receiving half of the GUI-only backup roundtrip: takes a ".picpeak" produced
|
||||
// by picpeakExportService and restores it onto THIS instance.
|
||||
//
|
||||
// Restore semantics (agreed design): FULL OVERRIDE — every table is wiped and
|
||||
// replaced by the backup's rows — EXCEPT the current logged-in admin account,
|
||||
// which is preserved so the operator is never locked out. A backup admin whose
|
||||
// email collides with the current account is overwritten with the current
|
||||
// account's credentials (so the operator's known password keeps working).
|
||||
//
|
||||
// Same-engine only (pg↔pg / sqlite↔sqlite) and forward-only (an older backup
|
||||
// restores onto a newer instance; a newer backup is refused). The target's own
|
||||
// schema is used as-is — we never replay the backup's DDL.
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const logger = require('../utils/logger');
|
||||
const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService');
|
||||
|
||||
const isPostgres = () => knexConfig.client === 'pg';
|
||||
|
||||
// Compare migrations by their numeric filename prefix (001_, 107_, 129_ …).
|
||||
function migrationOrder(name) {
|
||||
const m = String(name || '').match(/^(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
}
|
||||
|
||||
async function readManifestFromZip(picpeakPath) {
|
||||
const zip = new StreamZip.async({ file: picpeakPath });
|
||||
try {
|
||||
return JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
|
||||
} finally {
|
||||
await zip.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns an array of human-readable blockers ([] = OK to restore).
|
||||
async function validateManifest(manifest) {
|
||||
const errors = [];
|
||||
if (!manifest || manifest.kind !== 'picpeak-backup') {
|
||||
return ['This file is not a PicPeak backup (.picpeak).'];
|
||||
}
|
||||
if (Number(manifest.format) > PICPEAK_FORMAT_VERSION) {
|
||||
errors.push('This backup was created by a newer version of PicPeak. Update this instance first.');
|
||||
}
|
||||
const engine = isPostgres() ? 'pg' : 'sqlite';
|
||||
if (manifest.database && manifest.database.engine && manifest.database.engine !== engine) {
|
||||
errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`);
|
||||
}
|
||||
// Forward-only: the target schema must be at least as new as the backup's.
|
||||
let targetLatest = null;
|
||||
try {
|
||||
const applied = await db('knex_migrations').orderBy('id', 'desc').limit(1);
|
||||
targetLatest = applied[0] ? applied[0].name : null;
|
||||
} catch (_) {
|
||||
// No knex_migrations table (e.g. some test harnesses) — skip the check.
|
||||
}
|
||||
const backupLatest = manifest.database ? manifest.database.latest_migration : null;
|
||||
if (backupLatest && targetLatest && migrationOrder(backupLatest) > migrationOrder(targetLatest)) {
|
||||
errors.push('This backup is from a newer database schema than this instance. Update this instance to at least the backup version before restoring.');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function parseNdjson(filePath) {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
return fs
|
||||
.readFileSync(filePath, 'utf8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
// Re-insert the operator's account inside the restore transaction so they keep
|
||||
// working credentials. If the backup already loaded an admin with the same
|
||||
// email, overwrite that row's credentials with the current account's (current
|
||||
// creds win); otherwise insert the snapshot with a fresh id.
|
||||
async function reinjectCurrentAdmin(trx, currentAdmin) {
|
||||
if (!currentAdmin) return;
|
||||
const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first();
|
||||
if (existing) {
|
||||
await trx('admin_users').where({ id: existing.id }).update({
|
||||
password_hash: currentAdmin.password_hash,
|
||||
is_active: currentAdmin.is_active,
|
||||
must_change_password: currentAdmin.must_change_password,
|
||||
});
|
||||
} else {
|
||||
const row = { ...currentAdmin };
|
||||
delete row.id; // let the engine assign a fresh id to avoid collision
|
||||
await trx('admin_users').insert(row);
|
||||
}
|
||||
}
|
||||
|
||||
// The json/jsonb columns of a table (Postgres only). The pg driver returns
|
||||
// jsonb as parsed JS values, so on re-insert they must be serialised back to
|
||||
// valid JSON text — otherwise a scalar like the string "PicPeak" is sent
|
||||
// unquoted and pg rejects it ("invalid input syntax for type json").
|
||||
async function jsonColumnsFor(trx, table) {
|
||||
if (!isPostgres()) return new Set();
|
||||
const res = await trx.raw(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND data_type IN ('json', 'jsonb')",
|
||||
[table]
|
||||
);
|
||||
return new Set(res.rows.map((r) => r.column_name));
|
||||
}
|
||||
|
||||
function serialiseJsonColumns(rows, jsonCols) {
|
||||
if (!jsonCols.size) return rows;
|
||||
return rows.map((row) => {
|
||||
const out = { ...row };
|
||||
for (const col of jsonCols) {
|
||||
if (out[col] !== undefined && out[col] !== null) out[col] = JSON.stringify(out[col]);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// Whole-DB replace in one transaction with FK enforcement suspended (pg:
|
||||
// session_replication_role=replica on the trx connection, reset before commit;
|
||||
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
|
||||
// in the data set, so the target's schema/migration state is left intact.
|
||||
async function replaceAllTables(tables, dataDir, currentAdmin) {
|
||||
await db.transaction(async (trx) => {
|
||||
if (isPostgres()) {
|
||||
try {
|
||||
await trx.raw("SET session_replication_role = 'replica'");
|
||||
} catch (_) {
|
||||
// session_replication_role requires a Postgres SUPERUSER. The bundled
|
||||
// postgres image's role is one; managed Postgres (RDS / Cloud SQL / …)
|
||||
// app users usually are not. Fail fast with a clear message BEFORE any
|
||||
// rows are deleted — the transaction rolls back, so nothing is wiped.
|
||||
const err = new Error(
|
||||
'Restore needs a PostgreSQL superuser to suspend foreign-key checks during the full replace, but this instance’s database user is not a superuser (common on managed Postgres such as RDS or Cloud SQL). Restore onto the bundled Postgres, or grant the role superuser for the restore.'
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
await trx.raw('PRAGMA defer_foreign_keys = ON');
|
||||
}
|
||||
|
||||
for (const table of tables) {
|
||||
await trx(table).del();
|
||||
}
|
||||
for (const table of tables) {
|
||||
const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`));
|
||||
if (!rows.length) continue;
|
||||
const jsonCols = await jsonColumnsFor(trx, table);
|
||||
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
|
||||
}
|
||||
|
||||
await reinjectCurrentAdmin(trx, currentAdmin);
|
||||
|
||||
// Reset the pg session flag BEFORE the connection returns to the pool.
|
||||
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
|
||||
});
|
||||
}
|
||||
|
||||
// Copy the archive's files/ tree into storage, overwriting existing files.
|
||||
async function restoreFiles(stagingDir) {
|
||||
const src = path.join(stagingDir, 'files');
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
const storageRoot = getStoragePath();
|
||||
let count = 0;
|
||||
async function walk(rel) {
|
||||
const abs = path.join(src, rel);
|
||||
for (const entry of await fsp.readdir(abs, { withFileTypes: true })) {
|
||||
const childRel = path.join(rel, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(childRel);
|
||||
} else if (entry.isFile()) {
|
||||
const dest = path.join(storageRoot, childRel);
|
||||
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fsp.copyFile(path.join(src, childRel), dest);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk('');
|
||||
return count;
|
||||
}
|
||||
|
||||
// Does the restored data reference an external-media library? If so the caller
|
||||
// shows a banner telling the admin to (re)configure the external-media mount on
|
||||
// this instance — those files are NOT in the backup by design.
|
||||
async function detectExternalMedia() {
|
||||
try {
|
||||
if (await hasColumnCached('events', 'external_path')) {
|
||||
const row = await db('events').whereNotNull('external_path').first();
|
||||
if (row) return true;
|
||||
}
|
||||
if (await hasColumnCached('photos', 'external_relpath')) {
|
||||
const row = await db('photos').whereNotNull('external_relpath').first();
|
||||
if (row) return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort — a detection miss is not worth failing the restore.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a .picpeak onto this instance.
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.picpeakPath path to the uploaded/staged .picpeak
|
||||
* @param {number} [opts.currentAdminId] admin to preserve across the wipe
|
||||
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>}
|
||||
*/
|
||||
async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
const manifest = await readManifestFromZip(picpeakPath);
|
||||
const blockers = await validateManifest(manifest);
|
||||
if (blockers.length) {
|
||||
const err = new Error(blockers[0]);
|
||||
err.statusCode = 400;
|
||||
err.validation = blockers;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const currentAdmin = currentAdminId
|
||||
? await db('admin_users').where({ id: currentAdminId }).first()
|
||||
: null;
|
||||
|
||||
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-'));
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: picpeakPath });
|
||||
try {
|
||||
await zip.extract(null, staging);
|
||||
} finally {
|
||||
await zip.close();
|
||||
}
|
||||
|
||||
const dataDir = path.join(staging, 'data');
|
||||
// Only touch tables that (a) the uploaded manifest lists AND (b) actually
|
||||
// exist as real tables in THIS database. listDataTables() already excludes
|
||||
// knex_migrations/_lock (EXCLUDED_TABLES), so a crafted or corrupted
|
||||
// .picpeak can never make the restore delete the migration bookkeeping — or
|
||||
// any table that isn't a genuine data table here.
|
||||
const dbTables = new Set(await listDataTables());
|
||||
const manifestTables = Object.keys(manifest.tables || {});
|
||||
const tables = manifestTables.filter((tbl) => dbTables.has(tbl) && !EXCLUDED_TABLES.has(tbl));
|
||||
const skipped = manifestTables.filter((tbl) => !tables.includes(tbl));
|
||||
if (skipped.length) {
|
||||
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
|
||||
}
|
||||
|
||||
await replaceAllTables(tables, dataDir, currentAdmin);
|
||||
const filesRestored = await restoreFiles(staging);
|
||||
const usesExternalMedia = await detectExternalMedia();
|
||||
|
||||
logger.info(
|
||||
`[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia})`
|
||||
);
|
||||
return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, manifest };
|
||||
} finally {
|
||||
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
importFromPicpeak,
|
||||
readManifestFromZip,
|
||||
validateManifest,
|
||||
};
|
||||
@@ -33,7 +33,7 @@ const { getAppSetting } = require('../utils/appSettings');
|
||||
const { cleanNetMinor } = require('../utils/invoiceRounding');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { nextDocumentNumber } = require('../utils/documentSequences');
|
||||
const { claimNextSequence } = require('../utils/documentSequences');
|
||||
const { formatShortDate } = require('../utils/dateFormatter');
|
||||
const businessProfileService = require('./businessProfileService');
|
||||
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
||||
@@ -300,12 +300,28 @@ async function insertLineItemsHierarchical(trx, tableName, ownerColumn, ownerId,
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumberInTemplate(format, year, seq) {
|
||||
// Tokens: {YEAR}, {MONTH}, {SEQ:04d}. Defaults handle padding via
|
||||
// a tiny formatter, kept inline to avoid a new dependency.
|
||||
return format
|
||||
.replace(/\{YEAR\}/g, String(year))
|
||||
.replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0'))
|
||||
.replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0'))
|
||||
.replace(/\{SEQ\}/g, String(seq));
|
||||
}
|
||||
|
||||
// Atomic gap-free quote number generator. See utils/documentSequences.js
|
||||
// for the locking story; migration 132 created the underlying table.
|
||||
// The previous SELECT-MAX-then-INSERT path raced under concurrent
|
||||
// admin creates and could emit `Q-2026-AB12C3` after 5 retries.
|
||||
async function nextQuoteNumber(trx) {
|
||||
return nextDocumentNumber('quote', 'crm_quotes_number_format', 'Q-{YEAR}-{SEQ:04d}', trx);
|
||||
// Read through `trx` when present — getAppSetting on the global db inside an
|
||||
// open transaction deadlocks the single-connection SQLite pool (prepare_quote
|
||||
// runs createQuote unattended from a workflow).
|
||||
const format = (await getAppSetting('crm_quotes_number_format', null, trx || db)) || 'Q-{YEAR}-{SEQ:04d}';
|
||||
const year = new Date().getFullYear();
|
||||
const seq = await claimNextSequence('quote', year, trx);
|
||||
return formatNumberInTemplate(format, year, seq);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,40 +416,40 @@ async function listQuotes({ filters = {}, sort = 'issue_desc', page = 1, pageSiz
|
||||
const total = ensureInt(totalRow?.total || 0);
|
||||
|
||||
switch (sort) {
|
||||
// "Newest" / "Oldest" sort by CREATION time, not issue_date —
|
||||
// the latter is admin-controlled (retro-dated quotes, future-
|
||||
// dated quotes for accruals) and drifts from actual chronology.
|
||||
// Sorting by created_at always puts a just-saved quote at the
|
||||
// top of the "Newest first" list.
|
||||
case 'oldest':
|
||||
query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc');
|
||||
break;
|
||||
case 'issue_asc':
|
||||
query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc');
|
||||
break;
|
||||
case 'issue_desc':
|
||||
query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
case 'customer_asc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
|
||||
.orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
case 'customer_desc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
|
||||
.orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
case 'value_asc':
|
||||
query = query.orderBy('quotes.total_amount_minor', 'asc');
|
||||
break;
|
||||
case 'value_desc':
|
||||
query = query.orderBy('quotes.total_amount_minor', 'desc');
|
||||
break;
|
||||
case 'newest':
|
||||
default:
|
||||
query = query.orderBy('quotes.created_at', 'desc').orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
// "Newest" / "Oldest" sort by CREATION time, not issue_date —
|
||||
// the latter is admin-controlled (retro-dated quotes, future-
|
||||
// dated quotes for accruals) and drifts from actual chronology.
|
||||
// Sorting by created_at always puts a just-saved quote at the
|
||||
// top of the "Newest first" list.
|
||||
case 'oldest':
|
||||
query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc');
|
||||
break;
|
||||
case 'issue_asc':
|
||||
query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc');
|
||||
break;
|
||||
case 'issue_desc':
|
||||
query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
case 'customer_asc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
|
||||
.orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
case 'customer_desc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
|
||||
.orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
case 'value_asc':
|
||||
query = query.orderBy('quotes.total_amount_minor', 'asc');
|
||||
break;
|
||||
case 'value_desc':
|
||||
query = query.orderBy('quotes.total_amount_minor', 'desc');
|
||||
break;
|
||||
case 'newest':
|
||||
default:
|
||||
query = query.orderBy('quotes.created_at', 'desc').orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
}
|
||||
|
||||
const offset = Math.max(0, (page - 1) * pageSize);
|
||||
@@ -1508,8 +1524,8 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) {
|
||||
|
||||
const paymentTermSnapshot = quote.payment_term_snapshot
|
||||
? (typeof quote.payment_term_snapshot === 'string'
|
||||
? JSON.parse(quote.payment_term_snapshot)
|
||||
: quote.payment_term_snapshot)
|
||||
? JSON.parse(quote.payment_term_snapshot)
|
||||
: quote.payment_term_snapshot)
|
||||
: null;
|
||||
|
||||
const invoiceService = require('./invoiceService');
|
||||
@@ -1621,8 +1637,8 @@ async function convertToEvent(quoteId, adminId, options = {}) {
|
||||
|
||||
const paymentTermSnapshot = quote.payment_term_snapshot
|
||||
? (typeof quote.payment_term_snapshot === 'string'
|
||||
? JSON.parse(quote.payment_term_snapshot)
|
||||
: quote.payment_term_snapshot)
|
||||
? JSON.parse(quote.payment_term_snapshot)
|
||||
: quote.payment_term_snapshot)
|
||||
: null;
|
||||
|
||||
// Lazy import to avoid the circular dep.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const axios = require('axios');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function verifyRecaptcha(token) {
|
||||
// Check if reCAPTCHA is enabled
|
||||
@@ -33,7 +32,7 @@ async function verifyRecaptcha(token) {
|
||||
|
||||
// If no secret key configured, fail closed
|
||||
if (!secretKey) {
|
||||
logger.warn('reCAPTCHA enabled but no secret key configured — blocking request');
|
||||
console.warn('reCAPTCHA enabled but no secret key configured — blocking request');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -51,7 +50,7 @@ async function verifyRecaptcha(token) {
|
||||
|
||||
return response.data.success === true;
|
||||
} catch (error) {
|
||||
logger.error('reCAPTCHA verification error:', error);
|
||||
console.error('reCAPTCHA verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ const backupManifest = require('./backupManifest');
|
||||
const S3StorageAdapter = require('./storage/s3Storage');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { formatBytes } = require('../utils/formatBytes');
|
||||
const os = require('os');
|
||||
|
||||
/**
|
||||
@@ -200,20 +199,20 @@ class RestoreService {
|
||||
// Step 6: Perform the actual restore based on type
|
||||
let restoreResult;
|
||||
switch (options.restoreType) {
|
||||
case 'full':
|
||||
restoreResult = await this.performFullRestore(localBackupPath, manifest, options);
|
||||
break;
|
||||
case 'database':
|
||||
restoreResult = await this.performDatabaseRestore(localBackupPath, manifest, options);
|
||||
break;
|
||||
case 'files':
|
||||
restoreResult = await this.performFilesRestore(localBackupPath, manifest, options);
|
||||
break;
|
||||
case 'selective':
|
||||
restoreResult = await this.performSelectiveRestore(localBackupPath, manifest, options);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown restore type: ${options.restoreType}`);
|
||||
case 'full':
|
||||
restoreResult = await this.performFullRestore(localBackupPath, manifest, options);
|
||||
break;
|
||||
case 'database':
|
||||
restoreResult = await this.performDatabaseRestore(localBackupPath, manifest, options);
|
||||
break;
|
||||
case 'files':
|
||||
restoreResult = await this.performFilesRestore(localBackupPath, manifest, options);
|
||||
break;
|
||||
case 'selective':
|
||||
restoreResult = await this.performSelectiveRestore(localBackupPath, manifest, options);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown restore type: ${options.restoreType}`);
|
||||
}
|
||||
|
||||
// Step 7: Post-restore verification
|
||||
@@ -308,7 +307,7 @@ class RestoreService {
|
||||
this.log('info', 'Post-restore migrations applied');
|
||||
} catch (migErr) {
|
||||
this.log('warn',
|
||||
'Post-restore migrate:safe failed — restore data is in place but the schema may lag the running image. ' +
|
||||
`Post-restore migrate:safe failed — restore data is in place but the schema may lag the running image. ` +
|
||||
`A container restart will retry via wait-for-db.sh. Error: ${migErr.message}`);
|
||||
}
|
||||
|
||||
@@ -409,8 +408,8 @@ class RestoreService {
|
||||
if (restoreRun) {
|
||||
const failureMessage = rollbackAttempted
|
||||
? (rollbackSucceeded
|
||||
? `${error.message} (rolled back successfully to pre-restore state)`
|
||||
: `${error.message} | ROLLBACK ALSO FAILED: ${rollbackError} — destination is in a partial state, inspect before retrying`)
|
||||
? `${error.message} (rolled back successfully to pre-restore state)`
|
||||
: `${error.message} | ROLLBACK ALSO FAILED: ${rollbackError} — destination is in a partial state, inspect before retrying`)
|
||||
: `${error.message} (no pre-restore backup available — destination may be partial)`;
|
||||
await db('restore_runs').where('id', restoreRun.id).update({
|
||||
completed_at: new Date(),
|
||||
@@ -888,9 +887,9 @@ class RestoreService {
|
||||
throw new Error(
|
||||
`Database backup file not found. Tried: ${candidates.join(', ')}. ` +
|
||||
`Manifest recorded path: ${dbBackupFile}. ` +
|
||||
'Hint: this usually means the manifest\'s database.backup_file path no longer ' +
|
||||
'exists on disk (deleted? moved? volume not mounted?). Check ' +
|
||||
'~/<your-compose-dir>/backup/database/ on the host.'
|
||||
`Hint: this usually means the manifest's database.backup_file path no longer ` +
|
||||
`exists on disk (deleted? moved? volume not mounted?). Check ` +
|
||||
`~/<your-compose-dir>/backup/database/ on the host.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1043,8 +1042,8 @@ class RestoreService {
|
||||
await spawnAsync('psql', [
|
||||
'-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb,
|
||||
'-c',
|
||||
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity ' +
|
||||
`WHERE datname = '${database.replace(/'/g, '\'\'')}' AND pid <> pg_backend_pid()`,
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity ` +
|
||||
`WHERE datname = '${database.replace(/'/g, "''")}' AND pid <> pg_backend_pid()`,
|
||||
], { env });
|
||||
|
||||
// Drop and recreate database (extremely dangerous!)
|
||||
@@ -1529,7 +1528,15 @@ END $$;`
|
||||
* Format bytes to human readable
|
||||
*/
|
||||
formatBytes(bytes, decimals = 2) {
|
||||
return formatBytes(bytes, decimals);
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,6 @@ const { db } = require('../database/db');
|
||||
const watermarkService = require('./watermarkService');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class SecureImageService {
|
||||
constructor() {
|
||||
@@ -269,7 +268,7 @@ class SecureImageService {
|
||||
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
logger.error('Error processing protected image:', error);
|
||||
console.error('Error processing protected image:', error);
|
||||
// Return original on error
|
||||
return await fs.readFile(imagePath);
|
||||
}
|
||||
@@ -347,7 +346,7 @@ class SecureImageService {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error logging image access:', error);
|
||||
console.error('Error logging image access:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +386,7 @@ class SecureImageService {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error checking for rapid access:', error);
|
||||
console.error('Error checking for rapid access:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +417,7 @@ class SecureImageService {
|
||||
})
|
||||
});
|
||||
|
||||
logger.warn(`Suspicious activity flagged: ${reason}`, {
|
||||
console.warn(`Suspicious activity flagged: ${reason}`, {
|
||||
clientFingerprint,
|
||||
photoId,
|
||||
details
|
||||
@@ -433,12 +432,12 @@ class SecureImageService {
|
||||
.first();
|
||||
|
||||
if (parseInt(recentSuspicious.count) >= 3) {
|
||||
logger.warn(`Client fingerprint flagged for blocking: ${clientFingerprint}`);
|
||||
console.warn(`Client fingerprint flagged for blocking: ${clientFingerprint}`);
|
||||
// This would be handled by the middleware's blocking system
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error flagging suspicious activity:', error);
|
||||
console.error('Error flagging suspicious activity:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,13 +457,13 @@ class SecureImageService {
|
||||
|
||||
// Flag if more than 10 accesses to same photo in 5 minutes
|
||||
if (accessCount > 10) {
|
||||
logger.warn(`Suspicious activity detected: ${accessCount} accesses to photo ${photoId} from ${clientFingerprint}`);
|
||||
console.warn(`Suspicious activity detected: ${accessCount} accesses to photo ${photoId} from ${clientFingerprint}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('Error detecting suspicious activity:', error);
|
||||
console.error('Error detecting suspicious activity:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const SETTING_KEY = 'general_short_gallery_urls';
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
@@ -60,7 +59,7 @@ const getRawSettingValue = async () => {
|
||||
const setting = await db('app_settings').where({ setting_key: SETTING_KEY }).first();
|
||||
return setting?.setting_value ?? null;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read gallery URL setting:', error.message);
|
||||
console.error('Failed to read gallery URL setting:', error.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Example usage of the S3StorageAdapter
|
||||
*
|
||||
* This file demonstrates how to use the S3 storage adapter for various operations
|
||||
*/
|
||||
|
||||
const S3StorageAdapter = require('./s3Storage');
|
||||
|
||||
// Example 1: Basic AWS S3 Configuration
|
||||
const s3Storage = new S3StorageAdapter({
|
||||
bucket: 'my-photo-bucket',
|
||||
region: 'us-east-1',
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
||||
});
|
||||
|
||||
// Example 2: MinIO Configuration (S3-compatible)
|
||||
const minioStorage = new S3StorageAdapter({
|
||||
bucket: 'photo-storage',
|
||||
endpoint: 'http://localhost:9000', // MinIO endpoint
|
||||
accessKeyId: 'minioadmin',
|
||||
secretAccessKey: 'minioadmin',
|
||||
forcePathStyle: true, // Required for MinIO
|
||||
sslEnabled: false // For local development
|
||||
});
|
||||
|
||||
// Example 3: DigitalOcean Spaces Configuration
|
||||
const spacesStorage = new S3StorageAdapter({
|
||||
bucket: 'my-space-name',
|
||||
endpoint: 'https://nyc3.digitaloceanspaces.com',
|
||||
region: 'nyc3',
|
||||
accessKeyId: process.env.DO_SPACES_KEY,
|
||||
secretAccessKey: process.env.DO_SPACES_SECRET
|
||||
});
|
||||
|
||||
// Usage Examples
|
||||
async function examples() {
|
||||
try {
|
||||
// Test connection
|
||||
await s3Storage.testConnection();
|
||||
console.log('Connection successful!');
|
||||
|
||||
// Upload a file with progress tracking
|
||||
const uploadResult = await s3Storage.upload(
|
||||
'/path/to/local/photo.jpg',
|
||||
'events/wedding-2024/photo.jpg',
|
||||
{
|
||||
contentType: 'image/jpeg',
|
||||
metadata: {
|
||||
event: 'wedding-2024',
|
||||
photographer: 'John Doe'
|
||||
},
|
||||
onProgress: (loaded, total) => {
|
||||
const percentage = Math.round((loaded / total) * 100);
|
||||
console.log(`Upload progress: ${percentage}%`);
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log('Upload complete:', uploadResult.Location);
|
||||
|
||||
// Upload from stream
|
||||
const readStream = fs.createReadStream('/path/to/large-video.mp4');
|
||||
await s3Storage.uploadStream(readStream, 'events/wedding-2024/video.mp4', {
|
||||
contentType: 'video/mp4',
|
||||
onProgress: (loaded, total) => {
|
||||
console.log(`Streamed ${loaded} of ${total} bytes`);
|
||||
}
|
||||
});
|
||||
|
||||
// Download a file
|
||||
await s3Storage.download(
|
||||
'events/wedding-2024/photo.jpg',
|
||||
'/path/to/downloaded/photo.jpg',
|
||||
{
|
||||
onProgress: (loaded, total) => {
|
||||
const percentage = Math.round((loaded / total) * 100);
|
||||
console.log(`Download progress: ${percentage}%`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get a download stream
|
||||
const downloadStream = await s3Storage.downloadStream('events/wedding-2024/photo.jpg');
|
||||
downloadStream.pipe(fs.createWriteStream('/path/to/output.jpg'));
|
||||
|
||||
// List files
|
||||
const listing = await s3Storage.list('events/wedding-2024/');
|
||||
console.log(`Found ${listing.Contents.length} files`);
|
||||
listing.Contents.forEach(file => {
|
||||
console.log(`- ${file.Key} (${file.Size} bytes)`);
|
||||
});
|
||||
|
||||
// Generate pre-signed URL for temporary access
|
||||
const downloadUrl = await s3Storage.getSignedUrl('getObject', 'events/wedding-2024/photo.jpg', {
|
||||
expiresIn: 3600 // 1 hour
|
||||
});
|
||||
console.log('Pre-signed download URL:', downloadUrl);
|
||||
|
||||
// Generate pre-signed upload URL
|
||||
const uploadUrl = await s3Storage.getSignedUrl('putObject', 'events/wedding-2024/new-photo.jpg', {
|
||||
expiresIn: 1800, // 30 minutes
|
||||
params: {
|
||||
ContentType: 'image/jpeg'
|
||||
}
|
||||
});
|
||||
console.log('Pre-signed upload URL:', uploadUrl);
|
||||
|
||||
// Check if file exists
|
||||
const exists = await s3Storage.exists('events/wedding-2024/photo.jpg');
|
||||
console.log('File exists:', exists);
|
||||
|
||||
// Get metadata
|
||||
const metadata = await s3Storage.getMetadata('events/wedding-2024/photo.jpg');
|
||||
console.log('File metadata:', metadata);
|
||||
|
||||
// Copy file
|
||||
await s3Storage.copy(
|
||||
'events/wedding-2024/photo.jpg',
|
||||
'events/wedding-2024/photo-copy.jpg'
|
||||
);
|
||||
|
||||
// Move file
|
||||
await s3Storage.move(
|
||||
'events/wedding-2024/photo-copy.jpg',
|
||||
'events/wedding-2024/archived/photo.jpg'
|
||||
);
|
||||
|
||||
// Delete file
|
||||
await s3Storage.delete('events/wedding-2024/temp-photo.jpg');
|
||||
|
||||
// Delete multiple files
|
||||
const deleteResult = await s3Storage.deleteMany([
|
||||
'events/wedding-2024/temp1.jpg',
|
||||
'events/wedding-2024/temp2.jpg',
|
||||
'events/wedding-2024/temp3.jpg'
|
||||
]);
|
||||
console.log(`Deleted ${deleteResult.Deleted.length} files`);
|
||||
|
||||
// Get storage statistics
|
||||
const stats = await s3Storage.getStats('events/');
|
||||
console.log(`Total files: ${stats.totalCount}`);
|
||||
console.log(`Total size: ${stats.totalSizeFormatted}`);
|
||||
|
||||
// Listen to events
|
||||
s3Storage.on('uploadProgress', (data) => {
|
||||
console.log(`Uploading ${data.key}: ${data.loaded}/${data.total}`);
|
||||
});
|
||||
|
||||
s3Storage.on('uploadComplete', (data) => {
|
||||
console.log(`Upload completed: ${data.key}`);
|
||||
});
|
||||
|
||||
s3Storage.on('uploadError', (data) => {
|
||||
console.error(`Upload failed for ${data.key}:`, data.error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Integration with existing photo upload workflow
|
||||
async function integrateWithPhotoUpload(eventId, files) {
|
||||
const storage = new S3StorageAdapter({
|
||||
bucket: process.env.S3_BUCKET,
|
||||
region: process.env.AWS_REGION,
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
||||
});
|
||||
|
||||
const uploadedPhotos = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Generate unique S3 key
|
||||
const s3Key = storage.generateKey(file.originalname, `events/${eventId}`);
|
||||
|
||||
// Upload to S3
|
||||
const result = await storage.upload(file.path, s3Key, {
|
||||
contentType: file.mimetype,
|
||||
metadata: {
|
||||
eventId: eventId,
|
||||
originalName: file.originalname,
|
||||
uploadedAt: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
uploadedPhotos.push({
|
||||
filename: s3Key,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
s3Location: result.Location,
|
||||
s3Key: s3Key
|
||||
});
|
||||
|
||||
// Clean up local temp file
|
||||
await fs.promises.unlink(file.path);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to upload ${file.originalname}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedPhotos;
|
||||
}
|
||||
|
||||
// Environment variables needed:
|
||||
// AWS_ACCESS_KEY_ID=your-access-key
|
||||
// AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
// AWS_REGION=us-east-1
|
||||
// S3_BUCKET=your-bucket-name
|
||||
|
||||
// For MinIO:
|
||||
// MINIO_ENDPOINT=http://localhost:9000
|
||||
// MINIO_ACCESS_KEY=minioadmin
|
||||
// MINIO_SECRET_KEY=minioadmin
|
||||
// MINIO_BUCKET=photo-storage
|
||||
|
||||
module.exports = { examples, integrateWithPhotoUpload };
|
||||
@@ -7,7 +7,6 @@ const path = require('path');
|
||||
const stream = require('stream');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../../utils/logger');
|
||||
const { formatBytes } = require('../../utils/formatBytes');
|
||||
|
||||
/**
|
||||
* S3 Storage Adapter for handling file uploads to S3 and S3-compatible services
|
||||
@@ -494,14 +493,14 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
|
||||
let command;
|
||||
switch (operation.toLowerCase()) {
|
||||
case 'getobject':
|
||||
command = new GetObjectCommand(params);
|
||||
break;
|
||||
case 'putobject':
|
||||
command = new PutObjectCommand(params);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported operation: ${operation}`);
|
||||
case 'getobject':
|
||||
command = new GetObjectCommand(params);
|
||||
break;
|
||||
case 'putobject':
|
||||
command = new PutObjectCommand(params);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported operation: ${operation}`);
|
||||
}
|
||||
|
||||
return await getSignedUrl(this.s3Client, command, {
|
||||
@@ -638,7 +637,7 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
UploadId: uploadId
|
||||
}));
|
||||
} catch (abortError) {
|
||||
logger.error('Failed to abort multipart upload:', abortError);
|
||||
logger.error(`Failed to abort multipart upload:`, abortError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
@@ -739,7 +738,15 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
* @private
|
||||
*/
|
||||
_formatBytes(bytes, decimals = 2) {
|
||||
return formatBytes(bytes, decimals);
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||
const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
|
||||
@@ -88,7 +87,7 @@ const getMaxFilesPerUpload = async () => {
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return parsedValue;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read max files per upload setting:', error.message);
|
||||
console.error('Failed to read max files per upload setting:', error.message);
|
||||
cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
@@ -148,7 +147,7 @@ const getAllowedMimeTypes = async () => {
|
||||
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return mimeTypes;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read allowed file types setting:', error.message);
|
||||
console.error('Failed to read allowed file types setting:', error.message);
|
||||
const fallback = extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
cachedAllowedTypes = fallback;
|
||||
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
|
||||
@@ -12,7 +12,6 @@ const { db } = require('../database/db');
|
||||
const watermarkService = require('./watermarkService');
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||
const { withLocalCopy } = require('./imageProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class WatermarkGeneratorService {
|
||||
constructor() {
|
||||
@@ -64,8 +63,8 @@ class WatermarkGeneratorService {
|
||||
const storageKey = resolvePhotoStorageKey(event, photo);
|
||||
const result = storageKey
|
||||
? await withLocalCopy(storageKey, (lp) =>
|
||||
watermarkService.generateAndSaveWatermark(photo, lp, settings)
|
||||
)
|
||||
watermarkService.generateAndSaveWatermark(photo, lp, settings)
|
||||
)
|
||||
: await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings);
|
||||
|
||||
if (result.success) {
|
||||
@@ -80,7 +79,7 @@ class WatermarkGeneratorService {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Error generating watermark for photo ${photoId}:`, error);
|
||||
console.error(`Error generating watermark for photo ${photoId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -156,7 +155,7 @@ class WatermarkGeneratorService {
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error(`Error generating watermarks for event ${eventId}:`, error);
|
||||
console.error(`Error generating watermarks for event ${eventId}:`, error);
|
||||
return { ...results, errors: [...results.errors, error.message] };
|
||||
}
|
||||
}
|
||||
@@ -170,8 +169,8 @@ class WatermarkGeneratorService {
|
||||
const storageKey = resolvePhotoStorageKey(event, photo);
|
||||
const result = storageKey
|
||||
? await withLocalCopy(storageKey, (lp) =>
|
||||
watermarkService.generateAndSaveWatermark(photo, lp, settings)
|
||||
)
|
||||
watermarkService.generateAndSaveWatermark(photo, lp, settings)
|
||||
)
|
||||
: await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings);
|
||||
|
||||
if (result.success) {
|
||||
@@ -233,7 +232,7 @@ class WatermarkGeneratorService {
|
||||
return results;
|
||||
}
|
||||
|
||||
logger.info(`Starting watermark regeneration for ${photos.length} photos`);
|
||||
console.log(`Starting watermark regeneration for ${photos.length} photos`);
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < photos.length; i += this.batchSize) {
|
||||
@@ -282,11 +281,11 @@ class WatermarkGeneratorService {
|
||||
}
|
||||
|
||||
results.status = 'completed';
|
||||
logger.info(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
|
||||
console.log(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('Error during watermark regeneration:', error);
|
||||
console.error('Error during watermark regeneration:', error);
|
||||
results.status = 'failed';
|
||||
results.errors.push(error.message);
|
||||
return results;
|
||||
@@ -321,10 +320,10 @@ class WatermarkGeneratorService {
|
||||
watermark_generated_at: null
|
||||
});
|
||||
|
||||
logger.info(`Cleared ${photos.length} watermarks`);
|
||||
console.log(`Cleared ${photos.length} watermarks`);
|
||||
return { success: true, cleared: photos.length };
|
||||
} catch (error) {
|
||||
logger.error('Error clearing watermarks:', error);
|
||||
console.error('Error clearing watermarks:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -351,7 +350,7 @@ class WatermarkGeneratorService {
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting watermark for photo ${photoId}:`, error);
|
||||
console.error(`Error deleting watermark for photo ${photoId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { getStorage } = require('./storage');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class WatermarkService {
|
||||
constructor() {
|
||||
@@ -45,7 +44,7 @@ class WatermarkService {
|
||||
companyName: settingsObj.branding_company_name || 'Photo Gallery'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching watermark settings:', error);
|
||||
console.error('Error fetching watermark settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -139,7 +138,7 @@ class WatermarkService {
|
||||
|
||||
watermarkMetadata = { width: targetWidth, height: targetHeight };
|
||||
} catch (error) {
|
||||
logger.error('Error processing watermark logo:', error);
|
||||
console.error('Error processing watermark logo:', error);
|
||||
watermarkBuffer = null;
|
||||
}
|
||||
}
|
||||
@@ -210,7 +209,7 @@ class WatermarkService {
|
||||
|
||||
return watermarkedBuffer;
|
||||
} catch (error) {
|
||||
logger.error('Error applying watermark:', error);
|
||||
console.error('Error applying watermark:', error);
|
||||
// Return original image on error
|
||||
return await fs.readFile(imagePath);
|
||||
}
|
||||
@@ -288,7 +287,7 @@ class WatermarkService {
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error generating watermark for photo ${photo.id}:`, error);
|
||||
console.error(`Error generating watermark for photo ${photo.id}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
watermarkPath: null,
|
||||
@@ -309,7 +308,7 @@ class WatermarkService {
|
||||
await getStorage().delete(watermarkPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error deleting watermark file:', error);
|
||||
console.error('Error deleting watermark file:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('./logger');
|
||||
|
||||
// Default date format settings
|
||||
const DEFAULT_FORMAT = {
|
||||
@@ -20,7 +19,7 @@ async function formatDate(date, language = 'en') {
|
||||
try {
|
||||
dateConfig = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse date format setting:', e.message);
|
||||
console.warn('Failed to parse date format setting:', e.message);
|
||||
dateConfig = DEFAULT_FORMAT;
|
||||
}
|
||||
} else {
|
||||
@@ -47,7 +46,7 @@ async function formatDate(date, language = 'en') {
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
logger.error('Invalid date provided to formatDate:', date);
|
||||
console.error('Invalid date provided to formatDate:', date);
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
|
||||
@@ -92,7 +91,7 @@ async function formatDate(date, language = 'en') {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error formatting date:', error);
|
||||
console.error('Error formatting date:', error);
|
||||
// Fallback to basic formatting
|
||||
return date instanceof Date ? date.toLocaleDateString() : new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
// Note: Requiring db here creates circular dependency
|
||||
// db should be passed as parameter or required where needed
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Get database client type
|
||||
@@ -92,7 +91,7 @@ async function getDatabaseSize(db, dbName) {
|
||||
const stats = await fs.stat(dbPath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
logger.error('Error getting SQLite database size:', error);
|
||||
console.error('Error getting SQLite database size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,34 +30,6 @@
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { AppError } = require('./errors');
|
||||
const { getAppSetting } = require('./appSettings');
|
||||
|
||||
/**
|
||||
* Render a document-number format template.
|
||||
* Tokens: {YEAR}, {MONTH}, {SEQ:04d} (zero-padded), {SEQ}.
|
||||
*/
|
||||
function formatNumberInTemplate(format, year, seq) {
|
||||
return format
|
||||
.replace(/\{YEAR\}/g, String(year))
|
||||
.replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0'))
|
||||
.replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0'))
|
||||
.replace(/\{SEQ\}/g, String(seq));
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim + format the next document number for `kind` in the current
|
||||
* year, using the admin-configurable format stored under `settingKey`.
|
||||
*
|
||||
* Reads the setting through `trx` when present — getAppSetting on the
|
||||
* global db inside an open transaction deadlocks the single-connection
|
||||
* SQLite pool.
|
||||
*/
|
||||
async function nextDocumentNumber(kind, settingKey, defaultFormat, trx) {
|
||||
const format = (await getAppSetting(settingKey, null, trx || db)) || defaultFormat;
|
||||
const year = new Date().getFullYear();
|
||||
const seq = await claimNextSequence(kind, year, trx);
|
||||
return formatNumberInTemplate(format, year, seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the next sequence value for (kind, year). Returns the new
|
||||
@@ -118,4 +90,4 @@ async function claimNextSequence(kind, year, trx) {
|
||||
return await db.transaction(async (innerTrx) => exec(innerTrx));
|
||||
}
|
||||
|
||||
module.exports = { claimNextSequence, formatNumberInTemplate, nextDocumentNumber };
|
||||
module.exports = { claimNextSequence };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Secure file security utilities to prevent path traversal and validate file types
|
||||
@@ -175,7 +174,7 @@ async function validateFileContent(filePath, expectedMimeType) {
|
||||
return true;
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error validating file content:', error);
|
||||
console.error('Error validating file content:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -240,7 +239,7 @@ function createFileUploadValidator(options = {}) {
|
||||
try {
|
||||
await fs.unlink(file.path);
|
||||
} catch (err) {
|
||||
logger.error('Error removing invalid file:', err);
|
||||
console.error('Error removing invalid file:', err);
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: `File content does not match declared type: ${file.originalname}`
|
||||
@@ -251,7 +250,7 @@ function createFileUploadValidator(options = {}) {
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('File validation error:', error);
|
||||
console.error('File validation error:', error);
|
||||
res.status(500).json({ error: 'File validation failed' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Human-readable byte size, e.g. 1536 -> "1.5 KB".
|
||||
* Falsy/undefined input returns '0 Bytes'.
|
||||
*/
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (!bytes) {
|
||||
return '0 Bytes';
|
||||
}
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
module.exports = { formatBytes };
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
const { validationResult } = require('express-validator');
|
||||
const { ValidationError } = require('./errors');
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Wraps an async route handler to catch errors and pass them to the error handler.
|
||||
@@ -70,26 +69,25 @@ const successResponse = (res, data, statusCode = 200, message = null) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Logs an error and sends a standardized error response of shape `{ error: <string> }`.
|
||||
* Sends a standardized error response.
|
||||
* Note: Prefer throwing custom errors and letting the error handler format the response.
|
||||
*
|
||||
* @param {Response} res - Express response object
|
||||
* @param {Error|*} error - The caught error (logged, never sent to the client)
|
||||
* @param {string} message - Error message
|
||||
* @param {number} [statusCode=500] - HTTP status code
|
||||
* @param {string} [publicMessage] - Message sent to the client; falls back to the error's message
|
||||
* @param {string} [code] - Optional error code
|
||||
* @param {*} [details] - Optional additional error details
|
||||
*
|
||||
* @example
|
||||
* } catch (error) {
|
||||
* errorResponse(res, error, 500, 'Failed to fetch events');
|
||||
* }
|
||||
* errorResponse(res, 'Invalid input', 400, 'VALIDATION_ERROR', { field: 'email' });
|
||||
*/
|
||||
const errorResponse = (res, error, statusCode = 500, publicMessage) => {
|
||||
const message = publicMessage || (error instanceof Error ? error.message : String(error));
|
||||
const route = res.req ? `${res.req.method} ${res.req.originalUrl}` : null;
|
||||
logger.error(route ? `${route} - ${message}` : message, {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
});
|
||||
res.status(statusCode).json({ error: message });
|
||||
const errorResponse = (res, message, statusCode = 500, code = null, details = null) => {
|
||||
const response = {
|
||||
error: message,
|
||||
...(code && { code }),
|
||||
...(details && { details })
|
||||
};
|
||||
res.status(statusCode).json(response);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.79.1-beta.0",
|
||||
"version": "3.80.0-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
|
||||
import { Card, Button, Loading } from '../common';
|
||||
@@ -9,7 +9,6 @@ import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
|
||||
import { GuestInviteDialog } from './GuestInviteDialog';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast, useModal } from '../../hooks';
|
||||
|
||||
interface AdminGuestsListProps {
|
||||
eventId: number;
|
||||
@@ -21,34 +20,37 @@ type View = 'list' | 'aggregate';
|
||||
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
|
||||
const { t } = useTranslation();
|
||||
const { format: fmtDate } = useLocalizedDate();
|
||||
const queryClient = useQueryClient();
|
||||
const [view, setView] = useState<View>('list');
|
||||
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
|
||||
const [mergeMode, setMergeMode] = useState(false);
|
||||
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
|
||||
const inviteModal = useModal();
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin-guests', eventId],
|
||||
queryFn: () => guestsService.getEventGuests(eventId),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutationWithToast({
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId),
|
||||
successMessage: t('admin.guests.deletedToast', 'Guest removed'),
|
||||
invalidateKeys: [['admin-guests', eventId]],
|
||||
errorMessage: () => t('admin.guests.deletedError', 'Failed to remove guest'),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.deletedToast', 'Guest removed'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')),
|
||||
});
|
||||
|
||||
const mergeMutation = useMutationWithToast({
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) =>
|
||||
guestsService.mergeGuests(eventId, keepId, mergeIds),
|
||||
successMessage: t('admin.guests.mergedToast', 'Guests merged'),
|
||||
invalidateKeys: [['admin-guests', eventId]],
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.mergedToast', 'Guests merged'));
|
||||
setMergeMode(false);
|
||||
setMergeSelection([]);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
errorMessage: () => t('admin.guests.mergedError', 'Failed to merge guests'),
|
||||
onError: () => toast.error(t('admin.guests.mergedError', 'Failed to merge guests')),
|
||||
});
|
||||
|
||||
const handleDelete = (guest: AdminGuest) => {
|
||||
@@ -158,7 +160,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<UserPlus className="w-4 h-4" />}
|
||||
onClick={inviteModal.open}
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
>
|
||||
{t('admin.guests.createInvite', 'Create invite')}
|
||||
</Button>
|
||||
@@ -329,12 +331,12 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
||||
/>
|
||||
)}
|
||||
|
||||
{inviteModal.isOpen && (
|
||||
{inviteDialogOpen && (
|
||||
<GuestInviteDialog
|
||||
eventId={eventId}
|
||||
eventName={eventName}
|
||||
onClose={() => {
|
||||
inviteModal.close();
|
||||
setInviteDialogOpen(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user