Merge pull request #1266 from PicPeak/fix/testplan-2026-09-01

fix: resolve the 2026-09-01 QA run findings (#1-#21) and repo-health debt
This commit is contained in:
Paul Nothaft
2026-09-02 10:18:39 +02:00
committed by GitHub
190 changed files with 5939 additions and 1806 deletions
@@ -110,6 +110,11 @@ describe('Admin settings logo upload flow', () => {
} }
})); }));
jest.doMock('../src/middleware/permissions', () => ({
requirePermission: () => (req, res, next) => next(),
userHasAnyPermission: jest.fn().mockResolvedValue(true)
}));
jest.doMock('../src/services/publicSiteService', () => ({ jest.doMock('../src/services/publicSiteService', () => ({
clearPublicSiteCache: jest.fn(), clearPublicSiteCache: jest.fn(),
getDefaultPublicSitePayload: jest.fn(), getDefaultPublicSitePayload: jest.fn(),
@@ -40,6 +40,15 @@ describe('Admin photos in reference mode', () => {
} }
})); }));
// The routes gained requirePermission() after this fixture was written.
// It resolves the caller's role through admin_users/roles, which this
// minimal schema does not create, so every request died in the RBAC
// lookup before reaching the handler. RBAC is not what this suite is
// about — stub it out the same way adminAuth already is.
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next()
}));
jest.doMock('../../src/services/imageProcessor', () => ({ jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'), generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
ensureThumbnail: jest.fn() ensureThumbnail: jest.fn()
@@ -98,8 +107,21 @@ describe('Admin photos in reference mode', () => {
table.string('type').notNullable(); table.string('type').notNullable();
table.integer('size_bytes'); table.integer('size_bytes');
table.integer('category_id'); table.integer('category_id');
table.string('source_origin'); // Mirrors migration 041: the upload route never writes this column, it
// relies on the NOT NULL DEFAULT 'managed' to mark managed originals.
table.string('source_origin').notNullable().defaultTo('managed');
table.string('external_relpath'); table.string('external_relpath');
// Columns the upload insert writes (migrations 048, 062, 071, 085, 193)
// and the PATCH handler writes (migration 178). Without them the insert
// and the update both fail on "no such column".
table.string('original_filename', 512);
table.string('source_filename', 255);
table.datetime('captured_at').nullable();
table.string('media_type').defaultTo('image');
table.string('mime_type');
table.string('processing_status', 16).notNullable().defaultTo('complete');
table.string('upload_id', 64).nullable();
table.boolean('auto_categorized');
table.datetime('uploaded_at').defaultTo(db.fn.now()); table.datetime('uploaded_at').defaultTo(db.fn.now());
table.float('average_rating').defaultTo(0); table.float('average_rating').defaultTo(0);
table.integer('like_count').defaultTo(0); table.integer('like_count').defaultTo(0);
@@ -153,7 +175,10 @@ describe('Admin photos in reference mode', () => {
.field('category_id', String(categoryId)) .field('category_id', String(categoryId))
.attach('photos', Buffer.from('fake image data'), 'photo.jpg'); .attach('photos', Buffer.from('fake image data'), 'photo.jpg');
expect(uploadResponse.status).toBe(200); // 202 Accepted since the upload route went async (851744c3): the files are
// stored and a pending row is inserted, thumbnails/EXIF follow in the
// background worker. This assertion still said 200 from before that.
expect(uploadResponse.status).toBe(202);
expect(uploadResponse.body).toHaveProperty('photos'); expect(uploadResponse.body).toHaveProperty('photos');
expect(Array.isArray(uploadResponse.body.photos)).toBe(true); expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
@@ -203,5 +228,24 @@ describe('Admin photos in reference mode', () => {
const updated = await db('photos').where({ id: photo.id }).first(); const updated = await db('photos').where({ id: photo.id }).first();
expect(updated.category_id).toBeNull(); expect(updated.category_id).toBeNull();
// A real id still round-trips — the '0' guard must not swallow it.
await request(app)
.patch(`/api/admin/events/1/photos/${photo.id}`)
.send({ category_id: String(categoryId) })
.expect(200);
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBe(categoryId);
// Numeric 0 and unparseable input clear the category too, rather than
// writing a category id that can never exist.
for (const value of [0, 'not-a-category']) {
await request(app)
.patch(`/api/admin/events/1/photos/${photo.id}`)
.send({ category_id: value })
.expect(200);
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBeNull();
await db('photos').where({ id: photo.id }).update({ category_id: categoryId });
}
}); });
}); });
@@ -148,10 +148,15 @@ async function seedCustomerSignedContract() {
beforeAll(async () => { beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb()); ({ db, cleanup, tmpDir } = await bootCrmDb());
// Business-doc PDFs (quotes/invoices/contracts) persist under // Business-doc PDFs (quotes/invoices/contracts) persist under
// `process.cwd()/storage/business-docs/...` — chdir into the temp dir // `getStoragePath()/business-docs/...`, and safePath also allows a
// so every test artifact lands isolated and gets cleaned up. // `process.cwd()/storage/business-docs/...` root — chdir into the temp
// dir so every test artifact lands isolated and gets cleaned up.
process.chdir(tmpDir); process.chdir(tmpDir);
storageRoot = path.join(fs.realpathSync(tmpDir), 'storage', 'business-docs'); // Mirror what the services store: the raw STORAGE_PATH bootCrmDb
// exported, NOT a symlink-resolved variant. On macOS os.tmpdir() is
// /var/... while realpath is /private/var/..., so canonicalizing here
// would make every stored path fail the prefix check.
storageRoot = path.join(process.env.STORAGE_PATH, 'business-docs');
// Fail-fast on the pre-existing logActivity-inside-transaction // Fail-fast on the pre-existing logActivity-inside-transaction
// deadlock: createContract and createStorno call logActivity() from // deadlock: createContract and createStorno call logActivity() from
@@ -146,8 +146,8 @@ describe('webhook delivery worker (#327)', () => {
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 4, attempt_count: 4,
status: 'pending', status: 'pending',
next_retry_at: new Date(), next_retry_at: new Date().toISOString(),
created_at: new Date(), created_at: new Date().toISOString(),
}); });
await __test.tick(); await __test.tick();
@@ -190,8 +190,8 @@ describe('webhook delivery worker (#327)', () => {
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0, attempt_count: 0,
status: 'pending', status: 'pending',
next_retry_at: new Date(), next_retry_at: new Date().toISOString(),
created_at: new Date(), created_at: new Date().toISOString(),
}); });
await __test.tick(); await __test.tick();
@@ -214,8 +214,8 @@ describe('webhook delivery worker (#327)', () => {
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0, attempt_count: 0,
status: 'pending', status: 'pending',
next_retry_at: new Date(), next_retry_at: new Date().toISOString(),
created_at: new Date(), created_at: new Date().toISOString(),
}); });
await __test.tick(); await __test.tick();
@@ -0,0 +1,234 @@
/**
* The `gallery_created` German translation shipped as the English text
* verbatim on every fresh install (QA J.04), because 059 seeds `_de` from
* `_en` and 075 turns those columns into the `de` translation row.
*
* What is pinned here is as much about restraint as repair: the migration may
* only overwrite a German row that is still the English one, so a legacy
* install (whose German came from legacy migration 026) and any template an
* admin has edited themselves survive untouched.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/194_german_gallery_created_translation');
// The English copy seeded by migration 001, verbatim.
const EN_SUBJECT = 'Your Photo Gallery is Ready!';
const EN_HTML = `<h2>Gallery Created Successfully</h2>
<p>Dear {{host_name}},</p>
<p>Your photo gallery "{{event_name}}" has been created successfully!</p>
<p><strong>Gallery Details:</strong></p>
<ul>
<li>Event Date: {{event_date}}</li>
<li>Gallery Link: {{gallery_link}}</li>
<li>Password: {{gallery_password}}</li>
<li>Expires: {{expiry_date}}</li>
</ul>
<p>Share this link and password with your guests to allow them to view and download photos.</p>`;
const EN_TEXT = 'Gallery Created Successfully\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been created successfully!';
const placeholdersOf = (...parts) => {
const found = new Set();
for (const part of parts) {
for (const match of String(part || '').matchAll(/\{\{\s*([#/]?[\w.]+)\s*\}\}/g)) {
found.add(match[1]);
}
}
return [...found].sort();
};
describe('migration 194 — German gallery_created translation (QA J.04)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig194-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
const createTables = async () => {
await knex.schema.createTable('email_templates', (t) => {
t.increments('id').primary();
t.string('template_key').unique().notNullable();
t.string('subject_en');
t.string('subject_de');
t.text('body_html_en');
t.text('body_html_de');
t.text('body_text_en');
t.text('body_text_de');
t.json('variables');
t.string('updated_at');
});
await knex.schema.createTable('email_template_translations', (t) => {
t.increments('id').primary();
t.integer('template_id');
t.string('language', 10);
t.text('subject');
t.text('body_html');
t.text('body_text');
t.string('created_at');
t.string('updated_at');
});
};
const dropTables = async () => {
if (await knex.schema.hasTable('email_template_translations')) {
await knex.schema.dropTable('email_template_translations');
}
if (await knex.schema.hasTable('email_templates')) {
await knex.schema.dropTable('email_templates');
}
};
/** The state a fresh install lands in: 059 copied EN into the DE columns. */
const seedFreshInstall = async ({ deHtml = EN_HTML, deSubject = EN_SUBJECT, deText = EN_TEXT } = {}) => {
const [id] = await knex('email_templates').insert({
template_key: 'gallery_created',
subject_en: EN_SUBJECT,
subject_de: deSubject,
body_html_en: EN_HTML,
body_html_de: deHtml,
body_text_en: EN_TEXT,
body_text_de: deText,
variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date']),
});
await knex('email_template_translations').insert([
{ template_id: id, language: 'en', subject: EN_SUBJECT, body_html: EN_HTML, body_text: EN_TEXT },
{ template_id: id, language: 'de', subject: deSubject, body_html: deHtml, body_text: deText },
]);
return id;
};
const rowFor = async (templateId, language) =>
knex('email_template_translations').where({ template_id: templateId, language }).first();
beforeEach(async () => {
await dropTables();
await createTables();
});
it('replaces the English-as-German row with actual German', async () => {
const id = await seedFreshInstall();
await migration.up(knex);
const de = await rowFor(id, 'de');
expect(de.subject).not.toBe(EN_SUBJECT);
expect(de.body_html).not.toBe(EN_HTML);
expect(de.body_text).not.toBe(EN_TEXT);
expect(de.subject).toContain('Fotogalerie');
expect(de.body_html).toContain('Galerie erfolgreich erstellt');
expect(de.body_text).toContain('Passwort');
// The English row is not collateral damage.
const en = await rowFor(id, 'en');
expect(en.body_html).toBe(EN_HTML);
});
it('uses exactly the placeholder set of the English original', async () => {
const id = await seedFreshInstall();
await migration.up(knex);
const en = await rowFor(id, 'en');
const de = await rowFor(id, 'de');
const expected = placeholdersOf(en.subject, en.body_html, en.body_text);
expect(expected).toEqual(['event_date', 'event_name', 'expiry_date', 'gallery_link', 'gallery_password', 'host_name']);
expect(placeholdersOf(de.subject, de.body_html, de.body_text)).toEqual(expected);
});
it('repairs the legacy _de columns too', async () => {
const id = await seedFreshInstall();
await migration.up(knex);
const master = await knex('email_templates').where({ id }).first();
expect(master.body_html_de).not.toBe(EN_HTML);
expect(master.body_html_de).toContain('Galerie erfolgreich erstellt');
expect(master.subject_de).not.toBe(EN_SUBJECT);
expect(master.body_html_en).toBe(EN_HTML);
});
it('leaves an already-translated German row (and columns) alone', async () => {
// What a legacy install carries after legacy migration 026.
const legacyDe = '<h2>Galerie erfolgreich erstellt</h2><p>Liebe(r) {{host_name}},</p>';
const id = await seedFreshInstall({
deSubject: 'Ihre Fotogalerie ist bereit!',
deHtml: legacyDe,
deText: 'Galerie erfolgreich erstellt',
});
await migration.up(knex);
expect((await rowFor(id, 'de')).body_html).toBe(legacyDe);
expect((await knex('email_templates').where({ id }).first()).body_html_de).toBe(legacyDe);
});
it('fills in a missing German row', async () => {
const id = await seedFreshInstall();
await knex('email_template_translations').where({ template_id: id, language: 'de' }).del();
await migration.up(knex);
expect((await rowFor(id, 'de')).body_html).toContain('Galerie erfolgreich erstellt');
});
it('keeps a subject the admin translated while repairing the still-English body', async () => {
// Each field is judged on its own. Gating on body_html alone would have
// thrown this subject away — and down() is a no-op, so for good.
const adminSubject = 'Ihre Galerie steht bereit';
const id = await seedFreshInstall({ deSubject: adminSubject });
await migration.up(knex);
const de = await rowFor(id, 'de');
expect(de.subject).toBe(adminSubject);
expect(de.body_html).toContain('Galerie erfolgreich erstellt');
expect(de.body_text).toContain('Passwort');
const master = await knex('email_templates').where({ id }).first();
expect(master.subject_de).toBe(adminSubject);
expect(master.body_html_de).toContain('Galerie erfolgreich erstellt');
});
it('keeps an admin-translated body while repairing a still-English subject', async () => {
const adminHtml = '<p>Hallo {{host_name}}, „{{event_name}}“ ist online: {{gallery_link}} / {{gallery_password}} bis {{expiry_date}} ({{event_date}})</p>';
const id = await seedFreshInstall({ deHtml: adminHtml });
await migration.up(knex);
const de = await rowFor(id, 'de');
expect(de.body_html).toBe(adminHtml);
expect(de.subject).toBe('Ihre Fotogalerie ist bereit!');
expect((await knex('email_templates').where({ id }).first()).body_html_de).toBe(adminHtml);
});
it('is idempotent', async () => {
const id = await seedFreshInstall();
await migration.up(knex);
const once = await rowFor(id, 'de');
await migration.up(knex);
const twice = await rowFor(id, 'de');
expect(twice.body_html).toBe(once.body_html);
expect(await knex('email_template_translations').where({ template_id: id, language: 'de' }).count())
.toEqual([{ 'count(*)': 1 }]);
});
it('no-ops when the template or the tables are absent', async () => {
await expect(migration.up(knex)).resolves.toBeUndefined();
await dropTables();
await expect(migration.up(knex)).resolves.toBeUndefined();
await createTables();
});
});
@@ -0,0 +1,209 @@
/**
* The archives list must resolve search / type filter / sort in SQL.
*
* Before this, GET /admin/archives ignored every query param except page and
* limit: the UI fetched one 20-row page and filtered it in JavaScript while
* the pagination footer kept reporting the unfiltered server-side total. An
* archive that matched the search but lived on another page came back as a
* false "0 results". These tests pin the params the route now honours, and
* — the part that actually made the bug visible — that `pagination.total`
* describes the *filtered* set.
*/
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-archquery-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'archquery-test-secret';
const request = require('supertest');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb');
describe('GET /admin/archives query params (#I.01)', () => {
let db; let cleanup; let app; let token;
// name, type, archived_at, photo sizes
const fixtures = [
['Alpha Wedding', 'wedding', '2026-01-05T10:00:00.000Z', [300]],
['Bravo Birthday', 'birthday', '2026-02-05T10:00:00.000Z', [100]],
['Charlie Wedding', 'wedding', '2026-03-05T10:00:00.000Z', [500, 400]],
['Delta Corporate', 'corporate', '2026-04-05T10:00:00.000Z', [200]],
['Echo WEDDING Gala', 'wedding', '2026-05-05T10:00:00.000Z', [50]],
];
const list = async (query) => {
const res = await request(app)
.get('/admin/archives')
.query(query)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
return res.body;
};
const names = (body) => body.archives.map((a) => a.eventName);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'super_admin' }).first();
const inserted = await db('admin_users').insert({
username: 'arch-admin',
email: '[email protected]',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
const adminId = inserted[0]?.id ?? inserted[0];
token = jwt.sign(
{ id: adminId, username: 'arch-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
let i = 0;
for (const [eventName, eventType, archivedAt, sizes] of fixtures) {
const slug = `arch-${i++}`;
const ev = await db('events').insert({
slug,
event_type: eventType,
event_name: eventName,
event_date: '2026-08-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_token: `tok-${slug}`,
share_link: `/gallery/${slug}/tok-${slug}`,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 0,
is_archived: 1,
is_draft: 0,
archived_at: archivedAt,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ev[0]?.id ?? ev[0];
let p = 0;
for (const size of sizes) {
await db('photos').insert({
event_id: eventId,
filename: `${slug}-${p++}.jpg`,
path: `events/archived/${slug}.jpg`,
type: 'individual',
size_bytes: size,
uploaded_at: new Date().toISOString(),
});
}
}
// A live event that must never surface in the archives list.
await db('events').insert({
slug: 'not-archived',
event_type: 'wedding',
event_name: 'Alpha Live Wedding',
event_date: '2026-08-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_token: 'tok-not-archived',
share_link: '/gallery/not-archived/tok-not-archived',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
});
app = buildRouteApp('/admin/archives', require('../../src/routes/adminArchives'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
test('no params: every archive, newest first, unfiltered total', async () => {
const body = await list({});
expect(names(body)).toEqual([
'Echo WEDDING Gala', 'Delta Corporate', 'Charlie Wedding', 'Bravo Birthday', 'Alpha Wedding',
]);
expect(body.pagination.total).toBe(5);
});
test('search filters in SQL and the total describes the filtered set', async () => {
const body = await list({ search: 'wedding' });
// Case-insensitive, matches "Echo WEDDING Gala" too, and never the
// non-archived "Alpha Live Wedding".
expect(names(body).sort()).toEqual(['Alpha Wedding', 'Charlie Wedding', 'Echo WEDDING Gala']);
expect(body.pagination.total).toBe(3);
expect(body.pagination.totalPages).toBe(1);
});
test('search reaches rows that are not on page 1 — the actual bug', async () => {
// limit=2 puts "Alpha Wedding" (oldest) on page 3 of the unfiltered list.
// Client-side filtering of page 1 returned nothing for this query.
const body = await list({ search: 'alpha', limit: 2, page: 1 });
expect(names(body)).toEqual(['Alpha Wedding']);
expect(body.pagination.total).toBe(1);
});
test('search with no match returns an empty page and a zero total', async () => {
const body = await list({ search: 'zzz-nothing' });
expect(body.archives).toEqual([]);
expect(body.pagination.total).toBe(0);
expect(body.pagination.totalPages).toBe(0);
});
test('type filter narrows the rows and the total; "all" is a no-op', async () => {
const filtered = await list({ type: 'wedding' });
expect(names(filtered).sort()).toEqual(['Alpha Wedding', 'Charlie Wedding', 'Echo WEDDING Gala']);
expect(filtered.pagination.total).toBe(3);
const all = await list({ type: 'all' });
expect(all.pagination.total).toBe(5);
});
test('search and type filter combine', async () => {
const body = await list({ search: 'wedding', type: 'birthday' });
expect(body.archives).toEqual([]);
expect(body.pagination.total).toBe(0);
});
test('sortBy=name orders across the whole set, not just the page', async () => {
const page1 = await list({ sortBy: 'name', limit: 2, page: 1 });
expect(names(page1)).toEqual(['Alpha Wedding', 'Bravo Birthday']);
const page3 = await list({ sortBy: 'name', limit: 2, page: 3 });
expect(names(page3)).toEqual(['Echo WEDDING Gala']);
});
test('sortBy=size orders by archived content size, largest first', async () => {
const body = await list({ sortBy: 'size' });
expect(names(body)).toEqual([
'Charlie Wedding', // 900
'Alpha Wedding', // 300
'Delta Corporate', // 200
'Bravo Birthday', // 100
'Echo WEDDING Gala' // 50
]);
});
test('an unknown sortBy falls back to the date ordering', async () => {
const body = await list({ sortBy: 'events.id; drop table events' });
expect(names(body)[0]).toBe('Echo WEDDING Gala');
expect(body.pagination.total).toBe(5);
});
test('quotes in the search are bound as a value, not injected as SQL', async () => {
const body = await list({ search: '\'; DROP TABLE events; --' });
expect(body.archives).toEqual([]);
// The table is still there.
expect((await list({})).pagination.total).toBe(5);
});
});
@@ -0,0 +1,76 @@
/**
* photo_categories.name is varchar(100). Without a length check the insert
* hit Postgres' "value too long" and the route's catch turned it into a raw
* 500 with no message the form could surface — a >100-char name must come
* back as a normal 400 validation error instead.
*/
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-catlen-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'catlen-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-catlen-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const TOO_LONG = 'z'.repeat(101);
describe('category name length validation', () => {
let db; let cleanup; let app; let superTok;
const auth = (req) => req.set('Authorization', `Bearer ${superTok}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId: superId } = await seedMinimal(db);
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/categories', require('../../src/routes/adminCategories'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a >100-char name on create with a 400, not a 500', async () => {
const res = await auth(request(app).post('/api/admin/categories'))
.send({ name: TOO_LONG, is_global: true });
expect(res.status).toBe(400);
expect(res.body.errors.some((e) => e.path === 'name')).toBe(true);
const rows = await db('photo_categories').where('name', TOO_LONG);
expect(rows).toHaveLength(0);
});
it('rejects a >100-char name on update with a 400, not a 500', async () => {
const created = await auth(request(app).post('/api/admin/categories'))
.send({ name: 'zzcatlen-ok', is_global: true });
expect(created.status).toBe(200);
const res = await auth(request(app).put(`/api/admin/categories/${created.body.id}`))
.send({ name: TOO_LONG });
expect(res.status).toBe(400);
expect(res.body.errors.some((e) => e.path === 'name')).toBe(true);
const row = await db('photo_categories').where('id', created.body.id).first();
expect(row.name).toBe('zzcatlen-ok');
});
it('still accepts a name at exactly the 100-char limit', async () => {
const name = 'y'.repeat(100);
const res = await auth(request(app).post('/api/admin/categories'))
.send({ name, is_global: true });
expect(res.status).toBe(200);
expect(res.body.name).toBe(name);
});
});
@@ -0,0 +1,72 @@
/**
* Word-filter severity vocabulary. The Settings → Moderation UI offers
* low / moderate / high / block, but the validator only accepted the
* unrelated mild / moderate / severe set, so 3 of the 4 levels — including
* "block", the strongest tier — 400'd on every add.
*/
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-wfsev-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'wfsev-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-wfsev-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
describe('word filter severity levels', () => {
let db; let cleanup; let app; let superTok;
const auth = (req) => req.set('Authorization', `Bearer ${superTok}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId: superId } = await seedMinimal(db);
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/feedback', require('../../src/routes/adminFeedback'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it.each(['low', 'moderate', 'high', 'block'])('accepts severity "%s"', async (severity) => {
const word = `zzsev${severity}`;
const res = await auth(request(app).post('/api/admin/feedback/word-filters'))
.send({ word, severity });
expect(res.status).toBe(200);
const row = await db('feedback_word_filters').where({ word }).first();
expect(row.severity).toBe(severity);
});
it('still rejects a severity outside the vocabulary', async () => {
const res = await auth(request(app).post('/api/admin/feedback/word-filters'))
.send({ word: 'zzsevbogus', severity: 'catastrophic' });
expect(res.status).toBe(400);
expect(res.body.errors.some((e) => e.path === 'severity')).toBe(true);
});
it('blocks a comment matching a "block" filter and only flags a "low" one', async () => {
const moderation = require('../../src/services/feedbackModeration');
moderation.clearCache();
const blocked = await moderation.moderateText('this is zzsevblock speech');
expect(blocked.approved).toBe(false);
expect(blocked.violations.map((v) => v.word)).toEqual(['zzsevblock']);
const flagged = await moderation.moderateText('this is zzsevlow speech');
expect(flagged.approved).toBe(true);
expect(flagged.flagged).toBe(true);
});
});
@@ -0,0 +1,164 @@
/**
* Per-file upload size limit on the admin photo routes.
*
* `general_max_file_size_mb` (Settings → General, default 50MB) is what the
* dropzone advertises ("max. 50MB per file"), but the admin upload route
* hardcoded multer's cap at 10GB and the chunked-upload init route at 10GB
* too — so the advertised limit was never enforced anywhere server-side and a
* 50.74MB JPEG uploaded cleanly.
*
* Pins:
* - a file over the configured cap is rejected with a 400 naming the limit
* - the chunked-upload init route honours the same cap (it would otherwise
* be a trivial bypass of the multipart route's cap)
* - the chunk route enforces the cap on the bytes actually received, so a
* client can't declare `fileSize: 1` at init and stream past the limit
* - a file under the cap still gets past the size gate
* - the limit is read per request, so an admin raising it takes effect
*/
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-upload-size-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'upload-size-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-upload-size-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'upload-size-test-event';
describe('admin upload per-file size limit (general_max_file_size_mb)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
let uploadSettings;
const setLimitMb = async (mb) => {
await db('app_settings')
.insert({
setting_key: 'general_max_file_size_mb',
setting_value: JSON.stringify(mb),
setting_type: 'general',
updated_at: new Date().toISOString(),
})
.onConflict('setting_key')
.merge({ setting_value: JSON.stringify(mb) });
uploadSettings.clearMaxFileSizeCache();
};
const postUpload = (bytes, filename = 'shot.jpg') => request(app)
.post(`/api/admin/photos/${eventId}/upload`)
.set('Authorization', `Bearer ${adminToken}`)
.attach('photos', Buffer.alloc(bytes, 0x41), { filename, contentType: 'image/jpeg' });
const postChunkedInit = (fileSize) => request(app)
.post(`/api/admin/photos/${eventId}/chunked-upload/init`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ filename: 'clip.mp4', fileSize, mimeType: 'video/mp4', totalChunks: 1 });
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Upload Size Test',
event_date: '2026-09-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'upload-size-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'upload-size-admin',
email: '[email protected]',
password_hash: await bcrypt.hash('UploadSize123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'upload-size-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
uploadSettings = require('../../src/services/uploadSettings');
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a file over the configured limit with a 400 naming the limit', async () => {
await setLimitMb(1);
const res = await postUpload(2 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.');
});
it('rejects an over-limit chunked upload at init instead of allowing 10GB', async () => {
await setLimitMb(1);
const res = await postChunkedInit(200 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.');
});
it('rejects chunk bytes over the limit regardless of the declared fileSize', async () => {
await setLimitMb(1);
const initRes = await postChunkedInit(1);
expect(initRes.status).toBe(200);
const res = await request(app)
.post(`/api/admin/photos/${eventId}/chunked-upload/${initRes.body.uploadId}/chunk/0`)
.set('Authorization', `Bearer ${adminToken}`)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.alloc(2 * 1024 * 1024, 0x41));
expect(res.status).toBe(413);
expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.');
});
it('lets a file under the limit past the size gate', async () => {
await setLimitMb(1);
// Junk bytes, so it still fails downstream on the content check — that is
// the point: the failure is no longer about size.
const res = await postUpload(64 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File content does not match declared type: shot.jpg');
});
it('reads the limit per request, so raising it takes effect immediately', async () => {
await setLimitMb(1);
expect((await postUpload(2 * 1024 * 1024)).status).toBe(400);
await setLimitMb(10);
const res = await postUpload(2 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File content does not match declared type: shot.jpg');
});
});
@@ -0,0 +1,119 @@
/**
* Word-filter severity tiers, at the submission route.
*
* The Settings → Moderation UI advertises `block` as "comment is rejected
* immediately", but the submit route saved every non-approved comment with
* is_approved = false — identical handling to `moderate`/`high`. So the
* strongest tier stored the prohibited text anyway and only hid it from the
* public list.
*
* These three cases pin the tiers apart:
* block → 4xx, nothing written
* moderate / high → 201, stored held-for-moderation (is_approved = false)
* low → 201, stored approved (flag-only)
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'block-severity-secret';
const SLUG = 'block-severity';
describe('word-filter severity tiers at submission (#B11)', () => {
let db; let cleanup; let app;
let eventId; let photoId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const comment = (text) => request(app)
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ feedback_type: 'comment', comment_text: text });
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const [ev] = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Block Severity',
event_date: '2026-08-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'block-severity-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: `events/${SLUG}/shot.jpg`,
type: 'individual', uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = typeof p === 'object' ? p.id : p;
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: true, allow_comments: true,
moderate_comments: false, require_name_email: false,
show_feedback_to_guests: true,
});
await db('feedback_word_filters').insert([
{ word: 'zzblocked', severity: 'block', is_active: true, created_at: new Date().toISOString() },
{ word: 'zzmoderated', severity: 'moderate', is_active: true, created_at: new Date().toISOString() },
{ word: 'zzmild', severity: 'low', is_active: true, created_at: new Date().toISOString() },
]);
require('../../src/services/feedbackModeration').clearCache();
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('photo_feedback').where({ photo_id: photoId }).del();
});
it('rejects a "block" match outright and stores nothing', async () => {
const res = await comment('this is zzblocked content');
expect(res.status).toBe(400);
expect(res.body.code).toBe('COMMENT_BLOCKED');
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(0);
});
it('still holds a "moderate" match for moderation', async () => {
const res = await comment('this is zzmoderated content');
expect(res.status).toBeLessThan(400);
const rows = await db('photo_feedback').where({ photo_id: photoId });
expect(rows).toHaveLength(1);
expect([false, 0]).toContain(rows[0].is_approved);
expect(rows[0].comment_text).toContain('zzmoderated');
});
it('lets a "low" match through approved (flag only)', async () => {
const res = await comment('this is zzmild content');
expect(res.status).toBeLessThan(400);
const rows = await db('photo_feedback').where({ photo_id: photoId });
expect(rows).toHaveLength(1);
expect([true, 1]).toContain(rows[0].is_approved);
});
});
@@ -11,8 +11,27 @@ jest.mock('../../src/services/emailProcessor');
jest.mock('node-cron'); jest.mock('node-cron');
jest.mock('../../src/services/backupManifest'); jest.mock('../../src/services/backupManifest');
jest.mock('../../src/services/storage/s3Storage'); jest.mock('../../src/services/storage/s3Storage');
// runBackup lazily requires this from inside the run — resolve+register it
// here so the require doesn't hit the (mock-fs'd) filesystem mid-backup.
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: {
backup: jest.fn()
}
}));
// Same deal for the rsync path's lazy requires.
jest.mock('../../src/utils/safeExec', () => ({
spawnAsync: jest.fn(),
spawnToFile: jest.fn(),
spawnFromFile: jest.fn()
}));
jest.mock('../../src/utils/networkValidation', () => ({
isHostAllowed: jest.fn().mockResolvedValue(true)
}));
const backupService = require('../../src/services/backupService'); const backupService = require('../../src/services/backupService');
const { databaseBackupService } = require('../../src/services/databaseBackup');
const { spawnAsync } = require('../../src/utils/safeExec');
const { isHostAllowed } = require('../../src/utils/networkValidation');
const { db } = require('../../src/database/db'); const { db } = require('../../src/database/db');
const logger = require('../../src/utils/logger'); const logger = require('../../src/utils/logger');
const { queueEmail } = require('../../src/services/emailProcessor'); const { queueEmail } = require('../../src/services/emailProcessor');
@@ -20,6 +39,20 @@ const cron = require('node-cron');
const backupManifest = require('../../src/services/backupManifest'); const backupManifest = require('../../src/services/backupManifest');
const S3StorageAdapter = require('../../src/services/storage/s3Storage'); const S3StorageAdapter = require('../../src/services/storage/s3Storage');
// `runBackup` opens the run row with `db('backup_runs').insert(...).returning('id')`,
// so the insert mock has to be awaitable AND carry a `.returning()`.
const insertResult = (value) => {
const thenable = Promise.resolve(value);
thenable.returning = jest.fn().mockResolvedValue(value);
return thenable;
};
// Every runBackup goes through ensureDatabaseDumpForBackup, which stats the
// DB dump on disk and refuses to continue without it — seed it into every
// mock-fs tree.
const DB_DUMP_PATH = '/backup/db-dump.sql';
const mockStorage = (tree) => mockFs({ [DB_DUMP_PATH]: Buffer.from('database dump'), ...tree });
describe('Enhanced Backup Service Tests', () => { describe('Enhanced Backup Service Tests', () => {
let mockDb; let mockDb;
let mockS3Client; let mockS3Client;
@@ -36,7 +69,7 @@ describe('Enhanced Backup Service Tests', () => {
orderBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(),
first: jest.fn(), first: jest.fn(),
insert: jest.fn(), insert: jest.fn(() => insertResult([1])),
update: jest.fn(), update: jest.fn(),
delete: jest.fn() delete: jest.fn()
}; };
@@ -75,6 +108,19 @@ describe('Enhanced Backup Service Tests', () => {
logger.error = jest.fn(); logger.error = jest.fn();
logger.warn = jest.fn(); logger.warn = jest.fn();
logger.debug = jest.fn(); logger.debug = jest.fn();
// The inline DB dump and its on-disk verification run on every backup and
// throw when no dump is available — give both a passing default so each
// test can focus on the destination path it actually covers.
databaseBackupService.backup.mockResolvedValue({ path: DB_DUMP_PATH, size: 13 });
isHostAllowed.mockResolvedValue(true);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
type: 'sqlite',
backupFile: DB_DUMP_PATH,
size: 13,
checksum: 'abc123',
hasChanged: false
});
}); });
afterEach(() => { afterEach(() => {
@@ -132,7 +178,7 @@ describe('Enhanced Backup Service Tests', () => {
describe('S3 Backup Functionality', () => { describe('S3 Backup Functionality', () => {
beforeEach(() => { beforeEach(() => {
// Mock file system // Mock file system
mockFs({ mockStorage({
'/storage/events/active/event1': { '/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content'), 'photo1.jpg': Buffer.from('photo1 content'),
'photo2.jpg': Buffer.from('photo2 content') 'photo2.jpg': Buffer.from('photo2 content')
@@ -165,12 +211,12 @@ describe('Enhanced Backup Service Tests', () => {
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.where.mockReturnThis(); mockDb.where.mockReturnThis();
mockDb.first.mockResolvedValue(null); mockDb.first.mockResolvedValue(null);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({ jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
type: 'sqlite', type: 'sqlite',
backupFile: null, backupFile: DB_DUMP_PATH,
hasChanged: true hasChanged: true
}); });
@@ -202,7 +248,7 @@ describe('Enhanced Backup Service Tests', () => {
}; };
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null); mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -240,7 +286,7 @@ describe('Enhanced Backup Service Tests', () => {
}); });
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -265,7 +311,7 @@ describe('Enhanced Backup Service Tests', () => {
}; };
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({ jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
@@ -277,7 +323,7 @@ describe('Enhanced Backup Service Tests', () => {
}); });
// Mock database backup file // Mock database backup file
mockFs({ mockStorage({
'/storage/events/active': {}, '/storage/events/active': {},
'/backup/db-backup.sql': Buffer.from('database backup content') '/backup/db-backup.sql': Buffer.from('database backup content')
}); });
@@ -301,7 +347,7 @@ describe('Enhanced Backup Service Tests', () => {
}; };
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -326,12 +372,12 @@ describe('Enhanced Backup Service Tests', () => {
}; };
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null); mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({ mockStorage({
'/storage/events/active/event1': { '/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content') 'photo1.jpg': Buffer.from('photo1 content')
}, },
@@ -365,7 +411,7 @@ describe('Enhanced Backup Service Tests', () => {
}; };
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([2]); mockDb.insert.mockReturnValue(insertResult([2]));
mockDb.first.mockImplementation(() => Promise.resolve(lastBackup)); mockDb.first.mockImplementation(() => Promise.resolve(lastBackup));
mockDb.orderBy.mockReturnThis(); mockDb.orderBy.mockReturnThis();
mockDb.where.mockReturnThis(); mockDb.where.mockReturnThis();
@@ -373,7 +419,7 @@ describe('Enhanced Backup Service Tests', () => {
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({ mockStorage({
'/storage/events/active': {}, '/storage/events/active': {},
'/backup': {} '/backup': {}
}); });
@@ -395,7 +441,7 @@ describe('Enhanced Backup Service Tests', () => {
}; };
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null); mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -406,7 +452,7 @@ describe('Enhanced Backup Service Tests', () => {
}; };
backupManifest.generateManifest.mockResolvedValue(manifest); backupManifest.generateManifest.mockResolvedValue(manifest);
mockFs({ mockStorage({
'/storage/events/active': {}, '/storage/events/active': {},
'/storage/temp': {} '/storage/temp': {}
}); });
@@ -431,12 +477,12 @@ describe('Enhanced Backup Service Tests', () => {
}; };
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null); mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({ mockStorage({
'/storage/events/active/event1': { '/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content') 'photo1.jpg': Buffer.from('photo1 content')
}, },
@@ -461,28 +507,27 @@ describe('Enhanced Backup Service Tests', () => {
}; };
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null); mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
// Mock exec for rsync // rsync is spawned argv-style (no shell) — assert that shape, not the
const { exec } = require('child_process'); // legacy `exec('rsync ...')` string.
const mockExec = jest.fn((cmd, callback) => { spawnAsync.mockResolvedValue({
callback(null, { stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' }); stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes'
}); });
exec.mockImplementation(mockExec);
mockFs({ mockStorage({
'/storage/events/active': {} '/storage/events/active': {}
}); });
await backupService.runBackup(); await backupService.runBackup();
expect(mockExec).toHaveBeenCalledWith( expect(spawnAsync).toHaveBeenCalledWith('rsync', expect.any(Array));
expect.stringContaining('rsync'), const [, rsyncArgs] = spawnAsync.mock.calls[0];
expect.any(Function) expect(rsyncArgs).toContain('-avz');
); expect(rsyncArgs[rsyncArgs.length - 1]).toBe('[email protected]:/remote/backup');
}); });
}); });
@@ -497,7 +542,7 @@ describe('Enhanced Backup Service Tests', () => {
}; };
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.first.mockResolvedValue(null); mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
@@ -514,7 +559,7 @@ describe('Enhanced Backup Service Tests', () => {
return originalCreateReadStream(path); return originalCreateReadStream(path);
}); });
mockFs({ mockStorage({
'/storage/events/active': { '/storage/events/active': {
'error.jpg': Buffer.from('content'), 'error.jpg': Buffer.from('content'),
'good.jpg': Buffer.from('content') 'good.jpg': Buffer.from('content')
@@ -546,7 +591,7 @@ describe('Enhanced Backup Service Tests', () => {
]; ];
mockDb.select.mockResolvedValue([]); mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]); mockDb.insert.mockReturnValue(insertResult([1]));
mockDb.where.mockReturnThis(); mockDb.where.mockReturnThis();
jest.spyOn(backupService, 'getBackupConfig') jest.spyOn(backupService, 'getBackupConfig')
@@ -556,6 +601,12 @@ describe('Enhanced Backup Service Tests', () => {
// Force an error // Force an error
jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error')); jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error'));
// The DB-dump verification runs first and would throw its own error —
// give it a tree so 'Storage error' is what actually surfaces.
mockStorage({
'/storage/events/active': {}
});
// Mock admin users query // Mock admin users query
db.mockImplementation((table) => { db.mockImplementation((table) => {
if (table === 'admin_users') { if (table === 'admin_users') {
@@ -588,7 +639,7 @@ describe('Enhanced Backup Service Tests', () => {
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config); jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({ mockStorage({
'/storage/events/active': {}, '/storage/events/active': {},
'/backup': {} '/backup': {}
}); });
@@ -675,20 +726,32 @@ describe('Enhanced Backup Service Tests', () => {
]; ];
mockDb.limit.mockResolvedValue(recentRuns); mockDb.limit.mockResolvedValue(recentRuns);
// getBackupStatus also reads the backup config to compute the next run;
// an unscheduled/disabled backup legitimately yields null (#871).
mockDb.select.mockResolvedValue([
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_schedule', setting_value: '"daily"' }
]);
backupManifest.validateManifest.mockImplementation(() => true); backupManifest.validateManifest.mockImplementation(() => true);
const status = await backupService.getBackupStatus(); const status = await backupService.getBackupStatus();
// Runs are returned with a `created_at` alias for the frontend.
const run = { ...recentRuns[0], created_at: recentRuns[0].started_at };
expect(status).toEqual({ expect(status).toEqual({
isRunning: false, isRunning: false,
isHealthy: true, isHealthy: true,
lastRun: expect.objectContaining({ lastRun: { ...run, manifestValid: true },
...recentRuns[0], lastBackup: { ...run, manifestValid: true },
manifestValid: true lastSuccessfulBackup: run,
}), zombieRuns: [],
recentRuns: recentRuns, recentRuns: [run],
nextScheduledRun: expect.any(String) recentBackups: [run],
totalBackups: 1,
nextScheduledRun: expect.any(String),
nextBackup: expect.any(String)
}); });
}); });
@@ -0,0 +1,80 @@
/**
* The chunked-upload per-file cap has to hold on the bytes actually received,
* not on the client-declared `fileSize` the init route validates. Declaring
* `fileSize: 1` and then streaming 10 GB through the chunk route was a
* complete bypass of general_max_file_size_mb; the merge step only logged a
* size mismatch and processed the file anyway.
*/
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-cap-test-${process.pid}`);
const chunkedUpload = require('../../src/services/chunkedUploadService');
const MB = 1024 * 1024;
const init = (overrides = {}) => chunkedUpload.initializeUpload({
filename: 'clip.mp4',
fileSize: 1,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 2,
maxFileSizeBytes: 1 * MB,
...overrides,
});
describe('chunkedUploadService per-file size cap', () => {
afterAll(async () => {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
});
it('rejects a single chunk over the cap even when the declared fileSize is tiny', async () => {
const { uploadId } = await init();
await expect(chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(2 * MB)))
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
// Aborted, not merely rejected: the upload can no longer be completed.
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
});
it('rejects when the running total across chunks crosses the cap', async () => {
const { uploadId } = await init();
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.75 * MB));
await expect(chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(0.5 * MB)))
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE' });
});
it('counts a re-sent chunk once, not twice', async () => {
const { uploadId } = await init();
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.6 * MB));
// Same index again — replaces the earlier bytes, so the total stays 0.6 MB.
await expect(chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.6 * MB))).resolves.toBeTruthy();
await expect(chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(0.3 * MB))).resolves.toBeTruthy();
});
it('rejects chunk indices outside the announced range', async () => {
const { uploadId } = await init();
await expect(chunkedUpload.uploadChunk(uploadId, 2, Buffer.alloc(10)))
.rejects.toMatchObject({ code: 'INVALID_CHUNK', statusCode: 400 });
await expect(chunkedUpload.uploadChunk(uploadId, -1, Buffer.alloc(10)))
.rejects.toMatchObject({ code: 'INVALID_CHUNK' });
await expect(chunkedUpload.uploadChunk(uploadId, NaN, Buffer.alloc(10)))
.rejects.toMatchObject({ code: 'INVALID_CHUNK' });
});
it('merges an upload under the cap and reports the real size', async () => {
const { uploadId } = await init();
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(400 * 1024, 0x41));
await chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(400 * 1024, 0x42));
const merged = await chunkedUpload.completeUpload(uploadId);
expect(merged.size).toBe(800 * 1024);
await fs.rm(merged.tempDir, { recursive: true, force: true });
});
it('applies no cap when none is given', async () => {
const { uploadId } = await init({ maxFileSizeBytes: undefined, totalChunks: 1 });
await expect(chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(3 * MB))).resolves.toBeTruthy();
await chunkedUpload.abortUpload(uploadId);
});
});
@@ -0,0 +1,126 @@
/**
* Migration 194: give `gallery_created` a real German translation.
*
* On a fresh install the German copy of this template is the ENGLISH copy,
* verbatim. Migration 059 introduces the multilingual columns by seeding
* `subject_de`/`body_html_de`/`body_text_de` from their `_en` counterparts
* ("Copy to German as default"), and migration 075 then materialises exactly
* those columns as the `de` row in `email_template_translations`. The proper
* German lived only in the LEGACY migrations (009/026), which never run on a
* fresh install — so every install created since then mails English to
* German-locale recipients, while nl/pt/ru/fr/es/sl are all localised.
*
* This is the most customer-visible transactional mail we send (one per
* published gallery), so it is repaired as a content UPDATE: a code-only fix
* would leave every existing install on the English-as-German row forever,
* because Knex will not re-run 059/075.
*
* Idempotent, and deliberately conservative about WHICH rows it touches: the
* German row is rewritten only while it is still byte-identical to the English
* one (or empty), which is precisely the broken state. A legacy install whose
* German came from migration 026, or any install where an admin has edited the
* template themselves, is left alone.
*
* The placeholder set matches the English original exactly — host_name,
* event_name, event_date, gallery_link, gallery_password, expiry_date — which
* is also the template's declared `variables` array.
*/
const SUBJECT_DE = 'Ihre Fotogalerie ist bereit!';
const HTML_DE = `<h2>Galerie erfolgreich erstellt</h2>
<p>Guten Tag {{host_name}},</p>
<p>Ihre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!</p>
<p><strong>Details zur Galerie:</strong></p>
<ul>
<li>Veranstaltungsdatum: {{event_date}}</li>
<li>Link zur Galerie: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Passwort: {{gallery_password}}</li>
<li>Verfügbar bis: {{expiry_date}}</li>
</ul>
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.</p>`;
const TEXT_DE = 'Galerie erfolgreich erstellt\n\nGuten Tag {{host_name}},\n\nIhre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!\n\nVeranstaltungsdatum: {{event_date}}\nLink zur Galerie: {{gallery_link}}\nPasswort: {{gallery_password}}\nVerfügbar bis: {{expiry_date}}\n\nTeilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.';
// "Not translated yet" = empty, or still the English text.
const isUntranslated = (german, english) => {
const de = (german || '').trim();
if (!de) return true;
return de === (english || '').trim();
};
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
const master = await knex('email_templates')
.where('template_key', 'gallery_created')
.first();
if (!master) return; // Template not seeded on this install — nothing to fix.
const now = new Date().toISOString();
if (await knex.schema.hasTable('email_template_translations')) {
const enRow = await knex('email_template_translations')
.where({ template_id: master.id, language: 'en' })
.first();
const deRow = await knex('email_template_translations')
.where({ template_id: master.id, language: 'de' })
.first();
const englishHtml = (enRow && enRow.body_html) || master.body_html_en || master.body_html || '';
if (!deRow) {
await knex('email_template_translations').insert({
template_id: master.id,
language: 'de',
subject: SUBJECT_DE,
body_html: HTML_DE,
body_text: TEXT_DE,
created_at: now,
updated_at: now,
});
} else {
// Each field is judged on its own. Gating all three on body_html would
// overwrite a subject the admin had already translated whenever the HTML
// still matched English — and down() is a deliberate no-op, so that loss
// would be unrecoverable.
const englishSubject = (enRow && enRow.subject) || master.subject_en || master.subject || '';
const englishText = (enRow && enRow.body_text) || master.body_text_en || master.body_text || '';
const patch = {};
if (isUntranslated(deRow.subject, englishSubject)) patch.subject = SUBJECT_DE;
if (isUntranslated(deRow.body_html, englishHtml)) patch.body_html = HTML_DE;
if (isUntranslated(deRow.body_text, englishText)) patch.body_text = TEXT_DE;
if (Object.keys(patch).length > 0) {
patch.updated_at = now;
await knex('email_template_translations').where({ id: deRow.id }).update(patch);
}
}
}
// Legacy per-language columns on the master row — still the fallback path in
// emailProcessor.processTemplate when the translations table is unavailable.
const cols = await knex('email_templates').columnInfo();
if (cols.body_html_de) {
// Same per-field rule as the translations table above.
const legacyPatch = {};
if (cols.subject_de && isUntranslated(master.subject_de, master.subject_en)) {
legacyPatch.subject_de = SUBJECT_DE;
}
if (isUntranslated(master.body_html_de, master.body_html_en)) {
legacyPatch.body_html_de = HTML_DE;
}
if (cols.body_text_de && isUntranslated(master.body_text_de, master.body_text_en)) {
legacyPatch.body_text_de = TEXT_DE;
}
if (Object.keys(legacyPatch).length > 0) {
legacyPatch.updated_at = now;
await knex('email_templates').where({ id: master.id }).update(legacyPatch);
}
}
};
exports.down = async function() {
// No-op: reverting would restore English-as-German. Admins who want
// different copy can edit it under Settings → Email → Templates.
};
@@ -65,7 +65,7 @@ function makeRes() {
res.json = jest.fn().mockReturnValue(res); res.json = jest.fn().mockReturnValue(res);
return res; return res;
} }
function makeReq({ token = 'tkn', cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) { function makeReq({ cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) {
return { headers: { authorization: undefined, ...headers }, cookies, originalUrl, ip, connection: { remoteAddress: ip } }; return { headers: { authorization: undefined, ...headers }, cookies, originalUrl, ip, connection: { remoteAddress: ip } };
} }
@@ -19,7 +19,7 @@ const { sanitizeCss } = require('../utils/cssSanitizer');
const buildPublicSiteRows = (overrides = {}) => ([ const buildPublicSiteRows = (overrides = {}) => ([
{ setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) }, { setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) },
{ setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') }, { setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') },
{ setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") } { setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? 'body { color: red; }') }
]); ]);
const buildBrandingRows = (overrides = {}) => ([ const buildBrandingRows = (overrides = {}) => ([
@@ -64,7 +64,7 @@ describe('publicSiteService', () => {
it('sanitizes custom CSS and removes dangerous patterns', async () => { it('sanitizes custom CSS and removes dangerous patterns', async () => {
const publicSiteRows = buildPublicSiteRows({ const publicSiteRows = buildPublicSiteRows({
css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }" css: 'body { color: blue; } @import url(\'https://malicious.example/style.css\'); div { background: url("javascript:alert(1)"); }'
}); });
const brandingRows = buildBrandingRows(); const brandingRows = buildBrandingRows();
+7 -7
View File
@@ -21,7 +21,7 @@ try {
} }
} catch (e) { } catch (e) {
// Non-fatal: log and continue; SQLite will fail later if still missing // Non-fatal: log and continue; SQLite will fail later if still missing
try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {} try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) { /* non-fatal */ }
} }
// Create database connection with built-in retry logic. // Create database connection with built-in retry logic.
@@ -205,20 +205,20 @@ async function initializeDatabase() {
) )
`); `);
const pragmaRows = await db.raw("PRAGMA table_info('events')"); const pragmaRows = await db.raw('PRAGMA table_info(\'events\')');
const existingColumns = pragmaRows.map(row => row.name); const existingColumns = pragmaRows.map(row => row.name);
const selectColumns = existingColumns.map((col) => { const selectColumns = existingColumns.map((col) => {
switch (col) { switch (col) {
case 'allow_user_uploads': case 'allow_user_uploads':
return "COALESCE(allow_user_uploads, 0) as allow_user_uploads"; return 'COALESCE(allow_user_uploads, 0) as allow_user_uploads';
case 'upload_category_id': case 'upload_category_id':
return "upload_category_id"; return 'upload_category_id';
case 'allow_downloads': case 'allow_downloads':
return "COALESCE(allow_downloads, 1) as allow_downloads"; return 'COALESCE(allow_downloads, 1) as allow_downloads';
case 'disable_right_click': case 'disable_right_click':
return "COALESCE(disable_right_click, 0) as disable_right_click"; return 'COALESCE(disable_right_click, 0) as disable_right_click';
case 'watermark_downloads': case 'watermark_downloads':
return "COALESCE(watermark_downloads, 0) as watermark_downloads"; return 'COALESCE(watermark_downloads, 0) as watermark_downloads';
case 'watermark_text': case 'watermark_text':
return 'watermark_text'; return 'watermark_text';
case 'hero_photo_id': case 'hero_photo_id':
+1 -1
View File
@@ -176,7 +176,7 @@ function feedbackRateLimit(actionType) {
return res.status(429).json({ return res.status(429).json({
error: 'Too many requests', error: 'Too many requests',
message: `Rate limit exceeded. Please try again later.`, message: 'Rate limit exceeded. Please try again later.',
retryAfter: rateLimitStatus.window retryAfter: rateLimitStatus.window
}); });
} }
+1 -1
View File
@@ -27,7 +27,7 @@ function requireEventOwnership(req, res, next) {
} }
next(); next();
}) })
.catch((err) => { .catch((_err) => {
res.status(500).json({ error: 'Failed to verify ownership' }); res.status(500).json({ error: 'Failed to verify ownership' });
}); });
} }
@@ -1,7 +1,6 @@
const { db } = require('../database/db'); const { db } = require('../database/db');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
/** /**
* Enhanced secure image middleware with comprehensive protection * Enhanced secure image middleware with comprehensive protection
@@ -69,7 +68,7 @@ class SecureImageMiddleware {
/** /**
* Perform comprehensive security checks * Perform comprehensive security checks
*/ */
async performSecurityChecks(req, res) { async performSecurityChecks(req, _res) {
const { clientInfo } = req; const { clientInfo } = req;
const { photoId } = req.params; const { photoId } = req.params;
@@ -132,7 +131,6 @@ class SecureImageMiddleware {
*/ */
async checkRateLimit(req) { async checkRateLimit(req) {
const { clientInfo } = req; const { clientInfo } = req;
const now = Date.now();
// Get rate limit settings from database // Get rate limit settings from database
const settings = await this.getRateLimitSettings(); const settings = await this.getRateLimitSettings();
+2 -1
View File
@@ -24,7 +24,8 @@ function secureStatic(basePath, options = {}) {
try { try {
// Validate the full path is within the base directory // Validate the full path is within the base directory
const fullPath = safePathJoin(normalizedBase, requestedPath); // Validates the path stays inside the base dir; throws on traversal.
safePathJoin(normalizedBase, requestedPath);
// If validation passes, use express.static // If validation passes, use express.static
const staticMiddleware = express.static(normalizedBase, { const staticMiddleware = express.static(normalizedBase, {
+2 -2
View File
@@ -135,7 +135,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
// Clean up old token if user has a new one // Clean up old token if user has a new one
// This prevents memory leaks from token renewals // This prevents memory leaks from token renewals
const userId = decoded.id; const userId = decoded.id;
for (const [oldToken, _] of sessions.entries()) { for (const oldToken of sessions.keys()) {
if (oldToken !== token) { if (oldToken !== token) {
try { try {
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] }); const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] });
@@ -198,7 +198,7 @@ function getActiveSessions() {
const now = Date.now(); const now = Date.now();
let active = 0; let active = 0;
for (const [_, lastActivity] of sessions.entries()) { for (const lastActivity of sessions.values()) {
if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) { if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) {
active++; active++;
} }
+17 -7
View File
@@ -36,11 +36,13 @@ jest.mock('../../middleware/auth', () => ({
const { db, logActivity } = require('../../database/db'); const { db, logActivity } = require('../../database/db');
const adminAuthRouter = require('../adminAuth'); const adminAuthRouter = require('../adminAuth');
const { errorHandler } = require('../../middleware/errorHandler');
describe('adminAuth profile updates', () => { describe('adminAuth profile updates', () => {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use('/auth/admin', adminAuthRouter); app.use('/auth/admin', adminAuthRouter);
app.use(errorHandler);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -55,8 +57,8 @@ describe('adminAuth profile updates', () => {
}; };
db.__setImplementations( db.__setImplementations(
buildChain({ firstResult: null }), // email check
buildChain({ firstResult: null }), // username check buildChain({ firstResult: null }), // username check
buildChain({ firstResult: null }), // email check
buildChain({ updateResult: 1 }), // update buildChain({ updateResult: 1 }), // update
buildChain({ firstResult: updatedUser }), // fetch updated user buildChain({ firstResult: updatedUser }), // fetch updated user
); );
@@ -66,18 +68,22 @@ describe('adminAuth profile updates', () => {
.send({ username: updatedUser.username, email: updatedUser.email }) .send({ username: updatedUser.username, email: updatedUser.email })
.expect(200); .expect(200);
expect(response.body).toEqual({ user: updatedUser }); expect(response.body).toEqual({
message: 'Admin profile updated successfully',
user: updatedUser
});
expect(logActivity).toHaveBeenCalledWith( expect(logActivity).toHaveBeenCalledWith(
'admin_profile_updated', 'admin_profile_updated',
{ admin_id: 1, updated_fields: ['username', 'email'] }, { username: updatedUser.username, email: updatedUser.email },
null, null,
{ type: 'admin', id: 1, name: updatedUser.username } { type: 'admin', id: 1, name: 'admin' }
); );
}); });
it('rejects email conflicts', async () => { it('rejects email conflicts', async () => {
db.__setImplementations( db.__setImplementations(
buildChain({ firstResult: { id: 2 } }) buildChain({ firstResult: null }), // username check
buildChain({ firstResult: { id: 2 } }), // email check
); );
const response = await request(app) const response = await request(app)
@@ -85,7 +91,11 @@ describe('adminAuth profile updates', () => {
.send({ username: 'newadmin', email: '[email protected]' }) .send({ username: 'newadmin', email: '[email protected]' })
.expect(409); .expect(409);
expect(response.body).toEqual({ error: 'Email is already in use by another admin' }); expect(response.body).toEqual({
error: 'Email address is already in use',
code: 'CONFLICT',
field: 'email'
});
}); });
it('validates input', async () => { it('validates input', async () => {
@@ -94,6 +104,6 @@ describe('adminAuth profile updates', () => {
.send({ username: '', email: 'not-an-email' }) .send({ username: '', email: 'not-an-email' })
.expect(400); .expect(400);
expect(response.body.errors).toBeDefined(); expect(response.body.details).toBeDefined();
}); });
}); });
+46 -10
View File
@@ -6,7 +6,6 @@ const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug'); const { slugify } = require('../utils/slug');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const StreamZip = require('node-stream-zip'); const StreamZip = require('node-stream-zip');
const { requireEventOwnership } = require('../middleware/ownership'); const { requireEventOwnership } = require('../middleware/ownership');
const { assertZipEntriesWithin } = require('../utils/safePath'); const { assertZipEntriesWithin } = require('../utils/safePath');
@@ -19,15 +18,43 @@ const router = express.Router();
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => { router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
try { try {
const { page, limit, offset } = getPagination(req); const { page, limit, offset } = getPagination(req);
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
const type = typeof req.query.type === 'string' ? req.query.type.trim() : '';
const sortBy = ['date', 'name', 'size'].includes(req.query.sortBy) ? req.query.sortBy : 'date';
// Get total count // Search and type filtering run in SQL so both the returned rows and
const totalCount = await db('events') // the total count cover the whole archive table, not just the page the
.where('is_archived', formatBoolean(true)) // client happens to be on. Values are bound, never interpolated.
.count('id as count') // % and _ are wildcards to LIKE but literal characters to the client-side
// `includes()` this replaced, so searching for "100%" would otherwise match
// every archive and report a nonsense total. The ESCAPE clause is
// load-bearing rather than decorative: SQLite has no default LIKE escape
// character, so without it the escaped pattern matches literal backslashes
// there while working on Postgres.
const escapeLike = (value) => value.replace(/[\\%_]/g, '\\$&');
const applyFilters = (query) => {
if (search) {
query.whereRaw(
'LOWER(events.event_name) LIKE ? ESCAPE \'\\\'',
[`%${escapeLike(search.toLowerCase())}%`]
);
}
if (type && type !== 'all') {
query.where('events.event_type', type);
}
return query;
};
// Get total count (of the filtered set, so pagination stays truthful)
const totalCount = await applyFilters(
db('events').where('events.is_archived', formatBoolean(true))
)
.count('events.id as count')
.first(); .first();
// Get archived events // Get archived events
const archives = await db('events') const archivesQuery = applyFilters(
db('events')
.select( .select(
'events.*', 'events.*',
db.raw('COUNT(DISTINCT photos.id) as photo_count'), db.raw('COUNT(DISTINCT photos.id) as photo_count'),
@@ -35,10 +62,19 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
) )
.leftJoin('photos', 'events.id', 'photos.event_id') .leftJoin('photos', 'events.id', 'photos.event_id')
.where('events.is_archived', formatBoolean(true)) .where('events.is_archived', formatBoolean(true))
.groupBy('events.id') ).groupBy('events.id');
.orderBy('events.archived_at', 'desc')
.limit(limit) if (sortBy === 'name') {
.offset(offset); archivesQuery.orderBy('events.event_name', 'asc');
} else if (sortBy === 'size') {
// The zip's on-disk size is only known after the per-row fs.stat below,
// so a global size sort has to use the archived content size instead.
archivesQuery.orderByRaw('COALESCE(SUM(photos.size_bytes), 0) desc');
} else {
archivesQuery.orderBy('events.archived_at', 'desc');
}
const archives = await archivesQuery.limit(limit).offset(offset);
// Check if archive files exist and get their sizes // Check if archive files exist and get their sizes
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
+13 -9
View File
@@ -346,7 +346,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
const { destination_type, ...config } = req.body; const { destination_type, ...config } = req.body;
switch (destination_type) { switch (destination_type) {
case 'local': case 'local': {
// Test local path access // Test local path access
const fs = require('fs').promises; const fs = require('fs').promises;
try { try {
@@ -360,8 +360,9 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' }); res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
} }
break; break;
}
case 'rsync': case 'rsync': {
// Test rsync connection using spawn with argument arrays to prevent command injection // Test rsync connection using spawn with argument arrays to prevent command injection
const { spawn } = require('child_process'); const { spawn } = require('child_process');
@@ -425,7 +426,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
sshArgs.push('echo', 'Connection successful'); sshArgs.push('echo', 'Connection successful');
try { try {
const result = await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const sshProcess = spawn('ssh', sshArgs, { const sshProcess = spawn('ssh', sshArgs, {
timeout: 15000, timeout: 15000,
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe']
@@ -459,6 +460,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' }); res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
} }
break; break;
}
case 's3': case 's3':
// Test S3 connection (would need AWS SDK) // Test S3 connection (would need AWS SDK)
@@ -829,7 +831,7 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a
// Handle different backup types // Handle different backup types
switch (config.backup_destination_type) { switch (config.backup_destination_type) {
case 'local': case 'local': {
// Stream local backup as zip // Stream local backup as zip
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`); const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
const archive = archiver('zip', { zlib: { level: 9 } }); const archive = archiver('zip', { zlib: { level: 9 } });
@@ -847,8 +849,9 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a
await archive.finalize(); await archive.finalize();
break; break;
}
case 's3': case 's3': {
// For S3, provide pre-signed URLs or stream files // For S3, provide pre-signed URLs or stream files
const s3Adapter = new S3StorageAdapter({ const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint, endpoint: config.backup_s3_endpoint,
@@ -882,6 +885,7 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a
message: 'Use the provided URLs to download individual files' message: 'Use the provided URLs to download individual files'
}); });
break; break;
}
case 'rsync': case 'rsync':
return res.status(400).json({ error: 'Direct download not available for rsync backups' }); return res.status(400).json({ error: 'Direct download not available for rsync backups' });
@@ -909,7 +913,7 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
} }
// Calculate checksums for files // Calculate checksums for files
async function calculateDirChecksums(dirPath, relative = '') { const calculateDirChecksums = async (dirPath, relative = '') => {
try { try {
const entries = await fs.readdir(dirPath, { withFileTypes: true }); const entries = await fs.readdir(dirPath, { withFileTypes: true });
@@ -940,7 +944,7 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
} catch (error) { } catch (error) {
logger.error(`Failed to calculate checksums for ${dirPath}:`, error); logger.error(`Failed to calculate checksums for ${dirPath}:`, error);
} }
} };
await calculateDirChecksums(basePath); await calculateDirChecksums(basePath);
@@ -978,7 +982,7 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req
const breakdown = {}; const breakdown = {};
// Estimate size for each directory // Estimate size for each directory
async function estimateDir(dirPath, category) { const estimateDir = async (dirPath, category) => {
let dirSize = 0; let dirSize = 0;
let dirCount = 0; let dirCount = 0;
@@ -1005,7 +1009,7 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req
} }
return { size: dirSize, count: dirCount }; return { size: dirSize, count: dirCount };
} };
// Estimate each category // Estimate each category
const categories = [ const categories = [
+7 -2
View File
@@ -40,7 +40,11 @@ router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), req
// Create a new category // Create a new category
router.post('/', adminAuth, requirePermission('settings.edit'), [ router.post('/', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required'), // photo_categories.name is varchar(100) — without the length check Postgres
// raises "value too long" and the catch below turns it into a raw 500 with
// no usable message for the form.
body('name').notEmpty().withMessage('Category name is required')
.isLength({ max: 100 }).withMessage('Category name must be at most 100 characters'),
body('slug').optional(), body('slug').optional(),
body('is_global').optional().isBoolean(), body('is_global').optional().isBoolean(),
body('event_id').optional().isInt(), body('event_id').optional().isInt(),
@@ -127,7 +131,8 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
// Update a category // Update a category
router.put('/:id', adminAuth, requirePermission('settings.edit'), [ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required'), body('name').notEmpty().withMessage('Category name is required')
.isLength({ max: 100 }).withMessage('Category name must be at most 100 characters'),
body('hero_photo_id').optional({ nullable: true }).custom((value) => { body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true; if (value === null || value === undefined) return true;
return Number.isInteger(Number(value)); return Number.isInteger(Number(value));
+1 -1
View File
@@ -2,7 +2,7 @@ const express = require('express');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity'); const { sanitizeDays } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { resolveAdapter } = require('../services/trackers'); const { resolveAdapter } = require('../services/trackers');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
-2
View File
@@ -318,8 +318,6 @@ module.exports = (router) => {
css_template_id = null, css_template_id = null,
// Hero logo settings // Hero logo settings
hero_logo_visible = true, hero_logo_visible = true,
hero_logo_size = 'medium',
hero_logo_position = 'top',
// Header style settings // Header style settings
header_style = 'standard', header_style = 'standard',
hero_divider_style = 'wave', hero_divider_style = 'wave',
+3 -2
View File
@@ -176,8 +176,9 @@ const mapEventForApi = (event) => {
customer_name, customer_name,
customer_email, customer_email,
customer_phone, customer_phone,
password_hash: _ph, // Bound only to exclude the secrets from `...rest` — never read.
client_password_hash: _cph, // eslint-disable-next-line no-unused-vars -- rest-sibling omission
password_hash: _ph, client_password_hash: _cph,
...rest ...rest
} = event; } = event;
+3 -3
View File
@@ -130,7 +130,7 @@ router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.v
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream'); res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline'); res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline');
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); if (!isPdf) res.setHeader('Content-Security-Policy', 'default-src \'none\'; img-src \'self\' data:; style-src \'unsafe-inline\'');
createReadStream(safe).pipe(res); createReadStream(safe).pipe(res);
})); }));
@@ -149,7 +149,7 @@ router.get('/inbound/:id/page/:n', requireIncoming, requirePermission('accountin
res.setHeader('Content-Type', 'image/png'); res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Disposition', 'inline'); res.setHeader('Content-Disposition', 'inline');
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); res.setHeader('Content-Security-Policy', 'default-src \'none\'; img-src \'self\' data:; style-src \'unsafe-inline\'');
createReadStream(safePng).pipe(res); createReadStream(safePng).pipe(res);
})); }));
@@ -217,7 +217,7 @@ router.get('/:id/proof', requireExpenses, requirePermission('accounting.view'),
res.setHeader('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream'); res.setHeader('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream');
res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline'); res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline');
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); if (!isPdf) res.setHeader('Content-Security-Policy', 'default-src \'none\'; img-src \'self\' data:; style-src \'unsafe-inline\'');
createReadStream(safe).pipe(res); createReadStream(safe).pipe(res);
})); }));
+1 -1
View File
@@ -4,7 +4,7 @@ const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership'); const { requireEventOwnership } = require('../middleware/ownership');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); const { list, resolveExternalPath } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const sharp = require('sharp'); const sharp = require('sharp');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
-1
View File
@@ -15,7 +15,6 @@ const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { db } = require('../database/db');
const ledgerService = require('../services/ledgerService'); const ledgerService = require('../services/ledgerService');
const router = express.Router(); const router = express.Router();
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { db, logActivity } = require('../database/db'); const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
+55 -16
View File
@@ -17,7 +17,7 @@ const { COLOR_LABELS, dominantColorLabel, SHARED_COLOR_LABEL_IDENTITY } = requir
const feedbackService = require('../services/feedbackService'); const feedbackService = require('../services/feedbackService');
const photoAdminMarksService = require('../services/photoAdminMarksService'); const photoAdminMarksService = require('../services/photoAdminMarksService');
const { validateUploadedFiles } = require('../middleware/uploadValidation'); const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings'); const { getMaxFilesPerUpload, getAllowedMimeTypes, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../services/uploadSettings');
const { processUploadedPhotos } = require('../services/photoProcessor'); const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService'); const chunkedUpload = require('../services/chunkedUploadService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService');
@@ -37,7 +37,6 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
logger.info('Multer destination called for file:', file.originalname); logger.info('Multer destination called for file:', file.originalname);
const { eventId } = req.params;
// We'll validate the event exists in the route handler // We'll validate the event exists in the route handler
// For now, just create a temp destination // For now, just create a temp destination
@@ -67,10 +66,16 @@ const { validateFileType, createFileUploadValidator } = require('../utils/fileSe
// The allowed types are fetched from the database once per request (before multer // The allowed types are fetched from the database once per request (before multer
// processes files) and attached to req.allowedMimeTypes so that the fileFilter // processes files) and attached to req.allowedMimeTypes so that the fileFilter
// callback can read them synchronously. // callback can read them synchronously.
const upload = multer({ //
// The per-file size cap is resolved per request too (general_max_file_size_mb),
// so the uploader has to be built per request like the transfer routes do. It
// was hardcoded to 10GB here, which meant the advertised "max. 50MB per file"
// in the dropzone was never enforced anywhere server-side. getMaxFileSizeBytes()
// clamps to MAX_ALLOWED_FILE_SIZE_MB (10GB), so that hard ceiling still applies.
const createUpload = (maxFileSizeBytes) => multer({
storage: storage, storage: storage,
limits: { limits: {
fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos fileSize: maxFileSizeBytes,
files: 2000, // Hard safety ceiling; actual limit enforced dynamically files: 2000, // Hard safety ceiling; actual limit enforced dynamically
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
parts: 10000, parts: 10000,
@@ -105,7 +110,9 @@ const validateUploadContent = async (req, res, next) => {
const allowedTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp']; const allowedTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
const validator = createFileUploadValidator({ const validator = createFileUploadValidator({
allowedTypes, allowedTypes,
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos // Same per-request cap multer streamed against, so the two layers can't
// disagree; this one names the offending file in the 400.
maxFileSize: req.maxFileSizeBytes || DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024,
validateContent: true validateContent: true
}); });
return validator(req, res, next); return validator(req, res, next);
@@ -132,21 +139,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
}; };
// Upload photos for an event // Upload photos for an event
// Max file count is configurable via general settings // Max file count and max file size are configurable via general settings
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), requireEventOwnership, uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), requireEventOwnership, uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
let maxFilesPerUpload; let maxFilesPerUpload;
let maxFileSizeBytes;
try { try {
maxFilesPerUpload = await getMaxFilesPerUpload(); maxFilesPerUpload = await getMaxFilesPerUpload();
maxFileSizeBytes = await getMaxFileSizeBytes();
} catch (error) { } catch (error) {
return errorResponse(res, error, 500, 'Unable to determine upload limits'); return errorResponse(res, error, 500, 'Unable to determine upload limits');
} }
req.maxFileSizeBytes = maxFileSizeBytes;
const maxFileSizeMb = Math.floor(maxFileSizeBytes / (1024 * 1024));
upload.array('photos', maxFilesPerUpload)(req, res, (err) => { createUpload(maxFileSizeBytes).array('photos', maxFilesPerUpload)(req, res, (err) => {
if (err) { if (err) {
logger.error('Multer error:', err); logger.error('Multer error:', err);
if (err instanceof multer.MulterError) { if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 10GB per file.' }); return res.status(400).json({ error: `File too large. Maximum size is ${maxFileSizeMb} MB per file.` });
} }
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') { if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` }); return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
@@ -231,8 +242,11 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
} }
// Parse category_id to number if provided (handle string values like 'individual', 'collage') // Parse category_id to number if provided (handle string values like 'individual', 'collage')
// Same 0-is-not-a-category rule as the PATCH route below: '0' is truthy, so
// it parsed to 0 and the scope-validation guard (`if (parsedCategoryId && ...)`)
// then skipped on the falsy 0 and let it into the insert unvalidated.
const rawParsed = category_id ? parseInt(category_id, 10) : NaN; const rawParsed = category_id ? parseInt(category_id, 10) : NaN;
const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null; const parsedCategoryId = rawParsed > 0 ? rawParsed : null;
// Determine photo type and category name // Determine photo type and category name
let photoType = 'individual'; // default let photoType = 'individual'; // default
@@ -834,9 +848,16 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
// Explicitly clear category // Explicitly clear category
updateData.category_id = null; updateData.category_id = null;
} else { } else {
// Handle numeric category IDs from photo_categories table // Handle numeric category IDs from photo_categories table.
// 0 and negatives mean "no category", not category zero: photo_categories.id
// is an increments() column so it starts at 1, and a <select> whose "none"
// option carries value="0" is exactly how '0' reaches this route. Storing 0
// left the photo in a black hole — the grid's category filters never match
// it, and the "uncategorized" filter is whereNull() so it misses it too,
// while the list mapper renders it as uncategorized because 0 is falsy.
// NaN (unparseable input) already fell through to null and still does.
const numericCategoryId = parseInt(category_id, 10); const numericCategoryId = parseInt(category_id, 10);
if (!isNaN(numericCategoryId)) { if (numericCategoryId > 0) {
updateData.category_id = numericCategoryId; updateData.category_id = numericCategoryId;
} else { } else {
updateData.category_id = null; updateData.category_id = null;
@@ -1019,8 +1040,9 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
updateData.category_id = null; updateData.category_id = null;
} else { } else {
// Handle numeric category IDs from photo_categories table // Handle numeric category IDs from photo_categories table
// (0/negative mean "no category" — see the PATCH route above)
const numericCategoryId = parseInt(updates.category_id, 10); const numericCategoryId = parseInt(updates.category_id, 10);
if (!isNaN(numericCategoryId)) { if (numericCategoryId > 0) {
updateData.category_id = numericCategoryId; updateData.category_id = numericCategoryId;
} else { } else {
updateData.category_id = null; updateData.category_id = null;
@@ -1582,10 +1604,18 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
return res.status(400).json({ error: 'Missing required fields: filename, fileSize, mimeType' }); return res.status(400).json({ error: 'Missing required fields: filename, fileSize, mimeType' });
} }
// Validate file size (max 10GB) // Validate file size against the configured per-file cap. Hardcoding 10GB
const maxSize = 10 * 1024 * 1024 * 1024; // here let the chunked path sidestep general_max_file_size_mb entirely.
let maxSize;
try {
maxSize = await getMaxFileSizeBytes();
} catch {
maxSize = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024;
}
if (fileSize > maxSize) { 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 ${Math.floor(maxSize / (1024 * 1024))} MB per file.`
});
} }
const result = await chunkedUpload.initializeUpload({ const result = await chunkedUpload.initializeUpload({
@@ -1593,7 +1623,10 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
fileSize, fileSize,
mimeType, mimeType,
eventId: parseInt(eventId), eventId: parseInt(eventId),
totalChunks totalChunks,
// The declared fileSize check above is client-controlled; the service
// enforces this cap on the bytes it actually receives and merges.
maxFileSizeBytes: maxSize
}); });
res.json(result); res.json(result);
@@ -1618,6 +1651,9 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
res.json(result); res.json(result);
} catch (error) { } catch (error) {
if (error.statusCode === 413 || error.statusCode === 400) {
return res.status(error.statusCode).json({ error: error.message });
}
logger.error('Error uploading chunk:', error); logger.error('Error uploading chunk:', error);
res.status(500).json({ error: error.message || 'Failed to upload chunk' }); res.status(500).json({ error: error.message || 'Failed to upload chunk' });
} }
@@ -1660,6 +1696,9 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
photos: uploadedPhotos photos: uploadedPhotos
}); });
} catch (error) { } catch (error) {
if (error.statusCode === 413) {
return res.status(413).json({ error: error.message });
}
logger.error('Error completing chunked upload:', error); logger.error('Error completing chunked upload:', error);
res.status(500).json({ error: error.message || 'Failed to complete upload' }); res.status(500).json({ error: error.message || 'Failed to complete upload' });
} }
+1 -1
View File
@@ -3,7 +3,7 @@ const router = express.Router();
const { restoreService } = require('../services/restoreService'); const { restoreService } = require('../services/restoreService');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { body, query, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers'); const { getPagination } = require('../utils/routeHelpers');
const { db } = require('../database/db'); const { db } = require('../database/db');
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { db, withRetry } = require('../database/db'); const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const fs = require('fs').promises; const fs = require('fs').promises;
-1
View File
@@ -16,7 +16,6 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { body, param, validationResult } = require('express-validator'); const { body, param, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getBcryptRounds } = require('../utils/passwordValidation'); const { getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { errorResponse } = require('../utils/routeHelpers'); const { errorResponse } = require('../utils/routeHelpers');
-1
View File
@@ -17,7 +17,6 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { body, param, validationResult } = require('express-validator'); const { body, param, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha'); const { verifyRecaptcha } = require('../services/recaptcha');
const { const {
trackFailedAttempt, trackFailedAttempt,
+15 -1
View File
@@ -14,7 +14,6 @@ const {
checkValidation, checkValidation,
validateGuestRequirements validateGuestRequirements
} = require('../utils/feedbackValidation'); } = require('../utils/feedbackValidation');
const { escapeLikePattern } = require('../utils/sqlSecurity');
// Get feedback settings for a gallery // Get feedback settings for a gallery
router.get('/:slug/feedback-settings', router.get('/:slug/feedback-settings',
@@ -296,6 +295,21 @@ router.post('/:slug/photos/:photoId/feedback',
// Moderate the comment // Moderate the comment
const moderationResult = await feedbackModeration.moderateText(req.body.comment_text); const moderationResult = await feedbackModeration.moderateText(req.body.comment_text);
if (moderationResult.blocked) {
// `block` severity means rejected outright — never stored, not even
// as a pending row for a moderator to see. Anything else that isn't
// approved falls through to the held-for-moderation branch below.
logger.warn('Comment rejected by word filter:', {
eventId: event.id,
reason: moderationResult.reason,
violations: moderationResult.violations
});
return res.status(400).json({
error: 'Your comment contains words that are not allowed here.',
code: 'COMMENT_BLOCKED'
});
}
if (!moderationResult.approved) { if (!moderationResult.approved) {
// Still save but mark as not approved // Still save but mark as not approved
feedbackData.is_approved = false; feedbackData.is_approved = false;
+1
View File
@@ -35,6 +35,7 @@ function sanitizeName(value) {
// Strip HTML/control chars, collapse whitespace. // Strip HTML/control chars, collapse whitespace.
const cleaned = value const cleaned = value
.replace(/[<>&"']/g, '') .replace(/[<>&"']/g, '')
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from guest input
.replace(/[\u0000-\u001F\u007F]/g, '') .replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
+1 -1
View File
@@ -70,7 +70,7 @@ function verifyImageToken(token) {
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery, async (req, res) => { router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
try { try {
const { photoId } = req.params; const { photoId } = req.params;
const { protectionLevel = 'standard', token } = req.query; const { protectionLevel = 'standard' } = req.query;
// Create client fingerprint // Create client fingerprint
const clientFingerprint = secureImageService.createClientFingerprint(req); const clientFingerprint = secureImageService.createClientFingerprint(req);
+7
View File
@@ -74,6 +74,13 @@ function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosTe
unitPriceMinor: li.unit_price_minor, unitPriceMinor: li.unit_price_minor,
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent), discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
lineTotalMinor: li.line_total_minor, lineTotalMinor: li.line_total_minor,
// Hierarchy + details (migration 119), same shape adminQuotes.js
// projects. Omitting them here meant the customer-facing page could
// never thread sub-items or show details text, even though the data
// is on the rows getQuoteById already returns.
parentLineItemId: li.parent_line_item_id || null,
parentPosition: li.parent_position == null ? null : Number(li.parent_position),
detailsText: li.details_text || null,
})), })),
recipient: customer ? { recipient: customer ? {
displayName: customer.display_name || [customer.first_name, customer.last_name].filter(Boolean).join(' '), displayName: customer.display_name || [customer.first_name, customer.last_name].filter(Boolean).join(' '),
+10 -10
View File
@@ -16,32 +16,32 @@ const router = express.Router();
const { actByToken, peekApproval } = require('../services/workflows'); const { actByToken, peekApproval } = require('../services/workflows');
function page(title, body) { function page(title, body) {
return `<!doctype html><html><head><meta charset="utf-8">` return '<!doctype html><html><head><meta charset="utf-8">'
+ `<meta name="viewport" content="width=device-width, initial-scale=1">` + '<meta name="viewport" content="width=device-width, initial-scale=1">'
+ `<title>${title}</title></head>` + `<title>${title}</title></head>`
+ `<body style="font-family:system-ui,sans-serif;max-width:480px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2937">` + '<body style="font-family:system-ui,sans-serif;max-width:480px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2937">'
+ `<h2 style="font-weight:600">${title}</h2><p style="color:#4b5563;line-height:1.6">${body}</p></body></html>`; + `<h2 style="font-weight:600">${title}</h2><p style="color:#4b5563;line-height:1.6">${body}</p></body></html>`;
} }
// Escape any prompt text we echo into the interstitial HTML. // Escape any prompt text we echo into the interstitial HTML.
function esc(s) { function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' }[c]
)); ));
} }
function decisionPage(token, emphasis, prompt) { function decisionPage(token, emphasis, prompt) {
const btn = (href, label, primary) => `<form method="POST" action="${href}" style="display:inline">` const btn = (href, label, primary) => `<form method="POST" action="${href}" style="display:inline">`
+ `<button type="submit" style="cursor:pointer;margin:6px;padding:12px 20px;border-radius:8px;border:1px solid #d1d5db;` + '<button type="submit" style="cursor:pointer;margin:6px;padding:12px 20px;border-radius:8px;border:1px solid #d1d5db;'
+ `font-size:15px;font-weight:600;${primary + `font-size:15px;font-weight:600;${primary
? 'background:#1d9e75;color:#fff;border-color:#1d9e75' ? 'background:#1d9e75;color:#fff;border-color:#1d9e75'
: 'background:#fff;color:#374151'}">${label}</button></form>`; : 'background:#fff;color:#374151'}">${label}</button></form>`;
const body = (prompt ? `<span style="display:block;margin-bottom:16px">${esc(prompt)}</span>` : '') const body = (prompt ? `<span style="display:block;margin-bottom:16px">${esc(prompt)}</span>` : '')
+ `<div>` + '<div>'
+ btn(`confirm`, 'Confirm payment received', emphasis === 'confirm') + btn('confirm', 'Confirm payment received', emphasis === 'confirm')
+ btn(`deny`, 'No payment received', emphasis === 'deny') + btn('deny', 'No payment received', emphasis === 'deny')
+ `</div>` + '</div>'
+ `<p style="color:#9ca3af;font-size:13px;margin-top:20px">Choosing is a single, final action.</p>`; + '<p style="color:#9ca3af;font-size:13px;margin-top:20px">Choosing is a single, final action.</p>';
return page('Confirm your response', body); return page('Confirm your response', body);
} }
@@ -93,6 +93,14 @@ jest.mock('multer', () => {
return factory; return factory;
}); });
// The upload middleware resolves the per-file size cap from app_settings on
// every request (general_max_file_size_mb). That read would consume one of
// this suite's sequenced db chains and shift every later assertion, so stub it.
jest.mock('../../../services/uploadSettings', () => ({
getMaxFileSizeBytes: jest.fn().mockResolvedValue(50 * 1024 * 1024),
DEFAULT_MAX_FILE_SIZE_MB: 50,
}));
// Stub sharp so the happy-path test doesn't actually decode an image // Stub sharp so the happy-path test doesn't actually decode an image
// (the temp file is a 0-byte placeholder — see the beforeAll below). // (the temp file is a 0-byte placeholder — see the beforeAll below).
jest.mock('sharp', () => jest.fn(() => ({ jest.mock('sharp', () => jest.fn(() => ({
+24 -3
View File
@@ -38,6 +38,7 @@ const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers'); const { parseBooleanInput } = require('../../utils/parsers');
const { isValidEventType } = require('../../services/eventTypeService'); const { isValidEventType } = require('../../services/eventTypeService');
const { replacePhoto } = require('../../services/photoReplacementService'); const { replacePhoto } = require('../../services/photoReplacementService');
const { getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
const downloadZipService = require('../../services/downloadZipService'); const downloadZipService = require('../../services/downloadZipService');
const { PhotoFilterBuilder } = require('../../utils/photoFilterBuilder'); const { PhotoFilterBuilder } = require('../../utils/photoFilterBuilder');
const { PhotoExportService } = require('../../services/photoExportService'); const { PhotoExportService } = require('../../services/photoExportService');
@@ -65,14 +66,34 @@ const photoStorage = multer.diskStorage({
cb(null, `v1_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`); cb(null, `v1_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`);
} }
}); });
const photoUpload = multer({ const buildPhotoUpload = (maxFileSizeBytes) => multer({
storage: photoStorage, storage: photoStorage,
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB per file for v1 limits: { fileSize: maxFileSizeBytes },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
if (/^image\//.test(file.mimetype)) cb(null, true); if (/^image\//.test(file.mimetype)) cb(null, true);
else cb(new Error('Only image uploads are accepted on this endpoint')); else cb(new Error('Only image uploads are accepted on this endpoint'));
} }
}).single('photo');
// The per-file cap was hardcoded to 100MB here, so general_max_file_size_mb
// (Settings → General) didn't apply to the v1 upload either. Resolve it per
// request — the admin can change it at runtime — and turn multer's generic
// "File too large" into a 400 that names the configured limit.
const photoUpload = async (req, res, next) => {
let maxFileSizeBytes;
try {
maxFileSizeBytes = await getMaxFileSizeBytes();
} catch {
maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024;
}
buildPhotoUpload(maxFileSizeBytes)(req, res, (err) => {
if (err && err.code === 'LIMIT_FILE_SIZE') {
const limitMb = Math.floor(maxFileSizeBytes / (1024 * 1024));
return res.status(400).json({ error: `File too large. Maximum size is ${limitMb} MB per file.` });
}
next(err);
}); });
};
// slugify now imported from ../../utils/slug — shared with adminEvents // slugify now imported from ../../utils/slug — shared with adminEvents
// and events.js so the diacritic fix from #502 lands here too (#525). // and events.js so the diacritic fix from #502 lands here too (#525).
@@ -616,7 +637,7 @@ router.post(
requireApiScope('write'), requireApiScope('write'),
requirePermission('photos.upload'), requirePermission('photos.upload'),
requireEventOwnership, requireEventOwnership,
photoUpload.single('photo'), photoUpload,
async (req, res) => { async (req, res) => {
let tempPath = null; let tempPath = null;
try { try {
@@ -1,7 +1,6 @@
const { DatabaseBackupService } = require('../databaseBackup'); const { DatabaseBackupService } = require('../databaseBackup');
const { db } = require('../../database/db'); const { db } = require('../../database/db');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
// Mock dependencies // Mock dependencies
@@ -12,11 +11,9 @@ jest.mock('child_process');
describe('DatabaseBackupService', () => { describe('DatabaseBackupService', () => {
let service; let service;
let mockExecAsync;
beforeEach(() => { beforeEach(() => {
service = new DatabaseBackupService(); service = new DatabaseBackupService();
mockExecAsync = jest.fn();
// Reset mocks // Reset mocks
jest.clearAllMocks(); jest.clearAllMocks();
@@ -167,6 +167,7 @@ async function tryInstallFromBackup(db, logger) {
// console.log as well so the docker-logs surface tells the story // console.log as well so the docker-logs surface tells the story
// without needing to exec into the container. // without needing to exec into the container.
const announce = (msg) => { const announce = (msg) => {
// eslint-disable-next-line no-console -- deliberate: mirrors boot progress to docker logs
try { console.log(`[install-from-backup] ${msg}`); } catch (_) { /* defensive */ } try { console.log(`[install-from-backup] ${msg}`); } catch (_) { /* defensive */ }
}; };
@@ -335,6 +335,10 @@ const BUILTINS = [
}, },
]; ];
// NOTE: written at the end of seedBuiltinWorkflowsAtBoot but never read — the
// intended "seed only once per process" guard is missing its `if (booted) return;`
// check. Left in place so the gap stays visible rather than being silently dropped.
// eslint-disable-next-line no-unused-vars -- write-only boot guard, see note above
let booted = false; let booted = false;
function parseSeedConfig(raw) { function parseSeedConfig(raw) {
+4 -6
View File
@@ -4,7 +4,6 @@ const fsSync = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const childProcess = require('child_process'); const childProcess = require('child_process');
const os = require('os'); const os = require('os');
const { promisify } = require('util');
const cron = require('node-cron'); const cron = require('node-cron');
const cronParser = require('cron-parser'); const cronParser = require('cron-parser');
@@ -69,7 +68,6 @@ function ensureMockableExec() {
ensureMockableExec(); ensureMockableExec();
const getExecAsync = () => promisify(childProcess.exec);
async function resolveConfigWithFallback() { async function resolveConfigWithFallback() {
let config; let config;
@@ -763,7 +761,7 @@ async function performLocalBackup(config, files) {
function validateRsyncParam(value, label) { function validateRsyncParam(value, label) {
if (!value || typeof value !== 'string') return null; if (!value || typeof value !== 'string') return null;
if (!/^[a-zA-Z0-9._\/@:-]+$/.test(value)) { if (!/^[a-zA-Z0-9._/@:-]+$/.test(value)) {
throw new Error(`Invalid ${label}: contains disallowed characters`); throw new Error(`Invalid ${label}: contains disallowed characters`);
} }
if (value.length > 1024) { if (value.length > 1024) {
@@ -1532,7 +1530,7 @@ async function loadManifestFromAnywhere(manifestPath, config) {
throw new Error('S3 credentials not configured for manifest retrieval'); throw new Error('S3 credentials not configured for manifest retrieval');
} }
const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/); const match = manifestPath.match(/^s3:\/\/([^/]+)\/(.+)$/);
if (!match) { if (!match) {
throw new Error('Invalid S3 manifest path'); throw new Error('Invalid S3 manifest path');
} }
@@ -1601,7 +1599,7 @@ async function getBackupManifest(backupRunId) {
throw new Error('S3 credentials not configured for manifest retrieval'); throw new Error('S3 credentials not configured for manifest retrieval');
} }
const match = run.manifest_path.match(/^s3:\/\/([^\/]+)\/(.+)$/); const match = run.manifest_path.match(/^s3:\/\/([^/]+)\/(.+)$/);
if (!match) { if (!match) {
throw new Error('Invalid S3 manifest path'); throw new Error('Invalid S3 manifest path');
} }
@@ -1640,7 +1638,7 @@ async function validateBackupManifest(manifestPath) {
let manifest; let manifest;
if (manifestPath.startsWith('s3://')) { if (manifestPath.startsWith('s3://')) {
const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/); const match = manifestPath.match(/^s3:\/\/([^/]+)\/(.+)$/);
if (!match) { if (!match) {
throw new Error('Invalid S3 manifest path'); throw new Error('Invalid S3 manifest path');
} }
+58 -1
View File
@@ -16,6 +16,27 @@ const CHUNK_SIZE = 10 * 1024 * 1024;
// Upload expiration: 24 hours // Upload expiration: 24 hours
const UPLOAD_EXPIRATION_MS = 24 * 60 * 60 * 1000; const UPLOAD_EXPIRATION_MS = 24 * 60 * 60 * 1000;
function totalReceivedBytes(uploadMeta) {
let total = 0;
for (const size of uploadMeta.chunkSizes.values()) total += size;
return total;
}
// Tagged errors so the routes can answer 413/400 instead of a blanket 500.
function fileTooLargeError(maxFileSizeBytes) {
const err = new Error(`File too large. Maximum size is ${Math.floor(maxFileSizeBytes / (1024 * 1024))} MB per file.`);
err.code = 'FILE_TOO_LARGE';
err.statusCode = 413;
return err;
}
function invalidChunkError(message) {
const err = new Error(message);
err.code = 'INVALID_CHUNK';
err.statusCode = 400;
return err;
}
/** /**
* Initialize a new chunked upload * Initialize a new chunked upload
* @param {Object} options - Upload options * @param {Object} options - Upload options
@@ -27,7 +48,8 @@ async function initializeUpload(options) {
fileSize, fileSize,
mimeType, mimeType,
eventId, eventId,
totalChunks totalChunks,
maxFileSizeBytes
} = options; } = options;
// Strip any directory components from the client-supplied filename. It is // Strip any directory components from the client-supplied filename. It is
@@ -50,6 +72,13 @@ async function initializeUpload(options) {
// Calculate expected chunks // Calculate expected chunks
const expectedChunks = totalChunks || Math.ceil(fileSize / CHUNK_SIZE); const expectedChunks = totalChunks || Math.ceil(fileSize / CHUNK_SIZE);
// The per-file cap is enforced on the BYTES ACTUALLY RECEIVED, not on the
// client-declared fileSize the init route checks: a client can declare
// `fileSize: 1` and then stream whatever it likes through the chunk route.
// Missing/invalid cap means "no cap" (callers outside the admin routes).
const cap = Number(maxFileSizeBytes);
const sizeCap = Number.isFinite(cap) && cap > 0 ? cap : Infinity;
// Store upload metadata // Store upload metadata
const uploadMeta = { const uploadMeta = {
uploadId, uploadId,
@@ -59,6 +88,9 @@ async function initializeUpload(options) {
eventId, eventId,
expectedChunks, expectedChunks,
receivedChunks: new Set(), receivedChunks: new Set(),
// Bytes per chunk index, so a re-sent chunk replaces rather than adds.
chunkSizes: new Map(),
maxFileSizeBytes: sizeCap,
uploadDir, uploadDir,
createdAt: Date.now(), createdAt: Date.now(),
expiresAt: Date.now() + UPLOAD_EXPIRATION_MS, expiresAt: Date.now() + UPLOAD_EXPIRATION_MS,
@@ -107,12 +139,28 @@ async function uploadChunk(uploadId, chunkIndex, chunkData) {
throw new Error('Upload expired'); throw new Error('Upload expired');
} }
// Only the announced chunk indices are valid — anything else would merge
// into nothing (a gap) or let more chunks in than the declared file has.
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= uploadMeta.expectedChunks) {
throw invalidChunkError(`Invalid chunk index ${chunkIndex}: expected 0-${uploadMeta.expectedChunks - 1}`);
}
// Enforce the per-file cap on the running byte total. The upload is
// aborted, not just rejected: the chunks on disk are already over the
// limit and the client can't complete the file any more.
const receivedBytes = totalReceivedBytes(uploadMeta) - (uploadMeta.chunkSizes.get(chunkIndex) || 0) + chunkData.length;
if (receivedBytes > uploadMeta.maxFileSizeBytes) {
await abortUpload(uploadId);
throw fileTooLargeError(uploadMeta.maxFileSizeBytes);
}
// Write chunk to disk // Write chunk to disk
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(chunkIndex).padStart(6, '0')}`); const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
await fs.writeFile(chunkPath, chunkData); await fs.writeFile(chunkPath, chunkData);
// Mark chunk as received // Mark chunk as received
uploadMeta.receivedChunks.add(chunkIndex); uploadMeta.receivedChunks.add(chunkIndex);
uploadMeta.chunkSizes.set(chunkIndex, chunkData.length);
const progress = (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100; const progress = (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100;
@@ -184,6 +232,15 @@ async function completeUpload(uploadId) {
}); });
} }
// Backstop for the per-chunk running total above: the merged file is
// the number that matters, so it is the number that is checked last.
if (stats.size > uploadMeta.maxFileSizeBytes) {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true }).catch(() => {});
activeUploads.delete(uploadId);
throw fileTooLargeError(uploadMeta.maxFileSizeBytes);
}
// Clean up chunks // Clean up chunks
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true }); await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
@@ -30,7 +30,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
<p>Or open the full contract:<br> <p>Or open the full contract:<br>
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p> <span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Please sign by {{valid_until}}.</p>{{/if}}`, {{#if valid_until}}<p style="font-size: 13px; color: #666;">Please sign by {{valid_until}}.</p>{{/if}}`,
body_text: `Contract {{contract_number}}\n\nDear {{customer_name}},\n\nPlease review and sign the contract {{contract_number}}.\n\nOpen: {{response_url}}\n\n{{#if valid_until}}Please sign by {{valid_until}}.{{/if}}`, body_text: 'Contract {{contract_number}}\n\nDear {{customer_name}},\n\nPlease review and sign the contract {{contract_number}}.\n\nOpen: {{response_url}}\n\n{{#if valid_until}}Please sign by {{valid_until}}.{{/if}}',
}, },
de: { de: {
subject: 'Vertrag {{contract_number}} zur Unterzeichnung bereit', subject: 'Vertrag {{contract_number}} zur Unterzeichnung bereit',
@@ -44,7 +44,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
<p>Oder öffnen Sie den vollständigen Vertrag im Browser:<br> <p>Oder öffnen Sie den vollständigen Vertrag im Browser:<br>
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p> <span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Bitte unterzeichnen Sie bis {{valid_until}}.</p>{{/if}}`, {{#if valid_until}}<p style="font-size: 13px; color: #666;">Bitte unterzeichnen Sie bis {{valid_until}}.</p>{{/if}}`,
body_text: `Vertrag {{contract_number}}\n\nSehr geehrte/r {{customer_name}},\n\nbitte prüfen und unterzeichnen Sie den Vertrag {{contract_number}}.\n\nÖffnen: {{response_url}}\n\n{{#if valid_until}}Bitte unterzeichnen bis {{valid_until}}.{{/if}}`, body_text: 'Vertrag {{contract_number}}\n\nSehr geehrte/r {{customer_name}},\n\nbitte prüfen und unterzeichnen Sie den Vertrag {{contract_number}}.\n\nÖffnen: {{response_url}}\n\n{{#if valid_until}}Bitte unterzeichnen bis {{valid_until}}.{{/if}}',
}, },
}, },
contract_fully_signed: { contract_fully_signed: {
@@ -56,7 +56,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
<p>Dear {{customer_name}},</p> <p>Dear {{customer_name}},</p>
<p>Both parties have now signed contract {{contract_number}}{{#if title}} "{{title}}"{{/if}}. Please find the fully signed PDF attached for your records.</p> <p>Both parties have now signed contract {{contract_number}}{{#if title}} "{{title}}"{{/if}}. Please find the fully signed PDF attached for your records.</p>
<p style="font-size: 13px; color: #666;">This is the authoritative signed copy. Keep it alongside the related quote and invoices.</p>`, <p style="font-size: 13px; color: #666;">This is the authoritative signed copy. Keep it alongside the related quote and invoices.</p>`,
body_text: `Contract {{contract_number}} is now fully signed by both parties. The signed PDF is attached for your records.`, body_text: 'Contract {{contract_number}} is now fully signed by both parties. The signed PDF is attached for your records.',
}, },
de: { de: {
subject: 'Vertrag {{contract_number}} vollständig unterzeichnet', subject: 'Vertrag {{contract_number}} vollständig unterzeichnet',
@@ -64,7 +64,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
<p>Sehr geehrte/r {{customer_name}},</p> <p>Sehr geehrte/r {{customer_name}},</p>
<p>der Vertrag {{contract_number}}{{#if title}} {{title}}"{{/if}} wurde nun von beiden Parteien unterzeichnet. Im Anhang finden Sie das beidseitig unterzeichnete PDF für Ihre Unterlagen.</p> <p>der Vertrag {{contract_number}}{{#if title}} {{title}}"{{/if}} wurde nun von beiden Parteien unterzeichnet. Im Anhang finden Sie das beidseitig unterzeichnete PDF für Ihre Unterlagen.</p>
<p style="font-size: 13px; color: #666;">Dies ist die massgebliche unterzeichnete Fassung. Bewahren Sie sie zusammen mit dem zugehörigen Angebot und den Rechnungen auf.</p>`, <p style="font-size: 13px; color: #666;">Dies ist die massgebliche unterzeichnete Fassung. Bewahren Sie sie zusammen mit dem zugehörigen Angebot und den Rechnungen auf.</p>`,
body_text: `Vertrag {{contract_number}} ist nun beidseitig unterzeichnet. Das unterzeichnete PDF finden Sie im Anhang.`, body_text: 'Vertrag {{contract_number}} ist nun beidseitig unterzeichnet. Das unterzeichnete PDF finden Sie im Anhang.',
}, },
}, },
contract_signed_admin_notification: { contract_signed_admin_notification: {
@@ -75,14 +75,14 @@ const CONTRACT_EMAIL_TEMPLATES = {
body_html: `<h2>Contract signed</h2><p>{{signed_customer_name}} ({{customer_email}}) has just signed contract <strong>{{contract_number}}</strong>.</p> body_html: `<h2>Contract signed</h2><p>{{signed_customer_name}} ({{customer_email}}) has just signed contract <strong>{{contract_number}}</strong>.</p>
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p> <p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p>
<p style="font-size: 13px; color: #666;">The signed PDF and signature evidence (typed name, IP, timestamp, signature image if drawn) are available on the contract detail page. To make this fully binding, counter-sign the contract or upload a wet-signed copy.</p>`, <p style="font-size: 13px; color: #666;">The signed PDF and signature evidence (typed name, IP, timestamp, signature image if drawn) are available on the contract detail page. To make this fully binding, counter-sign the contract or upload a wet-signed copy.</p>`,
body_text: `Contract {{contract_number}} signed by {{signed_customer_name}} ({{customer_email}}). Open: {{admin_dashboard_url}}`, body_text: 'Contract {{contract_number}} signed by {{signed_customer_name}} ({{customer_email}}). Open: {{admin_dashboard_url}}',
}, },
de: { de: {
subject: 'Vertrag {{contract_number}} von {{customer_email}} unterzeichnet', subject: 'Vertrag {{contract_number}} von {{customer_email}} unterzeichnet',
body_html: `<h2>Vertrag unterzeichnet</h2><p>{{signed_customer_name}} ({{customer_email}}) hat soeben den Vertrag <strong>{{contract_number}}</strong> unterzeichnet.</p> body_html: `<h2>Vertrag unterzeichnet</h2><p>{{signed_customer_name}} ({{customer_email}}) hat soeben den Vertrag <strong>{{contract_number}}</strong> unterzeichnet.</p>
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p> <p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p>
<p style="font-size: 13px; color: #666;">Das unterzeichnete PDF und die Signatur-Belege (Name, IP, Zeitstempel, Signaturbild falls gezeichnet) sind auf der Vertragsdetailseite einsehbar. Für vollständige Verbindlichkeit unterzeichnen Sie den Vertrag gegen oder laden Sie eine handunterschriebene Kopie hoch.</p>`, <p style="font-size: 13px; color: #666;">Das unterzeichnete PDF und die Signatur-Belege (Name, IP, Zeitstempel, Signaturbild falls gezeichnet) sind auf der Vertragsdetailseite einsehbar. Für vollständige Verbindlichkeit unterzeichnen Sie den Vertrag gegen oder laden Sie eine handunterschriebene Kopie hoch.</p>`,
body_text: `Vertrag {{contract_number}} von {{signed_customer_name}} ({{customer_email}}) unterzeichnet. Öffnen: {{admin_dashboard_url}}`, body_text: 'Vertrag {{contract_number}} von {{signed_customer_name}} ({{customer_email}}) unterzeichnet. Öffnen: {{admin_dashboard_url}}',
}, },
}, },
}; };
+20 -20
View File
@@ -40,7 +40,7 @@ quote_sent: {
<p>Or open the full quote in your browser:<br> <p>Or open the full quote in your browser:<br>
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p> <span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
{{#if valid_until}}<p style="font-size: 13px; color: #666;">This quote is valid until {{valid_until}}.</p>{{/if}}`, {{#if valid_until}}<p style="font-size: 13px; color: #666;">This quote is valid until {{valid_until}}.</p>{{/if}}`,
body_text: `Quote {{quote_number}}\n\nDear {{customer_name}},\n\nPlease find the attached quote {{quote_number}}. Total: {{total_amount}}.\n\nRespond: {{response_url}}\nAccept: {{accept_url}}\nDecline: {{decline_url}}\n\n{{#if valid_until}}Valid until {{valid_until}}.{{/if}}`, body_text: 'Quote {{quote_number}}\n\nDear {{customer_name}},\n\nPlease find the attached quote {{quote_number}}. Total: {{total_amount}}.\n\nRespond: {{response_url}}\nAccept: {{accept_url}}\nDecline: {{decline_url}}\n\n{{#if valid_until}}Valid until {{valid_until}}.{{/if}}',
}, },
de: { de: {
subject: 'Ihr Angebot {{quote_number}} ist bereit', subject: 'Ihr Angebot {{quote_number}} ist bereit',
@@ -56,7 +56,7 @@ quote_sent: {
<p>Oder öffnen Sie das vollständige Angebot im Browser:<br> <p>Oder öffnen Sie das vollständige Angebot im Browser:<br>
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p> <span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Dieses Angebot ist gültig bis {{valid_until}}.</p>{{/if}}`, {{#if valid_until}}<p style="font-size: 13px; color: #666;">Dieses Angebot ist gültig bis {{valid_until}}.</p>{{/if}}`,
body_text: `Angebot {{quote_number}}\n\nSehr geehrte/r {{customer_name}},\n\nim Anhang finden Sie das Angebot {{quote_number}}. Gesamtbetrag: {{total_amount}}.\n\nAnsehen: {{response_url}}\nAnnehmen: {{accept_url}}\nAblehnen: {{decline_url}}\n\n{{#if valid_until}}Gültig bis {{valid_until}}.{{/if}}`, body_text: 'Angebot {{quote_number}}\n\nSehr geehrte/r {{customer_name}},\n\nim Anhang finden Sie das Angebot {{quote_number}}. Gesamtbetrag: {{total_amount}}.\n\nAnsehen: {{response_url}}\nAnnehmen: {{accept_url}}\nAblehnen: {{decline_url}}\n\n{{#if valid_until}}Gültig bis {{valid_until}}.{{/if}}',
}, },
}, },
quote_accepted_admin: { quote_accepted_admin: {
@@ -66,13 +66,13 @@ quote_sent: {
subject: 'Quote {{quote_number}} accepted by {{customer_email}}', subject: 'Quote {{quote_number}} accepted by {{customer_email}}',
body_html: `<h2>Quote accepted</h2><p>{{customer_email}} just accepted quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}. Total: {{total_amount}}.</p> body_html: `<h2>Quote accepted</h2><p>{{customer_email}} just accepted quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}. Total: {{total_amount}}.</p>
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p>`, <p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p>`,
body_text: `Quote {{quote_number}} accepted by {{customer_email}}. Open: {{admin_dashboard_url}}`, body_text: 'Quote {{quote_number}} accepted by {{customer_email}}. Open: {{admin_dashboard_url}}',
}, },
de: { de: {
subject: 'Angebot {{quote_number}} von {{customer_email}} angenommen', subject: 'Angebot {{quote_number}} von {{customer_email}} angenommen',
body_html: `<h2>Angebot angenommen</h2><p>{{customer_email}} hat soeben das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} angenommen. Gesamtbetrag: {{total_amount}}.</p> body_html: `<h2>Angebot angenommen</h2><p>{{customer_email}} hat soeben das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} angenommen. Gesamtbetrag: {{total_amount}}.</p>
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p>`, <p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p>`,
body_text: `Angebot {{quote_number}} von {{customer_email}} angenommen. Öffnen: {{admin_dashboard_url}}`, body_text: 'Angebot {{quote_number}} von {{customer_email}} angenommen. Öffnen: {{admin_dashboard_url}}',
}, },
}, },
quote_declined_admin: { quote_declined_admin: {
@@ -82,13 +82,13 @@ quote_sent: {
subject: 'Quote {{quote_number}} declined by {{customer_email}}', subject: 'Quote {{quote_number}} declined by {{customer_email}}',
body_html: `<p>{{customer_email}} declined quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}.</p> body_html: `<p>{{customer_email}} declined quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}.</p>
<p><a href="{{admin_dashboard_url}}">Open quote in admin</a></p>`, <p><a href="{{admin_dashboard_url}}">Open quote in admin</a></p>`,
body_text: `Quote {{quote_number}} declined by {{customer_email}}. Open: {{admin_dashboard_url}}`, body_text: 'Quote {{quote_number}} declined by {{customer_email}}. Open: {{admin_dashboard_url}}',
}, },
de: { de: {
subject: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt', subject: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt',
body_html: `<p>{{customer_email}} hat das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} abgelehnt.</p> body_html: `<p>{{customer_email}} hat das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} abgelehnt.</p>
<p><a href="{{admin_dashboard_url}}">Angebot im Admin-Bereich öffnen</a></p>`, <p><a href="{{admin_dashboard_url}}">Angebot im Admin-Bereich öffnen</a></p>`,
body_text: `Angebot {{quote_number}} von {{customer_email}} abgelehnt. Öffnen: {{admin_dashboard_url}}`, body_text: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt. Öffnen: {{admin_dashboard_url}}',
}, },
}, },
invoice_sent: { invoice_sent: {
@@ -101,7 +101,7 @@ quote_sent: {
<p>Please find the attached invoice {{invoice_number}}{{#if event_name}} for "{{event_name}}"{{/if}}.</p> <p>Please find the attached invoice {{invoice_number}}{{#if event_name}} for "{{event_name}}"{{/if}}.</p>
<p><strong>Amount:</strong> {{total_amount}}<br><strong>Due:</strong> {{due_date}}{{#if installment_label}}<br><strong>Installment:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p> <p><strong>Amount:</strong> {{total_amount}}<br><strong>Due:</strong> {{due_date}}{{#if installment_label}}<br><strong>Installment:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p>
<p>The payment details and IBAN are on the attached PDF.</p>`, <p>The payment details and IBAN are on the attached PDF.</p>`,
body_text: `Invoice {{invoice_number}}: {{total_amount}}, due {{due_date}}.`, body_text: 'Invoice {{invoice_number}}: {{total_amount}}, due {{due_date}}.',
}, },
de: { de: {
subject: 'Rechnung {{invoice_number}} — {{total_amount}}', subject: 'Rechnung {{invoice_number}} — {{total_amount}}',
@@ -109,7 +109,7 @@ quote_sent: {
<p>im Anhang finden Sie die Rechnung {{invoice_number}}{{#if event_name}} für "{{event_name}}"{{/if}}.</p> <p>im Anhang finden Sie die Rechnung {{invoice_number}}{{#if event_name}} für "{{event_name}}"{{/if}}.</p>
<p><strong>Betrag:</strong> {{total_amount}}<br><strong>Fällig:</strong> {{due_date}}{{#if installment_label}}<br><strong>Teilzahlung:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p> <p><strong>Betrag:</strong> {{total_amount}}<br><strong>Fällig:</strong> {{due_date}}{{#if installment_label}}<br><strong>Teilzahlung:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p>
<p>Die Zahlungsdetails und IBAN finden Sie auf dem beigefügten PDF.</p>`, <p>Die Zahlungsdetails und IBAN finden Sie auf dem beigefügten PDF.</p>`,
body_text: `Rechnung {{invoice_number}}: {{total_amount}}, fällig {{due_date}}.`, body_text: 'Rechnung {{invoice_number}}: {{total_amount}}, fällig {{due_date}}.',
}, },
}, },
invoice_reminder_first: { invoice_reminder_first: {
@@ -120,14 +120,14 @@ quote_sent: {
body_html: `<h2>Payment reminder</h2><p>Dear {{customer_name}},</p> body_html: `<h2>Payment reminder</h2><p>Dear {{customer_name}},</p>
<p>Our records show that invoice <strong>{{invoice_number}}</strong> (originally due {{due_date}}) is now {{days_overdue}} days overdue. The outstanding amount is <strong>{{total_amount}}</strong>.</p> <p>Our records show that invoice <strong>{{invoice_number}}</strong> (originally due {{due_date}}) is now {{days_overdue}} days overdue. The outstanding amount is <strong>{{total_amount}}</strong>.</p>
<p>If you have already paid, please ignore this reminder. Otherwise, please find a fresh copy attached.</p>`, <p>If you have already paid, please ignore this reminder. Otherwise, please find a fresh copy attached.</p>`,
body_text: `Invoice {{invoice_number}} is {{days_overdue}} days overdue. Outstanding: {{total_amount}}.`, body_text: 'Invoice {{invoice_number}} is {{days_overdue}} days overdue. Outstanding: {{total_amount}}.',
}, },
de: { de: {
subject: 'Zahlungserinnerung: Rechnung {{invoice_number}}', subject: 'Zahlungserinnerung: Rechnung {{invoice_number}}',
body_html: `<h2>Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p> body_html: `<h2>Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p>
<p>laut unseren Unterlagen ist die Rechnung <strong>{{invoice_number}}</strong> (ursprünglich fällig am {{due_date}}) seit {{days_overdue}} Tagen überfällig. Der offene Betrag beträgt <strong>{{total_amount}}</strong>.</p> <p>laut unseren Unterlagen ist die Rechnung <strong>{{invoice_number}}</strong> (ursprünglich fällig am {{due_date}}) seit {{days_overdue}} Tagen überfällig. Der offene Betrag beträgt <strong>{{total_amount}}</strong>.</p>
<p>Sollten Sie die Zahlung bereits veranlasst haben, betrachten Sie diese Erinnerung als gegenstandslos. Im Anhang finden Sie eine aktuelle Kopie der Rechnung.</p>`, <p>Sollten Sie die Zahlung bereits veranlasst haben, betrachten Sie diese Erinnerung als gegenstandslos. Im Anhang finden Sie eine aktuelle Kopie der Rechnung.</p>`,
body_text: `Rechnung {{invoice_number}} ist seit {{days_overdue}} Tagen überfällig. Offen: {{total_amount}}.`, body_text: 'Rechnung {{invoice_number}} ist seit {{days_overdue}} Tagen überfällig. Offen: {{total_amount}}.',
}, },
}, },
invoice_reminder_second: { invoice_reminder_second: {
@@ -139,14 +139,14 @@ quote_sent: {
body_html: `<h2>Second payment reminder</h2><p>Dear {{customer_name}},</p> body_html: `<h2>Second payment reminder</h2><p>Dear {{customer_name}},</p>
<p>Invoice <strong>{{invoice_number}}</strong> is now {{days_overdue}} days overdue. As advised in our payment terms, a late fee of <strong>{{late_fee_amount}}</strong> has been added. The new total is <strong>{{new_total_amount}}</strong>.</p> <p>Invoice <strong>{{invoice_number}}</strong> is now {{days_overdue}} days overdue. As advised in our payment terms, a late fee of <strong>{{late_fee_amount}}</strong> has been added. The new total is <strong>{{new_total_amount}}</strong>.</p>
<p>Please settle the outstanding amount as soon as possible. A revised invoice is attached.</p>`, <p>Please settle the outstanding amount as soon as possible. A revised invoice is attached.</p>`,
body_text: `Second reminder for {{invoice_number}}. Late fee {{late_fee_amount}} added. New total: {{new_total_amount}}.`, body_text: 'Second reminder for {{invoice_number}}. Late fee {{late_fee_amount}} added. New total: {{new_total_amount}}.',
}, },
de: { de: {
subject: 'Zweite Mahnung: Rechnung {{invoice_number}}', subject: 'Zweite Mahnung: Rechnung {{invoice_number}}',
body_html: `<h2>Zweite Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p> body_html: `<h2>Zweite Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p>
<p>die Rechnung <strong>{{invoice_number}}</strong> ist nun seit {{days_overdue}} Tagen überfällig. Gemäss unseren Zahlungsbedingungen wurde eine Mahngebühr von <strong>{{late_fee_amount}}</strong> hinzugefügt. Der neue Gesamtbetrag beträgt <strong>{{new_total_amount}}</strong>.</p> <p>die Rechnung <strong>{{invoice_number}}</strong> ist nun seit {{days_overdue}} Tagen überfällig. Gemäss unseren Zahlungsbedingungen wurde eine Mahngebühr von <strong>{{late_fee_amount}}</strong> hinzugefügt. Der neue Gesamtbetrag beträgt <strong>{{new_total_amount}}</strong>.</p>
<p>Wir bitten Sie, den offenen Betrag umgehend zu begleichen. Eine aktualisierte Rechnung finden Sie im Anhang.</p>`, <p>Wir bitten Sie, den offenen Betrag umgehend zu begleichen. Eine aktualisierte Rechnung finden Sie im Anhang.</p>`,
body_text: `Zweite Mahnung für {{invoice_number}}. Mahngebühr {{late_fee_amount}} hinzugefügt. Neuer Gesamtbetrag: {{new_total_amount}}.`, body_text: 'Zweite Mahnung für {{invoice_number}}. Mahngebühr {{late_fee_amount}} hinzugefügt. Neuer Gesamtbetrag: {{new_total_amount}}.',
}, },
}, },
invoice_paid_receipt: { invoice_paid_receipt: {
@@ -156,13 +156,13 @@ quote_sent: {
subject: 'Receipt for invoice {{invoice_number}}', subject: 'Receipt for invoice {{invoice_number}}',
body_html: `<h2>Payment received</h2><p>Dear {{customer_name}},</p> body_html: `<h2>Payment received</h2><p>Dear {{customer_name}},</p>
<p>We received your payment of <strong>{{paid_amount}}</strong> for invoice {{invoice_number}} on {{paid_at}}. Thank you!</p>`, <p>We received your payment of <strong>{{paid_amount}}</strong> for invoice {{invoice_number}} on {{paid_at}}. Thank you!</p>`,
body_text: `Receipt: {{paid_amount}} received for {{invoice_number}} on {{paid_at}}.`, body_text: 'Receipt: {{paid_amount}} received for {{invoice_number}} on {{paid_at}}.',
}, },
de: { de: {
subject: 'Zahlungsbestätigung für Rechnung {{invoice_number}}', subject: 'Zahlungsbestätigung für Rechnung {{invoice_number}}',
body_html: `<h2>Zahlung erhalten</h2><p>Sehr geehrte/r {{customer_name}},</p> body_html: `<h2>Zahlung erhalten</h2><p>Sehr geehrte/r {{customer_name}},</p>
<p>vielen Dank für Ihre Zahlung in Höhe von <strong>{{paid_amount}}</strong> für die Rechnung {{invoice_number}} am {{paid_at}}.</p>`, <p>vielen Dank für Ihre Zahlung in Höhe von <strong>{{paid_amount}}</strong> für die Rechnung {{invoice_number}} am {{paid_at}}.</p>`,
body_text: `Zahlungsbestätigung: {{paid_amount}} erhalten für {{invoice_number}} am {{paid_at}}.`, body_text: 'Zahlungsbestätigung: {{paid_amount}} erhalten für {{invoice_number}} am {{paid_at}}.',
}, },
}, },
invoice_cancelled: { invoice_cancelled: {
@@ -170,13 +170,13 @@ quote_sent: {
variables: ['invoice_number', 'customer_name'], variables: ['invoice_number', 'customer_name'],
en: { en: {
subject: 'Invoice {{invoice_number}} cancelled', subject: 'Invoice {{invoice_number}} cancelled',
body_html: `<p>Dear {{customer_name}},</p><p>Invoice {{invoice_number}} has been cancelled. Please disregard any previous reminders for this invoice.</p>`, body_html: '<p>Dear {{customer_name}},</p><p>Invoice {{invoice_number}} has been cancelled. Please disregard any previous reminders for this invoice.</p>',
body_text: `Invoice {{invoice_number}} has been cancelled.`, body_text: 'Invoice {{invoice_number}} has been cancelled.',
}, },
de: { de: {
subject: 'Rechnung {{invoice_number}} storniert', subject: 'Rechnung {{invoice_number}} storniert',
body_html: `<p>Sehr geehrte/r {{customer_name}},</p><p>die Rechnung {{invoice_number}} wurde storniert. Bitte ignorieren Sie eventuelle frühere Erinnerungen zu dieser Rechnung.</p>`, body_html: '<p>Sehr geehrte/r {{customer_name}},</p><p>die Rechnung {{invoice_number}} wurde storniert. Bitte ignorieren Sie eventuelle frühere Erinnerungen zu dieser Rechnung.</p>',
body_text: `Rechnung {{invoice_number}} wurde storniert.`, body_text: 'Rechnung {{invoice_number}} wurde storniert.',
}, },
}, },
quote_accepted_customer: { quote_accepted_customer: {
@@ -294,14 +294,14 @@ Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungser
body_html: `<p>Dear {{customer_name}},</p> body_html: `<p>Dear {{customer_name}},</p>
<p>Please find attached cancellation invoice <strong>{{storno_number}}</strong>, which formally reverses invoice <strong>{{original_invoice_number}}</strong> dated {{original_issue_date}} for {{total_amount}}.</p> <p>Please find attached cancellation invoice <strong>{{storno_number}}</strong>, which formally reverses invoice <strong>{{original_invoice_number}}</strong> dated {{original_issue_date}} for {{total_amount}}.</p>
<p>The original invoice is no longer payable. Please retain the attached PDF for your records and disregard any prior reminders.</p>`, <p>The original invoice is no longer payable. Please retain the attached PDF for your records and disregard any prior reminders.</p>`,
body_text: `Cancellation invoice {{storno_number}} formally reverses invoice {{original_invoice_number}} dated {{original_issue_date}} for {{total_amount}}. The original invoice is no longer payable. PDF attached.`, body_text: 'Cancellation invoice {{storno_number}} formally reverses invoice {{original_invoice_number}} dated {{original_issue_date}} for {{total_amount}}. The original invoice is no longer payable. PDF attached.',
}, },
de: { de: {
subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}', subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}',
body_html: `<p>Sehr geehrte/r {{customer_name}},</p> body_html: `<p>Sehr geehrte/r {{customer_name}},</p>
<p>anbei erhalten Sie die Stornorechnung <strong>{{storno_number}}</strong>, mit der die Rechnung <strong>{{original_invoice_number}}</strong> vom {{original_issue_date}} über {{total_amount}} förmlich aufgehoben wird.</p> <p>anbei erhalten Sie die Stornorechnung <strong>{{storno_number}}</strong>, mit der die Rechnung <strong>{{original_invoice_number}}</strong> vom {{original_issue_date}} über {{total_amount}} förmlich aufgehoben wird.</p>
<p>Die ursprüngliche Rechnung ist damit nicht mehr zu begleichen. Bitte bewahren Sie die beigefügte PDF für Ihre Unterlagen auf etwaige vorherige Mahnungen sind hinfällig.</p>`, <p>Die ursprüngliche Rechnung ist damit nicht mehr zu begleichen. Bitte bewahren Sie die beigefügte PDF für Ihre Unterlagen auf etwaige vorherige Mahnungen sind hinfällig.</p>`,
body_text: `Stornorechnung {{storno_number}} hebt Rechnung {{original_invoice_number}} vom {{original_issue_date}} über {{total_amount}} förmlich auf. Die ursprüngliche Rechnung ist nicht mehr zu begleichen. PDF im Anhang.`, body_text: 'Stornorechnung {{storno_number}} hebt Rechnung {{original_invoice_number}} vom {{original_issue_date}} über {{total_amount}} förmlich auf. Die ursprüngliche Rechnung ist nicht mehr zu begleichen. PDF im Anhang.',
}, },
}, },
invoice_paid_admin_notification: { invoice_paid_admin_notification: {
+4 -6
View File
@@ -23,10 +23,8 @@
* same legal-record discipline as line items today. * same legal-record discipline as line items today.
*/ */
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { hasColumnCached } = require('../utils/schemaCache'); const { hasColumnCached } = require('../utils/schemaCache');
const logger = require('../utils/logger');
const invoiceService = require('./invoiceService'); const invoiceService = require('./invoiceService');
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -287,7 +285,7 @@ async function createEntry(customerId, payload, adminId) {
logInfo = { type: 'hour_entry_logged', meta: { entryId, customerId: customer.id } }; logInfo = { type: 'hour_entry_logged', meta: { entryId, customerId: customer.id } };
return { id: entryId, status: 'unbilled' }; return { id: entryId, status: 'unbilled' };
}); });
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} } if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } }
return result; return result;
} }
@@ -385,7 +383,7 @@ async function updateEntry(entryId, payload, adminId) {
logInfo = { type: 'hour_entry_updated', meta: { entryId, customerId: entry.customer_account_id } }; logInfo = { type: 'hour_entry_updated', meta: { entryId, customerId: entry.customer_account_id } };
return { id: entryId }; return { id: entryId };
}); });
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} } if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } }
return result; return result;
} }
@@ -436,7 +434,7 @@ async function deleteEntry(entryId, adminId) {
logInfo = { type: 'hour_entry_deleted', meta: { entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id } }; logInfo = { type: 'hour_entry_deleted', meta: { entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id } };
return { deleted: true }; return { deleted: true };
}); });
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} } if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } }
return result; return result;
} }
@@ -521,7 +519,7 @@ async function billUnbilledEntries(customerId, adminId) {
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } }; logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
return { invoiceId, entriesBilled: unbilled.length }; return { invoiceId, entriesBilled: unbilled.length };
}); });
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} } if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } }
return result; return result;
} }
+4 -6
View File
@@ -13,8 +13,6 @@ const { formatBoolean } = require('../utils/dbCompat');
const packageJson = require('../../package.json'); const packageJson = require('../../package.json');
// Constants // Constants
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
const PROGRESS_INTERVAL = 100; // Report progress every 100 rows
// Face recognition tables (#1074). Their SCHEMA is backed up, their CONTENTS // Face recognition tables (#1074). Their SCHEMA is backed up, their CONTENTS
// are not: embeddings are biometric data (GDPR Art. 9) and fully derived from // are not: embeddings are biometric data (GDPR Art. 9) and fully derived from
@@ -183,7 +181,7 @@ class DatabaseBackupService {
/** /**
* Create SQLite backup * Create SQLite backup
*/ */
async createSQLiteBackup(outputPath, options = {}) { async createSQLiteBackup(outputPath, _options = {}) {
const dbPath = knexConfig.connection.filename; const dbPath = knexConfig.connection.filename;
const tempPath = `${outputPath}.tmp`; const tempPath = `${outputPath}.tmp`;
@@ -214,7 +212,7 @@ class DatabaseBackupService {
// works out that a manual re-scan is needed. Requeue instead. // works out that a manual re-scan is needed. Requeue instead.
await spawnAsync('sqlite3', [ await spawnAsync('sqlite3', [
tempPath, tempPath,
"UPDATE photos SET face_status = CASE WHEN face_status IS NULL THEN NULL ELSE 'pending' END, " 'UPDATE photos SET face_status = CASE WHEN face_status IS NULL THEN NULL ELSE \'pending\' END, '
+ 'face_count = NULL, face_started_at = NULL, face_error = NULL;', + 'face_count = NULL, face_started_at = NULL, face_error = NULL;',
]).catch(() => {}); ]).catch(() => {});
// FATAL, not a warning. Deleting rows leaves their pages in the file // FATAL, not a warning. Deleting rows leaves their pages in the file
@@ -337,7 +335,7 @@ class DatabaseBackupService {
/** /**
* Validate backup integrity * Validate backup integrity
*/ */
async validateBackup(backupPath, originalChecksums) { async validateBackup(backupPath, _originalChecksums) {
const tempDbPath = `${backupPath}.validate`; const tempDbPath = `${backupPath}.validate`;
try { try {
@@ -747,7 +745,7 @@ class DatabaseBackupService {
/** /**
* Restore from backup (with version checking) * Restore from backup (with version checking)
*/ */
async restore(backupPath, options = {}) { async restore(backupPath, _options = {}) {
// This is a dangerous operation and should be used with extreme caution // This is a dangerous operation and should be used with extreme caution
throw new Error('Restore functionality not implemented for safety. Please use restore service or restore manually.'); throw new Error('Restore functionality not implemented for safety. Please use restore service or restore manually.');
} }
+1 -1
View File
@@ -183,7 +183,7 @@ async function getRecipientLanguage(email, eventId = null) {
.first(); .first();
if (langSetting && langSetting.setting_value) { if (langSetting && langSetting.setting_value) {
let lang = langSetting.setting_value; let lang = langSetting.setting_value;
try { lang = JSON.parse(lang); } catch (_) {} try { lang = JSON.parse(lang); } catch (_) { /* non-fatal */ }
if (typeof lang === 'string' && lang.trim()) return lang.trim(); if (typeof lang === 'string' && lang.trim()) return lang.trim();
} }
} catch (error) { } catch (error) {
@@ -63,8 +63,6 @@ const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates
const DEFAULT_DAYS_BEFORE = 2; const DEFAULT_DAYS_BEFORE = 2;
const DEFAULT_TEMPLATE_GROUP = 'event_reminder'; const DEFAULT_TEMPLATE_GROUP = 'event_reminder';
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
// One-shot guard: the "schema not migrated" warn would otherwise fire // One-shot guard: the "schema not migrated" warn would otherwise fire
// once per cron tick (≈ hourly) on installs that haven't applied // once per cron tick (≈ hourly) on installs that haven't applied
+12 -12
View File
@@ -37,8 +37,8 @@ const VARIABLES = [
// Tiny HTML signature line shared across templates so the maintainer // Tiny HTML signature line shared across templates so the maintainer
// only has to brand once. Variables substitute at render time. // only has to brand once. Variables substitute at render time.
const SIGNATURE_EN = `<p style="margin-top: 24px;">See you soon,<br>{{business_name}}</p>`; const SIGNATURE_EN = '<p style="margin-top: 24px;">See you soon,<br>{{business_name}}</p>';
const SIGNATURE_DE = `<p style="margin-top: 24px;">Bis bald,<br>{{business_name}}</p>`; const SIGNATURE_DE = '<p style="margin-top: 24px;">Bis bald,<br>{{business_name}}</p>';
const EVENT_REMINDER_TEMPLATES = { const EVENT_REMINDER_TEMPLATES = {
event_reminder_default: { event_reminder_default: {
@@ -54,7 +54,7 @@ const EVENT_REMINDER_TEMPLATES = {
</ul> </ul>
<p>If anything has changed since we last spoke, just hit reply.</p> <p>If anything has changed since we last spoke, just hit reply.</p>
${SIGNATURE_EN}`, ${SIGNATURE_EN}`,
body_text: `Hi {{customer_name}},\n\nJust a quick reminder that {{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) from now.\n\nA few things that help us hit the ground running on the day:\n- Confirm the exact start time and address.\n- Let us know if there is anything we should keep an eye on (VIPs, surprise moments, restricted areas).\n- Indoor venues: a small corner for equipment setup is a huge help.\n\nIf anything has changed since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`, body_text: 'Hi {{customer_name}},\n\nJust a quick reminder that {{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) from now.\n\nA few things that help us hit the ground running on the day:\n- Confirm the exact start time and address.\n- Let us know if there is anything we should keep an eye on (VIPs, surprise moments, restricted areas).\n- Indoor venues: a small corner for equipment setup is a huge help.\n\nIf anything has changed since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}',
}, },
de: { de: {
subject: 'Erinnerung: {{event_name}} in {{days_before}} Tag(en)', subject: 'Erinnerung: {{event_name}} in {{days_before}} Tag(en)',
@@ -68,7 +68,7 @@ ${SIGNATURE_EN}`,
</ul> </ul>
<p>Hat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.</p> <p>Hat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.</p>
${SIGNATURE_DE}`, ${SIGNATURE_DE}`,
body_text: `Hallo {{customer_name}},\n\nkurze Erinnerung: {{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en).\n\nDamit wir am Tag selbst sofort loslegen können, helfen uns folgende Punkte sehr:\n- Genaue Startzeit und Adresse bestätigen.\n- Kurz Bescheid geben, falls etwas besonders zu beachten ist (VIPs, Überraschungsmomente, abgesperrte Bereiche).\n- Bei Innen-Locations: eine kleine Ecke für den Equipment-Aufbau ist Gold wert.\n\nHat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.\n\nBis bald,\n{{business_name}}`, body_text: 'Hallo {{customer_name}},\n\nkurze Erinnerung: {{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en).\n\nDamit wir am Tag selbst sofort loslegen können, helfen uns folgende Punkte sehr:\n- Genaue Startzeit und Adresse bestätigen.\n- Kurz Bescheid geben, falls etwas besonders zu beachten ist (VIPs, Überraschungsmomente, abgesperrte Bereiche).\n- Bei Innen-Locations: eine kleine Ecke für den Equipment-Aufbau ist Gold wert.\n\nHat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.\n\nBis bald,\n{{business_name}}',
}, },
}, },
@@ -87,7 +87,7 @@ ${SIGNATURE_DE}`,
</ul> </ul>
<p>If anything has shifted since we last spoke even small things just hit reply.</p> <p>If anything has shifted since we last spoke even small things just hit reply.</p>
${SIGNATURE_EN}`, ${SIGNATURE_EN}`,
body_text: `Dear {{customer_name}},\n\nYour wedding day is almost here — {{event_date}}, in about {{days_before}} day(s). We are very much looking forward to it.\n\nA short pre-day checklist so the photo coverage flows smoothly:\n- Timeline: a rough hour-by-hour run-of-day helps us anticipate every moment.\n- Family shots: a short list of must-have group photos (with names) keeps the formals quick.\n- Getting-ready space: a room with natural light makes a real difference.\n- Surprises: let us know so we are in the right place — and won't spoil them.\n- Logistics: ceremony start time, venue address, parking notes, coordinator contact.\n\nIf anything has shifted since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`, body_text: 'Dear {{customer_name}},\n\nYour wedding day is almost here — {{event_date}}, in about {{days_before}} day(s). We are very much looking forward to it.\n\nA short pre-day checklist so the photo coverage flows smoothly:\n- Timeline: a rough hour-by-hour run-of-day helps us anticipate every moment.\n- Family shots: a short list of must-have group photos (with names) keeps the formals quick.\n- Getting-ready space: a room with natural light makes a real difference.\n- Surprises: let us know so we are in the right place — and won\'t spoil them.\n- Logistics: ceremony start time, venue address, parking notes, coordinator contact.\n\nIf anything has shifted since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}',
}, },
de: { de: {
subject: 'Eure Hochzeit am {{event_date}} — letzte Details', subject: 'Eure Hochzeit am {{event_date}} — letzte Details',
@@ -103,7 +103,7 @@ ${SIGNATURE_EN}`,
</ul> </ul>
<p>Hat sich seit unserem letzten Gespräch etwas verschoben auch Kleinigkeiten? Einfach kurz antworten.</p> <p>Hat sich seit unserem letzten Gespräch etwas verschoben auch Kleinigkeiten? Einfach kurz antworten.</p>
${SIGNATURE_DE}`, ${SIGNATURE_DE}`,
body_text: `Liebe/r {{customer_name}},\n\neuer grosser Tag steht fast vor der Tür — {{event_date}}, in etwa {{days_before}} Tag(en). Wir freuen uns sehr darauf.\n\nEine kurze Checkliste vor dem Tag:\n- Ablauf: ein grober Stunden-Ablauf hilft uns enorm.\n- Familienbilder: kurze Liste der Wunsch-Gruppenbilder (mit Namen).\n- Getting-Ready-Raum: ein Zimmer mit Tageslicht macht einen riesigen Unterschied.\n- Überraschungen: kurz Bescheid geben, damit wir zur richtigen Zeit am richtigen Ort sind.\n- Logistik: Beginn der Trauung, Adresse, Parkhinweise, Telefonnummer der Tages-Koordination.\n\nHat sich etwas verschoben? Einfach kurz antworten.\n\nBis bald,\n{{business_name}}`, body_text: 'Liebe/r {{customer_name}},\n\neuer grosser Tag steht fast vor der Tür — {{event_date}}, in etwa {{days_before}} Tag(en). Wir freuen uns sehr darauf.\n\nEine kurze Checkliste vor dem Tag:\n- Ablauf: ein grober Stunden-Ablauf hilft uns enorm.\n- Familienbilder: kurze Liste der Wunsch-Gruppenbilder (mit Namen).\n- Getting-Ready-Raum: ein Zimmer mit Tageslicht macht einen riesigen Unterschied.\n- Überraschungen: kurz Bescheid geben, damit wir zur richtigen Zeit am richtigen Ort sind.\n- Logistik: Beginn der Trauung, Adresse, Parkhinweise, Telefonnummer der Tages-Koordination.\n\nHat sich etwas verschoben? Einfach kurz antworten.\n\nBis bald,\n{{business_name}}',
}, },
}, },
@@ -120,7 +120,7 @@ ${SIGNATURE_DE}`,
</ul> </ul>
<p>Looking forward to celebrating let us know if anything has changed.</p> <p>Looking forward to celebrating let us know if anything has changed.</p>
${SIGNATURE_EN}`, ${SIGNATURE_EN}`,
body_text: `Hi {{customer_name}},\n\n{{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) away. Quick check-in:\n- Headcount: roughly how many guests?\n- Schedule: when is the cake/song moment?\n- Theme or dress code, if any.\n- Surprises we should keep quiet about?\n\nLooking forward to celebrating — let us know if anything has changed.\n\nSee you soon,\n{{business_name}}`, body_text: 'Hi {{customer_name}},\n\n{{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) away. Quick check-in:\n- Headcount: roughly how many guests?\n- Schedule: when is the cake/song moment?\n- Theme or dress code, if any.\n- Surprises we should keep quiet about?\n\nLooking forward to celebrating — let us know if anything has changed.\n\nSee you soon,\n{{business_name}}',
}, },
de: { de: {
subject: '{{event_name}} am {{event_date}} — kurze Rückfrage', subject: '{{event_name}} am {{event_date}} — kurze Rückfrage',
@@ -134,7 +134,7 @@ ${SIGNATURE_EN}`,
</ul> </ul>
<p>Wir freuen uns auf das Fest kurz Bescheid geben, falls sich etwas geändert hat.</p> <p>Wir freuen uns auf das Fest kurz Bescheid geben, falls sich etwas geändert hat.</p>
${SIGNATURE_DE}`, ${SIGNATURE_DE}`,
body_text: `Hallo {{customer_name}},\n\n{{event_name}} steht am {{event_date}} an — in etwa {{days_before}} Tag(en). Kurze Rückfrage:\n- Personenzahl: wie viele Gäste werden in etwa kommen?\n- Ablauf: wann ist der Torten-/Ständchen-Moment?\n- Motto oder Dresscode, falls vorhanden.\n- Überraschungen, über die wir nicht reden sollten?\n\nKurz Bescheid geben, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}`, body_text: 'Hallo {{customer_name}},\n\n{{event_name}} steht am {{event_date}} an — in etwa {{days_before}} Tag(en). Kurze Rückfrage:\n- Personenzahl: wie viele Gäste werden in etwa kommen?\n- Ablauf: wann ist der Torten-/Ständchen-Moment?\n- Motto oder Dresscode, falls vorhanden.\n- Überraschungen, über die wir nicht reden sollten?\n\nKurz Bescheid geben, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}',
}, },
}, },
@@ -153,7 +153,7 @@ ${SIGNATURE_DE}`,
</ul> </ul>
<p>Happy to jump on a 10-min call beforehand if it is easier than email.</p> <p>Happy to jump on a 10-min call beforehand if it is easier than email.</p>
${SIGNATURE_EN}`, ${SIGNATURE_EN}`,
body_text: `Dear {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:\n- Shot brief: internal comms, press kit, social, website?\n- Agenda / run-of-show: speakers, awards, panels, Q&A.\n- VIPs & brand: names to prioritise, plus logo/colour direction.\n- Access: entrance, loading dock, on-site contact. Photo ID needed?\n- Confidentiality: any no-photo sessions?\n- Delivery: rough turnaround (24h press selects, full gallery later)?\n\nHappy to jump on a 10-min call beforehand if it is easier than email.\n\nSee you soon,\n{{business_name}}`, body_text: 'Dear {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:\n- Shot brief: internal comms, press kit, social, website?\n- Agenda / run-of-show: speakers, awards, panels, Q&A.\n- VIPs & brand: names to prioritise, plus logo/colour direction.\n- Access: entrance, loading dock, on-site contact. Photo ID needed?\n- Confidentiality: any no-photo sessions?\n- Delivery: rough turnaround (24h press selects, full gallery later)?\n\nHappy to jump on a 10-min call beforehand if it is easier than email.\n\nSee you soon,\n{{business_name}}',
}, },
de: { de: {
subject: 'Vorbereitung Bildbegleitung: {{event_name}} am {{event_date}}', subject: 'Vorbereitung Bildbegleitung: {{event_name}} am {{event_date}}',
@@ -169,7 +169,7 @@ ${SIGNATURE_EN}`,
</ul> </ul>
<p>Falls eine kurze 10-Min-Abstimmung einfacher ist als E-Mail, gerne jederzeit melden.</p> <p>Falls eine kurze 10-Min-Abstimmung einfacher ist als E-Mail, gerne jederzeit melden.</p>
${SIGNATURE_DE}`, ${SIGNATURE_DE}`,
body_text: `Sehr geehrte/r {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Damit die Bildstrecke euren Zielen entspricht, kurz folgende Punkte abstimmen:\n- Briefing: interne Kommunikation, Pressekit, Social, Website?\n- Agenda / Ablauf: Speaker, Awards, Panels, Q&A.\n- VIPs & Brand: zu priorisierende Personen, Logo-/Farbvorgaben.\n- Zugang: Eingang, Anlieferung, Ansprechperson am Morgen. Lichtbildausweis nötig?\n- Vertraulichkeit: rein interne Sessions / kein Foto?\n- Lieferung: Turnaround-Zeit (24h Press-Selects, vollständige Galerie später)?\n\nFalls eine 10-Min-Abstimmung einfacher ist, gerne melden.\n\nBis bald,\n{{business_name}}`, body_text: 'Sehr geehrte/r {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Damit die Bildstrecke euren Zielen entspricht, kurz folgende Punkte abstimmen:\n- Briefing: interne Kommunikation, Pressekit, Social, Website?\n- Agenda / Ablauf: Speaker, Awards, Panels, Q&A.\n- VIPs & Brand: zu priorisierende Personen, Logo-/Farbvorgaben.\n- Zugang: Eingang, Anlieferung, Ansprechperson am Morgen. Lichtbildausweis nötig?\n- Vertraulichkeit: rein interne Sessions / kein Foto?\n- Lieferung: Turnaround-Zeit (24h Press-Selects, vollständige Galerie später)?\n\nFalls eine 10-Min-Abstimmung einfacher ist, gerne melden.\n\nBis bald,\n{{business_name}}',
}, },
}, },
@@ -186,7 +186,7 @@ ${SIGNATURE_DE}`,
</ul> </ul>
<p>If anything has changed since we last spoke, hit reply.</p> <p>If anything has changed since we last spoke, hit reply.</p>
${SIGNATURE_EN}`, ${SIGNATURE_EN}`,
body_text: `Hi {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. A short prep note:\n- Start time & address: please confirm both.\n- Run-of-day: a rough timeline of the key moments.\n- Setup space: a small corner for gear if indoors.\n- Anything specific: people to prioritise, things to avoid, dress code, surprises.\n\nIf anything has changed, just hit reply.\n\nSee you soon,\n{{business_name}}`, body_text: 'Hi {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. A short prep note:\n- Start time & address: please confirm both.\n- Run-of-day: a rough timeline of the key moments.\n- Setup space: a small corner for gear if indoors.\n- Anything specific: people to prioritise, things to avoid, dress code, surprises.\n\nIf anything has changed, just hit reply.\n\nSee you soon,\n{{business_name}}',
}, },
de: { de: {
subject: '{{event_name}} am {{event_date}} — Vorbereitungs-Hinweise', subject: '{{event_name}} am {{event_date}} — Vorbereitungs-Hinweise',
@@ -200,7 +200,7 @@ ${SIGNATURE_EN}`,
</ul> </ul>
<p>Hat sich seit dem letzten Austausch etwas geändert? Einfach kurz antworten.</p> <p>Hat sich seit dem letzten Austausch etwas geändert? Einfach kurz antworten.</p>
${SIGNATURE_DE}`, ${SIGNATURE_DE}`,
body_text: `Hallo {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Kurz zur Vorbereitung:\n- Startzeit & Adresse: bitte beides kurz bestätigen.\n- Ablauf: ein grober Zeitplan der Schlüsselmomente.\n- Aufbauplatz: bei Innen-Locations eine kleine Ecke fürs Equipment.\n- Besonderheiten: Personen im Fokus, Dinge zu vermeiden, Dresscode, Überraschungen.\n\nKurz antworten, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}`, body_text: 'Hallo {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Kurz zur Vorbereitung:\n- Startzeit & Adresse: bitte beides kurz bestätigen.\n- Ablauf: ein grober Zeitplan der Schlüsselmomente.\n- Aufbauplatz: bei Innen-Locations eine kleine Ecke fürs Equipment.\n- Besonderheiten: Personen im Fokus, Dinge zu vermeiden, Dresscode, Überraschungen.\n\nKurz antworten, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}',
}, },
}, },
}; };
+2 -2
View File
@@ -3,7 +3,7 @@
* Handles renaming events including slug updates, file system changes, and database updates * Handles renaming events including slug updates, file system changes, and database updates
*/ */
const { db, logActivity } = require('../database/db'); const { db } = require('../database/db');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@@ -228,7 +228,7 @@ class EventRenameService {
const event = await trx('events').where({ id: eventId }).first(); const event = await trx('events').where({ id: eventId }).first();
// Generate new share link // Generate new share link
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({
slug: newSlug, slug: newSlug,
shareToken: event.share_token shareToken: event.share_token
}); });
@@ -20,7 +20,7 @@ async function getById(id) {
return row; return row;
} }
async function create({ name, color, displayOrder }, adminId) { async function create({ name, color, displayOrder }, _adminId) {
if (!name || !String(name).trim()) { if (!name || !String(name).trim()) {
throw new AppError('Category name is required', 400, 'NAME_REQUIRED'); throw new AppError('Category name is required', 400, 'NAME_REQUIRED');
} }
+1 -5
View File
@@ -61,7 +61,7 @@ async function list(relativePath = '') {
const targetDir = safePathJoin(root, relativePath || '.'); const targetDir = safePathJoin(root, relativePath || '.');
const entries = []; const entries = [];
try { // Errors propagate to the caller to handle (e.g. invalid path).
const dirents = await fs.readdir(targetDir, { withFileTypes: true }); const dirents = await fs.readdir(targetDir, { withFileTypes: true });
for (const d of dirents) { for (const d of dirents) {
// Skip hidden files and directories // Skip hidden files and directories
@@ -79,10 +79,6 @@ async function list(relativePath = '') {
} }
} }
} }
} catch (e) {
// Propagate errors for caller to handle (e.g., invalid path)
throw e;
}
const rootResolved = path.resolve(root); const rootResolved = path.resolve(root);
const currentResolved = path.resolve(targetDir); const currentResolved = path.resolve(targetDir);
+16 -6
View File
@@ -66,17 +66,27 @@ class FeedbackModerationService {
} }
} }
// Check for severe violations // Check for blocking violations. 'severe' is the legacy vocabulary the
if (violations.some(v => v.severity === 'severe')) { // validator used to accept before it was aligned with the UI's
// low/moderate/high/block levels — rows stored under it still apply.
const isBlocking = (v) => v.severity === 'block' || v.severity === 'severe';
if (violations.some(isBlocking)) {
// `blocked` is the flag the submit route branches on: the `block`
// tier is advertised as "comment is rejected immediately", so it 4xxs
// the submission instead of storing it for a moderator. Every other
// not-approved outcome (moderate/high, spam checks, and the
// moderation-system-error fallback below) deliberately omits it and
// keeps the held-for-moderation behaviour.
return { return {
approved: false, approved: false,
blocked: true,
reason: 'Content contains prohibited words', reason: 'Content contains prohibited words',
violations: violations.filter(v => v.severity === 'severe') violations: violations.filter(isBlocking)
}; };
} }
// Check for moderate violations // Check for moderate/high violations
if (violations.some(v => v.severity === 'moderate')) { if (violations.some(v => v.severity === 'moderate' || v.severity === 'high')) {
return { return {
approved: false, approved: false,
reason: 'Content requires moderation', reason: 'Content requires moderation',
@@ -84,7 +94,7 @@ class FeedbackModerationService {
}; };
} }
// Check for mild violations (may just flag for review) // Check for low-severity violations (may just flag for review)
if (violations.length > 0) { if (violations.length > 0) {
return { return {
approved: true, approved: true,
+8 -1
View File
@@ -645,7 +645,14 @@ class FeedbackService {
guest_id: guest_id || null, guest_id: guest_id || null,
ip_address, ip_address,
user_agent, user_agent,
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments, // The submit route can force a comment into moderation (a
// `moderate`/`high` word-filter hit) on an event whose
// moderate_comments is off — this line used to ignore that entirely,
// so those hits published straight away. A caller-supplied `false` is
// honoured; nothing a caller passes can RELAX the event's setting.
is_approved: feedbackData.is_approved === false
? false
: (feedback_type !== 'comment' || !feedbackData.moderate_comments),
created_at: new Date(), created_at: new Date(),
updated_at: new Date() updated_at: new Date()
}).returning('id'); }).returning('id');
+1 -1
View File
@@ -40,7 +40,7 @@ function startFileWatcher() {
} }
const watcher = chokidar.watch(WATCH_PATH(), { const watcher = chokidar.watch(WATCH_PATH(), {
ignored: /(^|[\/\\])\../, // ignore dotfiles ignored: /(^|[/\\])\../, // ignore dotfiles
persistent: true, persistent: true,
awaitWriteFinish: { awaitWriteFinish: {
stabilityThreshold: 2000, stabilityThreshold: 2000,
+1 -1
View File
@@ -166,7 +166,7 @@ async function scanRoot(rootAbs) {
if (result.has(lc)) { if (result.has(lc)) {
logger.warn( logger.warn(
`[fonts] Duplicate family ${family.family} within ${rootAbs}; ` + `[fonts] Duplicate family ${family.family} within ${rootAbs}; ` +
`keeping the first encountered folder` 'keeping the first encountered folder'
); );
continue; continue;
} }
+2 -2
View File
@@ -306,7 +306,7 @@ async function createInvoice(payload, adminId, trx = db) {
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, items); await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, items);
} }
try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`, trx); } catch (_) {} try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`, trx); } catch (_) { /* non-fatal */ }
return { invoiceIds: [invoiceId] }; return { invoiceIds: [invoiceId] };
} }
@@ -568,7 +568,7 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre
// pool (this runs unattended from the booking flow's prepare_invoice). // pool (this runs unattended from the booking flow's prepare_invoice).
await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt }, await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt },
eventId, `admin:${adminId}`, trx); eventId, `admin:${adminId}`, trx);
} catch (_) {} } catch (_) { /* non-fatal */ }
invoiceIds.push(invoiceId); invoiceIds.push(invoiceId);
} }
return { invoiceIds }; return { invoiceIds };
+1 -1
View File
@@ -221,7 +221,7 @@ async function appendToMonthlyDraft(payload, customer, adminId, trx) {
await logActivity('monthly_billing_items_queued', await logActivity('monthly_billing_items_queued',
{ invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length }, { invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length },
null, `admin:${adminId}`); null, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
return draft.id; return draft.id;
} }
@@ -347,7 +347,7 @@ async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) {
await logActivity('invoice_scheduled', { await logActivity('invoice_scheduled', {
invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape', invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape',
}, sample.event_id, `admin:${adminId}`); }, sample.event_id, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
created.push(newId); created.push(newId);
} }
@@ -365,7 +365,7 @@ async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) {
dealUuid, newCount, dealUuid, newCount,
kept: kept.length, created: created.length, deleted: deleted.length, kept: kept.length, created: created.length, deleted: deleted.length,
}, sample.event_id, `admin:${adminId}`); }, sample.event_id, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
return { return {
invoiceIds: [...kept, ...created], invoiceIds: [...kept, ...created],
+5 -5
View File
@@ -88,7 +88,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not
try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment', try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment',
{ invoiceId: id, amountMinor: amount, totalPaidMinor: total }, { invoiceId: id, amountMinor: amount, totalPaidMinor: total },
invoice.event_id || null, `admin:${adminId}`); } catch (_) {} invoice.event_id || null, `admin:${adminId}`); } catch (_) { /* non-fatal */ }
// Migration 127 — admin payment-received notification. Fires only // Migration 127 — admin payment-received notification. Fires only
// on the transition into 'paid' so admins don't get duplicate // on the transition into 'paid' so admins don't get duplicate
@@ -134,7 +134,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not
paidTotalMinor: markResult.paidTotalMinor, paidTotalMinor: markResult.paidTotalMinor,
}, },
}); });
} catch (_) {} } catch (_) { /* non-fatal */ }
} }
return markResult; return markResult;
} }
@@ -203,7 +203,7 @@ async function queueInvoicePaidAdminNotification({
try { try {
await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id }, await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id },
invoice.event_id || null, 'system'); invoice.event_id || null, 'system');
} catch (_) {} } catch (_) { /* non-fatal */ }
} }
async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) { async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) {
@@ -318,7 +318,7 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
try { try {
await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) }, await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) },
invoice.event_id || null, 'scheduler'); invoice.event_id || null, 'scheduler');
} catch (_) {} } catch (_) { /* non-fatal */ }
return { token, sent: true }; return { token, sent: true };
} }
@@ -449,7 +449,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI
{ invoiceId: invoice.id, action, amountMinor: amountMinor || null }, { invoiceId: invoice.id, action, amountMinor: amountMinor || null },
invoice.event_id || null, invoice.event_id || null,
adminId ? `admin:${adminId}` : 'public:payment-check'); adminId ? `admin:${adminId}` : 'public:payment-check');
} catch (_) {} } catch (_) { /* non-fatal */ }
// --- Apply the action ----------------------------------------- // --- Apply the action -----------------------------------------
if (action === 'paid_full') { if (action === 'paid_full') {
+2 -2
View File
@@ -117,7 +117,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
currency: invoice.currency, currency: invoice.currency,
}, },
}); });
} catch (_) {} } catch (_) { /* non-fatal */ }
// Render the MAHNUNG (reminder letter). The original invoice PDF is left // Render the MAHNUNG (reminder letter). The original invoice PDF is left
// UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a // UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a
@@ -180,7 +180,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
try { try {
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross }, await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross },
invoice.event_id || null, `admin:${adminId || 'system'}`); invoice.event_id || null, `admin:${adminId || 'system'}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
return { level, lateFeeMinor: lateFeeGross }; return { level, lateFeeMinor: lateFeeGross };
} }
+2 -2
View File
@@ -76,7 +76,7 @@ async function runScheduledTasks() {
await logActivity('monthly_bill_skipped_empty', await logActivity('monthly_bill_skipped_empty',
{ invoiceId: draft.id, customerId: draft.customer_account_id }, { invoiceId: draft.id, customerId: draft.customer_account_id },
null, 'scheduler'); null, 'scheduler');
} catch (_) {} } catch (_) { /* non-fatal */ }
continue; continue;
} }
// Arm for the flush pass: clear the draft flag, set the send // Arm for the flush pass: clear the draft flag, set the send
@@ -95,7 +95,7 @@ async function runScheduledTasks() {
{ invoiceId: draft.id, customerId: draft.customer_account_id, { invoiceId: draft.id, customerId: draft.customer_account_id,
periodEnd: draft.monthly_period_end }, periodEnd: draft.monthly_period_end },
null, 'scheduler'); null, 'scheduler');
} catch (_) {} } catch (_) { /* non-fatal */ }
} catch (err) { } catch (err) {
logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message }); logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message });
} }
+8 -8
View File
@@ -159,7 +159,7 @@ async function sendInvoice(id, adminId, options = {}) {
attachments: invoiceAttachments, attachments: invoiceAttachments,
}); });
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {} try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) { /* non-fatal */ }
// Fire the workflow engine's invoice.sent trigger (after the row is updated + // 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 // the email queued). Idempotent per invoice id; no-op when the workflows flag
@@ -180,7 +180,7 @@ async function sendInvoice(id, adminId, options = {}) {
currency: invoice.currency, currency: invoice.currency,
}, },
}); });
} catch (_) {} } catch (_) { /* non-fatal */ }
return { sent: true, pdfPath }; return { sent: true, pdfPath };
} }
@@ -355,7 +355,7 @@ async function createStorno(originalId, adminId, trx = db) {
await logActivity('invoice_cancelled_via_storno', await logActivity('invoice_cancelled_via_storno',
{ invoiceId: originalId, stornoId, stornoNumber }, { invoiceId: originalId, stornoId, stornoNumber },
original.event_id || null, `admin:${adminId}`, trx); original.event_id || null, `admin:${adminId}`, trx);
} catch (_) {} } catch (_) { /* non-fatal */ }
return stornoId; return stornoId;
} }
@@ -430,7 +430,7 @@ async function sendStorno(stornoId, adminId) {
await logActivity('storno_sent', await logActivity('storno_sent',
{ stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null }, { stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null },
storno.event_id || null, `admin:${adminId || 'system'}`); storno.event_id || null, `admin:${adminId || 'system'}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
return { status: 'sent', stornoId }; return { status: 'sent', stornoId };
} }
@@ -558,7 +558,7 @@ async function reissueInvoice(id, adminId) {
await logActivity('invoice_reissued', await logActivity('invoice_reissued',
{ originalInvoiceId: id, newInvoiceId: newId, stornoId }, { originalInvoiceId: id, newInvoiceId: newId, stornoId },
original.event_id || null, `admin:${adminId}`, trx); original.event_id || null, `admin:${adminId}`, trx);
} catch (_) {} } catch (_) { /* non-fatal */ }
return { id: newId, replaces: id, stornoId }; return { id: newId, replaces: id, stornoId };
}); });
@@ -592,7 +592,7 @@ async function releaseForDelivery(id, adminId) {
}); });
try { try {
await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
// Fire immediately rather than waiting for the next scheduler // Fire immediately rather than waiting for the next scheduler
// tick — admin clicked the button because they want it out now. // tick — admin clicked the button because they want it out now.
return await sendInvoice(id, adminId); return await sendInvoice(id, adminId);
@@ -646,7 +646,7 @@ async function cancelInvoice(id, adminId) {
await logActivity('invoice_cancelled', await logActivity('invoice_cancelled',
{ invoiceId: id, viaStorno: false }, { invoiceId: id, viaStorno: false },
invoice.event_id || null, `admin:${adminId}`); invoice.event_id || null, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
return { cancelled: true, stornoId: null }; return { cancelled: true, stornoId: null };
} }
@@ -690,7 +690,7 @@ async function triggerMonthlyBillNow(customerId, adminId) {
await logActivity('monthly_bill_triggered_manually', await logActivity('monthly_bill_triggered_manually',
{ invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end }, { invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end },
null, `admin:${adminId}`); null, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
// Inline send so admin gets immediate feedback (PDF stored, status // Inline send so admin gets immediate feedback (PDF stored, status
// flipped to 'sent', email queued). A failure here doesn't roll // flipped to 'sent', email queued). A failure here doesn't roll
+6 -15
View File
@@ -527,15 +527,6 @@ function drawTitle(doc, title, x, y) {
return doc.y + 8; return doc.y + 8;
} }
function drawDate(doc, label, value, x, y, width) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
const right = x + width;
const labelWidth = 80;
doc.text(`${label}:`, right - labelWidth - 80, y, { width: 80, align: 'right' });
doc.text(value, right - 80, y, { width: 80, align: 'right' });
return doc.y + 10;
}
/** /**
* Render the line-items table via swissqrbill's Table helper. We supply * Render the line-items table via swissqrbill's Table helper. We supply
* widths in points; the helper draws the borderless layout the * widths in points; the helper draws the borderless layout the
@@ -1946,12 +1937,12 @@ function renderContractToBuffer(context) {
// ---- helper: ensure space before drawing, paginate if needed. // ---- helper: ensure space before drawing, paginate if needed.
const bottomLimit = PAGE.height - PAGE.marginBottom - 20; const bottomLimit = PAGE.height - PAGE.marginBottom - 20;
function ensureSpace(needed) { const ensureSpace = (needed) => {
if (y + needed > bottomLimit) { if (y + needed > bottomLimit) {
doc.addPage(); doc.addPage();
y = PAGE.marginTop; y = PAGE.marginTop;
} }
} };
// ---- helper: render body text with inline **bold** support. // ---- helper: render body text with inline **bold** support.
// Splits on `**text**` markers, switches the font weight per // Splits on `**text**` markers, switches the font weight per
@@ -1960,7 +1951,7 @@ function renderContractToBuffer(context) {
// chunks continue from PDFKit's cursor so wrapping works // chunks continue from PDFKit's cursor so wrapping works
// across font switches. After rendering, we read doc.y as // across font switches. After rendering, we read doc.y as
// the new cursor. // the new cursor.
function renderBodyMarkdown(text, opts) { const renderBodyMarkdown = (text, opts) => {
const parts = String(text || '').split(/(\*\*[^*]+\*\*)/g).filter((p) => p.length > 0); const parts = String(text || '').split(/(\*\*[^*]+\*\*)/g).filter((p) => p.length > 0);
if (parts.length === 0) return; if (parts.length === 0) return;
const last = parts.length - 1; const last = parts.length - 1;
@@ -1976,7 +1967,7 @@ function renderContractToBuffer(context) {
doc.text(chunk, { ...opts, continued: i < last }); doc.text(chunk, { ...opts, continued: i < last });
} }
} }
} };
// ---- intro text --------------------------------------------- // ---- intro text ---------------------------------------------
if (ctx.doc?.introText) { if (ctx.doc?.introText) {
@@ -2174,7 +2165,7 @@ function renderContractToBuffer(context) {
// Two empty signature boxes — customer on the left, admin on // Two empty signature boxes — customer on the left, admin on
// the right. drawn at fixed coordinates so the stamp service // the right. drawn at fixed coordinates so the stamp service
// can find them later by constant rather than runtime layout. // can find them later by constant rather than runtime layout.
function drawEmptySignaturePane(x, label, info) { const drawEmptySignaturePane = (x, label, info) => {
doc.font(doc._fonts.bold).fontSize(10).fillColor('#000'); doc.font(doc._fonts.bold).fontSize(10).fillColor('#000');
doc.text(label, x, L.paneLabelY, { width: L.boxWidth }); doc.text(label, x, L.paneLabelY, { width: L.boxWidth });
doc.strokeColor('#cccccc').lineWidth(0.5) doc.strokeColor('#cccccc').lineWidth(0.5)
@@ -2194,7 +2185,7 @@ function renderContractToBuffer(context) {
`${t(locale, 'signed_label_date')}: ${info?.signedAt ? formatDate(info.signedAt, locale) : ''}`, `${t(locale, 'signed_label_date')}: ${info?.signedAt ? formatDate(info.signedAt, locale) : ''}`,
x, captionY + 12, { width: L.boxWidth }, x, captionY + 12, { width: L.boxWidth },
); );
} };
drawEmptySignaturePane(L.customerX, t(locale, 'signature_customer'), ctx.signatures?.customer); drawEmptySignaturePane(L.customerX, t(locale, 'signature_customer'), ctx.signatures?.customer);
drawEmptySignaturePane(L.adminX, t(locale, 'signature_admin'), ctx.signatures?.admin); drawEmptySignaturePane(L.adminX, t(locale, 'signature_admin'), ctx.signatures?.admin);
+3 -4
View File
@@ -23,7 +23,6 @@
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const PDFKit = require('pdfkit'); const PDFKit = require('pdfkit');
const { PDFDocument } = require('pdf-lib'); const { PDFDocument } = require('pdf-lib');
@@ -84,7 +83,7 @@ function pdfkitToPdfLib(pageHeight, x, y, w, h) {
* or the input file. * or the input file.
*/ */
async function stampSignature({ pdfBuffer, signaturePngPath, role, caption }) { async function stampSignature({ pdfBuffer, signaturePngPath, role, caption }) {
const { L, FONT_BODY, FONT_BOLD, formatDate } = pdfConsts(); const { L, formatDate } = pdfConsts();
if (!Buffer.isBuffer(pdfBuffer)) { if (!Buffer.isBuffer(pdfBuffer)) {
throw new Error('stampSignature: pdfBuffer must be a Buffer'); throw new Error('stampSignature: pdfBuffer must be a Buffer');
} }
@@ -261,7 +260,7 @@ async function renderAuditCertificate({ contract, customer, admin, locale = 'de'
const labelW = 200; const labelW = 200;
const valueW = PAGE.contentWidth - labelW; const valueW = PAGE.contentWidth - labelW;
function row(labelKey, value) { const row = (labelKey, value) => {
if (!value) return; if (!value) return;
doc.font(doc._fonts.bold).fontSize(9).fillColor('#444'); doc.font(doc._fonts.bold).fontSize(9).fillColor('#444');
doc.text(t(locale, labelKey), PAGE.marginLeft, y, { doc.text(t(locale, labelKey), PAGE.marginLeft, y, {
@@ -272,7 +271,7 @@ async function renderAuditCertificate({ contract, customer, admin, locale = 'de'
width: valueW, align: 'left', width: valueW, align: 'left',
}); });
y = Math.max(y + 12, doc.y + 4); y = Math.max(y + 12, doc.y + 4);
} };
row('audit_contract_number', contract.contract_number); row('audit_contract_number', contract.contract_number);
row('audit_issued_at', contract.sent_at row('audit_issued_at', contract.sent_at
+1 -2
View File
@@ -12,7 +12,6 @@ const feedbackService = require('./feedbackService');
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe'); const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
const { db } = require('../database/db'); const { db } = require('../database/db');
const path = require('path'); const path = require('path');
const fs = require('fs').promises;
/** /**
* The name the camera gave the file, or null when nothing was recorded (#1229). * The name the camera gave the file, or null when nothing was recorded (#1229).
@@ -289,7 +288,7 @@ class PhotoExportService {
/** /**
* Export as JSON metadata * Export as JSON metadata
*/ */
async exportAsJson(photos, eventId, options = {}) { async exportAsJson(photos, eventId, _options = {}) {
// Get event info // Get event info
const event = await db('events') const event = await db('events')
.where('id', eventId) .where('id', eventId)
+3 -3
View File
@@ -261,7 +261,7 @@ const PRESERVED_AUTH_FIELDS = [
async function jsonColumnsFor(trx, table) { async function jsonColumnsFor(trx, table) {
if (!isPostgres()) return new Set(); if (!isPostgres()) return new Set();
const res = await trx.raw( 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')", 'SELECT column_name FROM information_schema.columns WHERE table_schema = \'public\' AND table_name = ? AND data_type IN (\'json\', \'jsonb\')',
[table] [table]
); );
return new Set(res.rows.map((r) => r.column_name)); return new Set(res.rows.map((r) => r.column_name));
@@ -337,7 +337,7 @@ async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot, { c
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
if (isPostgres()) { if (isPostgres()) {
try { try {
await trx.raw("SET session_replication_role = 'replica'"); await trx.raw('SET session_replication_role = \'replica\'');
} catch (_) { } catch (_) {
// session_replication_role requires a Postgres SUPERUSER. The bundled // session_replication_role requires a Postgres SUPERUSER. The bundled
// postgres image's role is one; managed Postgres (RDS / Cloud SQL / …) // postgres image's role is one; managed Postgres (RDS / Cloud SQL / …)
@@ -420,7 +420,7 @@ async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot, { c
} }
// Reset the pg session flag BEFORE the connection returns to the pool. // Reset the pg session flag BEFORE the connection returns to the pool.
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'"); if (isPostgres()) await trx.raw('SET session_replication_role = \'origin\'');
}); });
} }
+12 -8
View File
@@ -46,6 +46,10 @@ const { hasColumnCached } = require('../utils/schemaCache');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// NOTE: this transition table is currently never consulted — quote status
// changes are not validated against it anywhere in the codebase. Kept as the
// documented intent; wiring it up is tracked separately.
// eslint-disable-next-line no-unused-vars -- unwired state machine, see note above
const VALID_QUOTE_TRANSITIONS = { const VALID_QUOTE_TRANSITIONS = {
draft: new Set(['sent', 'declined']), draft: new Set(['sent', 'declined']),
sent: new Set(['draft', 'accepted', 'declined', 'expired']), sent: new Set(['draft', 'accepted', 'declined', 'expired']),
@@ -620,7 +624,7 @@ async function createQuote(payload, adminId) {
// Pass `trx` so the audit insert rides the transaction's connection — // Pass `trx` so the audit insert rides the transaction's connection —
// the global db here deadlocks the single-connection SQLite pool. // the global db here deadlocks the single-connection SQLite pool.
await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`, trx); await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`, trx);
} catch (_) {} } catch (_) { /* non-fatal */ }
logger.info('Quote created', { adminId, quoteId, quoteNumber }); logger.info('Quote created', { adminId, quoteId, quoteNumber });
return quoteId; return quoteId;
@@ -760,7 +764,7 @@ async function updateQuote(id, payload, adminId) {
try { try {
await logActivity('quote_updated', { quoteId: id }, null, `admin:${adminId}`); await logActivity('quote_updated', { quoteId: id }, null, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
}); });
} }
@@ -1033,7 +1037,7 @@ async function sendQuote(id, adminId) {
// Do NOT log the raw bearer token — it grants quote actions and the // Do NOT log the raw bearer token — it grants quote actions and the
// activity log is readable later (GHSA-prch). The quoteId is the audit key. // activity log is readable later (GHSA-prch). The quoteId is the audit key.
await logActivity('quote_sent', { quoteId: id }, null, `admin:${adminId}`); await logActivity('quote_sent', { quoteId: id }, null, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
// Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when // Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when
// the workflows flag is off). The accepted/declined emits already exist; this // the workflows flag is off). The accepted/declined emits already exist; this
@@ -1259,7 +1263,7 @@ async function recordResponse({ token, action, ip, tosAccepted }) {
try { try {
// Raw bearer token must not reach the activity log (GHSA-prch). // Raw bearer token must not reach the activity log (GHSA-prch).
await logActivity(`quote_${newStatus}`, { quoteId: quote.id }, null, 'customer:public'); await logActivity(`quote_${newStatus}`, { quoteId: quote.id }, null, 'customer:public');
} catch (_) {} } catch (_) { /* non-fatal */ }
// Defer the workflow emit until the 15-min toggle window locks — so accepting // Defer the workflow emit until the 15-min toggle window locks — so accepting
// (then converting) can't strip the customer's ability to decline. The // (then converting) can't strip the customer's ability to decline. The
@@ -1317,7 +1321,7 @@ async function adminAcceptQuote(id, adminId) {
try { try {
await logActivity('quote_accepted_by_admin', { quoteId: id }, null, `admin:${adminId}`); await logActivity('quote_accepted_by_admin', { quoteId: id }, null, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
// ---- customer confirmation email ------------------------------- // ---- customer confirmation email -------------------------------
// Renders the quote PDF + queues a "quote accepted — on your // Renders the quote PDF + queues a "quote accepted — on your
@@ -1428,7 +1432,7 @@ async function adminDeclineQuote(id, adminId, reason = null) {
try { try {
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`); await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
// Admin decline locks the window immediately (response_locked_at = now), so // Admin decline locks the window immediately (response_locked_at = now), so
// this emits straight away (and stamps emitted) rather than deferring. // this emits straight away (and stamps emitted) rather than deferring.
@@ -1569,7 +1573,7 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) {
try { try {
await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: result.installmentsCreated }, await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: result.installmentsCreated },
null, `admin:${adminId}`); null, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: result.installmentsCreated }); logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: result.installmentsCreated });
return result; return result;
@@ -1750,7 +1754,7 @@ async function convertToEvent(quoteId, adminId, options = {}) {
// (prepare_event runs this unattended from the booking flow). // (prepare_event runs this unattended from the booking flow).
try { try {
await logActivity('quote_converted', { quoteId: quote.id, eventId: result.eventId }, result.eventId, `admin:${adminId}`); await logActivity('quote_converted', { quoteId: quote.id, eventId: result.eventId }, result.eventId, `admin:${adminId}`);
} catch (_) {} } catch (_) { /* non-fatal */ }
logger.info('Quote converted to event', { adminId, quoteId: quote.id, eventId: result.eventId }); logger.info('Quote converted to event', { adminId, quoteId: quote.id, eventId: result.eventId });
return result; return result;
+1 -1
View File
@@ -96,7 +96,7 @@ function clearSettingsCache() {
*/ */
function isAuthenticated(req) { function isAuthenticated(req) {
try { try {
const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/); const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^/]+)/);
const slug = slugMatch ? slugMatch[1] : req.requestedSlug; const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
+5 -4
View File
@@ -771,7 +771,7 @@ class RestoreService {
* Download backup from S3 * Download backup from S3
*/ */
async downloadFromS3(s3Url, manifest, options) { async downloadFromS3(s3Url, manifest, options) {
const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/); const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/);
if (!s3PathMatch) { if (!s3PathMatch) {
throw new Error('Invalid S3 URL format'); throw new Error('Invalid S3 URL format');
} }
@@ -930,7 +930,7 @@ class RestoreService {
/** /**
* Perform database-only restore * Perform database-only restore
*/ */
async performDatabaseRestore(backupPath, manifest, options) { async performDatabaseRestore(backupPath, manifest, _options) {
this.updateProgress('Restoring database...'); this.updateProgress('Restoring database...');
const dbBackupFile = manifest.database.backup_file; const dbBackupFile = manifest.database.backup_file;
@@ -1524,7 +1524,8 @@ END $$;`
try { try {
// Read backup manifest // Read backup manifest
const manifestPath = path.join(preRestoreBackupPath, 'backup-manifest.json'); const manifestPath = path.join(preRestoreBackupPath, 'backup-manifest.json');
const backupManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); // Parsed for its side effect: throws if the manifest is missing/corrupt.
JSON.parse(await fs.readFile(manifestPath, 'utf8'));
// Restore database if backed up // Restore database if backed up
const dbBackupPath = path.join(preRestoreBackupPath, 'database.sql.gz'); const dbBackupPath = path.join(preRestoreBackupPath, 'database.sql.gz');
@@ -1622,7 +1623,7 @@ END $$;`
* Download file from S3 * Download file from S3
*/ */
async downloadFileFromS3(s3Url, localPath, s3Config) { async downloadFileFromS3(s3Url, localPath, s3Config) {
const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/); const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/);
if (!s3PathMatch) { if (!s3PathMatch) {
throw new Error('Invalid S3 URL format'); throw new Error('Invalid S3 URL format');
} }
@@ -1,8 +1,6 @@
const crypto = require('crypto'); const crypto = require('crypto');
const sharp = require('sharp'); const sharp = require('sharp');
const { db } = require('../database/db'); const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@@ -51,7 +51,7 @@ describe('S3StorageAdapter', () => {
}); });
it('should configure for MinIO with path style', () => { it('should configure for MinIO with path style', () => {
const minioStorage = new S3StorageAdapter({ new S3StorageAdapter({
bucket: 'test-bucket', bucket: 'test-bucket',
endpoint: 'http://localhost:9000', endpoint: 'http://localhost:9000',
forcePathStyle: true, forcePathStyle: true,
+14 -14
View File
@@ -51,7 +51,7 @@ async function fetchPending(limit) {
const excludeIds = Array.from(inFlight); const excludeIds = Array.from(inFlight);
let q = db('webhook_deliveries') let q = db('webhook_deliveries')
.where('status', 'pending') .where('status', 'pending')
.where('next_retry_at', '<=', new Date()) .where('next_retry_at', '<=', new Date().toISOString())
.orderBy('next_retry_at', 'asc') .orderBy('next_retry_at', 'asc')
.limit(limit); .limit(limit);
if (excludeIds.length > 0) { if (excludeIds.length > 0) {
@@ -71,7 +71,7 @@ async function deliverOne(row) {
.update({ .update({
status: 'failed', status: 'failed',
last_error: 'webhook subscription no longer exists', last_error: 'webhook subscription no longer exists',
completed_at: new Date(), completed_at: new Date().toISOString(),
attempt_count: row.attempt_count + 1, attempt_count: row.attempt_count + 1,
}); });
return; return;
@@ -85,7 +85,7 @@ async function deliverOne(row) {
.update({ .update({
status: 'failed', status: 'failed',
last_error: 'webhook is disabled', last_error: 'webhook is disabled',
completed_at: new Date(), completed_at: new Date().toISOString(),
attempt_count: row.attempt_count + 1, attempt_count: row.attempt_count + 1,
}); });
return; return;
@@ -172,10 +172,10 @@ async function deliverOne(row) {
response_body: truncate(stringifyBody(response.data), RESPONSE_TRUNCATE_BYTES), response_body: truncate(stringifyBody(response.data), RESPONSE_TRUNCATE_BYTES),
latency_ms: latency, latency_ms: latency,
attempt_count: newAttempt, attempt_count: newAttempt,
completed_at: new Date(), completed_at: new Date().toISOString(),
next_retry_at: null, next_retry_at: null,
}); });
await db('webhooks').where({ id: webhook.id }).update({ last_success_at: new Date() }); await db('webhooks').where({ id: webhook.id }).update({ last_success_at: new Date().toISOString() });
return; return;
} }
@@ -194,10 +194,10 @@ async function deliverOne(row) {
last_error: errorMsg, last_error: errorMsg,
latency_ms: latency, latency_ms: latency,
attempt_count: newAttempt, attempt_count: newAttempt,
completed_at: new Date(), completed_at: new Date().toISOString(),
next_retry_at: null, next_retry_at: null,
}); });
await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() }); await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date().toISOString() });
return; return;
} }
@@ -211,9 +211,9 @@ async function deliverOne(row) {
last_error: errorMsg, last_error: errorMsg,
latency_ms: latency, latency_ms: latency,
attempt_count: newAttempt, attempt_count: newAttempt,
next_retry_at: new Date(Date.now() + backoff), next_retry_at: new Date(Date.now() + backoff).toISOString(),
}); });
await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() }); await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date().toISOString() });
} }
async function markFailedFinal(row, reason) { async function markFailedFinal(row, reason) {
@@ -223,10 +223,10 @@ async function markFailedFinal(row, reason) {
status: 'failed', status: 'failed',
last_error: reason, last_error: reason,
attempt_count: row.attempt_count + 1, attempt_count: row.attempt_count + 1,
completed_at: new Date(), completed_at: new Date().toISOString(),
next_retry_at: null, next_retry_at: null,
}); });
await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date() }); await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date().toISOString() });
} }
// Schedule the normal retry/backoff for a transient failure that must not // Schedule the normal retry/backoff for a transient failure that must not
@@ -242,7 +242,7 @@ async function scheduleTransientRetry(row, webhook, errorMsg) {
status: 'failed', status: 'failed',
last_error: errorMsg, last_error: errorMsg,
attempt_count: newAttempt, attempt_count: newAttempt,
completed_at: new Date(), completed_at: new Date().toISOString(),
next_retry_at: null, next_retry_at: null,
}); });
} else { } else {
@@ -253,10 +253,10 @@ async function scheduleTransientRetry(row, webhook, errorMsg) {
status: 'pending', status: 'pending',
last_error: errorMsg, last_error: errorMsg,
attempt_count: newAttempt, attempt_count: newAttempt,
next_retry_at: new Date(Date.now() + backoff), next_retry_at: new Date(Date.now() + backoff).toISOString(),
}); });
} }
await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() }); await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date().toISOString() });
} }
function stringifyBody(data) { function stringifyBody(data) {
+4 -4
View File
@@ -190,8 +190,8 @@ async function fire(eventType, data) {
payload: JSON.stringify(envelope), payload: JSON.stringify(envelope),
attempt_count: 0, attempt_count: 0,
status: 'pending', status: 'pending',
next_retry_at: now, next_retry_at: now.toISOString(),
created_at: now, created_at: now.toISOString(),
}); });
} }
@@ -232,8 +232,8 @@ async function enqueueForWebhook(webhookId, eventType, data) {
payload: JSON.stringify(envelope), payload: JSON.stringify(envelope),
attempt_count: 0, attempt_count: 0,
status: 'pending', status: 'pending',
next_retry_at: now, next_retry_at: now.toISOString(),
created_at: now, created_at: now.toISOString(),
}); });
return { enqueued: true, webhookId: w.id, deliveryId: deliveryUuid }; return { enqueued: true, webhookId: w.id, deliveryId: deliveryUuid };
} catch (err) { } catch (err) {
+1 -1
View File
@@ -64,7 +64,7 @@ process.on('uncaughtException', (error) => {
process.exit(1); process.exit(1);
}); });
process.on('unhandledRejection', (reason, promise) => { process.on('unhandledRejection', (reason) => {
logger.error('Unhandled rejection in worker manager:', reason); logger.error('Unhandled rejection in worker manager:', reason);
}); });
@@ -28,9 +28,9 @@ describe('sanitizeFilename — accented characters transliterate via NFD (#607)'
const legacyBroken = (s) => const legacyBroken = (s) =>
String(s).trim() String(s).trim()
.replace(/\s+/g, '_') .replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9_\-\.]/g, '') .replace(/[^a-zA-Z0-9_\-.]/g, '')
.replace(/[_\-]{2,}/g, '_') .replace(/[_-]{2,}/g, '_')
.replace(/^[_\-]+|[_\-]+$/g, ''); .replace(/^[_-]+|[_-]+$/g, '');
it.each([ it.each([
['Ägypten', 'Agypten'], ['Ägypten', 'Agypten'],
@@ -138,8 +138,8 @@ describe('sanitizeForContentDisposition — header-safe ASCII fallback', () => {
describe('buildContentDisposition — RFC 6266 / RFC 5987 dual form', () => { describe('buildContentDisposition — RFC 6266 / RFC 5987 dual form', () => {
it('emits both filename="..." (ASCII) and filename*=UTF-8\'\'... (unicode) for accented names', () => { it('emits both filename="..." (ASCII) and filename*=UTF-8\'\'... (unicode) for accented names', () => {
const header = buildContentDisposition('Ägypten.jpg'); const header = buildContentDisposition('Ägypten.jpg');
expect(header).toContain("filename=\"gypten.jpg\""); expect(header).toContain('filename="gypten.jpg"');
expect(header).toContain("filename*=UTF-8''%C3%84gypten.jpg"); expect(header).toContain('filename*=UTF-8\'\'%C3%84gypten.jpg');
expect(header.startsWith('attachment;')).toBe(true); expect(header.startsWith('attachment;')).toBe(true);
}); });
+2
View File
@@ -58,6 +58,7 @@ function sanitizeCss(css) {
sanitized = sanitized.replace(pattern, ''); sanitized = sanitized.replace(pattern, '');
}); });
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, ''); sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
const MAX_LENGTH = 100 * 1024; const MAX_LENGTH = 100 * 1024;
@@ -112,6 +113,7 @@ function sanitizeCSS(cssContent) {
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, ''); sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
// Remove control characters // Remove control characters
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, ''); sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
// Remove any remaining script-like content // Remove any remaining script-like content
+2 -1
View File
@@ -89,6 +89,7 @@ function sanitizeComment(text) {
text = text.replace(/[\u200B-\u200D\uFEFF]/g, ''); text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
// Remove control characters // Remove control characters
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from feedback text
text = text.replace(/[\x00-\x1F\x7F]/g, ''); text = text.replace(/[\x00-\x1F\x7F]/g, '');
// Limit consecutive special characters // Limit consecutive special characters
@@ -241,7 +242,7 @@ const validateWordFilter = [
.withMessage('Word must be between 2 and 100 characters'), .withMessage('Word must be between 2 and 100 characters'),
body('severity') body('severity')
.optional() .optional()
.isIn(['mild', 'moderate', 'severe']) .isIn(['low', 'moderate', 'high', 'block'])
.withMessage('Invalid severity level') .withMessage('Invalid severity level')
]; ];
+2 -1
View File
@@ -37,8 +37,9 @@ function safePathJoin(basePath, userPath) {
function isPathSafe(filePath) { function isPathSafe(filePath) {
// Check for common path traversal patterns // Check for common path traversal patterns
const dangerousPatterns = [ const dangerousPatterns = [
/\.\.[\/\\]/, // ../ or ..\ /\.\.[/\\]/, // ../ or ..\
/^[A-Za-z]:/, // Windows drive letters /^[A-Za-z]:/, // Windows drive letters
// eslint-disable-next-line no-control-regex -- intentional: detects control chars in paths
/[\x00-\x1f]/ // Control characters /[\x00-\x1f]/ // Control characters
]; ];
+3 -3
View File
@@ -27,13 +27,13 @@ function sanitizeFilename(str, maxLength = 50) {
sanitized = sanitized.replace(/\s+/g, '_'); sanitized = sanitized.replace(/\s+/g, '_');
// Remove special characters except hyphens, underscores, and dots // Remove special characters except hyphens, underscores, and dots
sanitized = sanitized.replace(/[^a-zA-Z0-9_\-\.]/g, ''); sanitized = sanitized.replace(/[^a-zA-Z0-9_\-.]/g, '');
// Remove multiple consecutive underscores or hyphens // Remove multiple consecutive underscores or hyphens
sanitized = sanitized.replace(/[_\-]{2,}/g, '_'); sanitized = sanitized.replace(/[_-]{2,}/g, '_');
// Remove leading/trailing underscores or hyphens // Remove leading/trailing underscores or hyphens
sanitized = sanitized.replace(/^[_\-]+|[_\-]+$/g, ''); sanitized = sanitized.replace(/^[_-]+|[_-]+$/g, '');
// Limit length // Limit length
if (sanitized.length > maxLength) { if (sanitized.length > maxLength) {
+1 -1
View File
@@ -93,7 +93,7 @@ function validatePasswordStrength(password) {
result.score += 1; result.score += 1;
} }
if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) { if (!/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.test(password)) {
result.messages.push('Password must contain special characters'); result.messages.push('Password must contain special characters');
} else { } else {
result.score += 1; result.score += 1;
+2 -2
View File
@@ -66,7 +66,7 @@ function validatePassword(password, options = {}) {
} }
// Check special character requirement // Check special character requirement
if (config.requireSpecialChars && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { if (config.requireSpecialChars && !/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
errors.push('Password must contain at least one special character'); errors.push('Password must contain at least one special character');
} }
@@ -230,7 +230,7 @@ async function validatePasswordInContext(password, context, userData = {}) {
// Only allow date-format passwords when complexity is 'simple' // Only allow date-format passwords when complexity is 'simple'
if (complexityLevel === 'simple') { if (complexityLevel === 'simple') {
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/; const datePattern = /^\d{1,2}[./-]\d{1,2}[./-]\d{4}$/;
if (datePattern.test(password)) { if (datePattern.test(password)) {
return { return {
valid: true, valid: true,
+1 -1
View File
@@ -25,7 +25,7 @@ function decodeEntities(s) {
.replace(/&lt;/g, '<') .replace(/&lt;/g, '<')
.replace(/&gt;/g, '>') .replace(/&gt;/g, '>')
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&#0*39;|&#x0*27;|&apos;/gi, "'") .replace(/&#0*39;|&#x0*27;|&apos;/gi, '\'')
.replace(/&amp;/g, '&'); .replace(/&amp;/g, '&');
} }
+21 -6
View File
@@ -3,19 +3,34 @@ import { typescriptPlugin } from "./scripts/i18nextExtractionHelper";
export default defineConfig({ export default defineConfig({
locales: ['en', 'de', 'nl', 'pt', 'ru', 'fr'], // Only the locales that are kept at full key parity are managed by the extractor.
// nl/pt/ru/fr/sl/es are deliberately partial and rely on `fallbackLng: 'en'`; letting
// the extractor own them would fill each file with ~2700 empty-string values, and
// i18next's default `returnEmptyString: true` renders those as blank UI instead of
// falling back to English.
locales: ['en', 'de'],
extract: { extract: {
input: ['src/**/*.{ts,tsx,js,jsx}', '!src/**/*.{test,spec,d}.{ts,tsx}'], input: ['src/**/*.{ts,tsx,js,jsx}'],
// `glob` (used by i18next-cli) ignores `!`-prefixed entries inside `input`,
// so exclusions have to live here or they are silently no-ops.
ignore: [
'src/**/*.{test,spec}.{ts,tsx,js,jsx}',
'src/**/__tests__/**',
'src/**/*.d.ts',
],
output: 'src/i18n/locales/{{language}}.json', output: 'src/i18n/locales/{{language}}.json',
defaultNS: false, defaultNS: false,
primaryLanguage: 'en', primaryLanguage: 'en',
removeUnusedKeys: true, // Pruning is unsafe in this codebase: a large share of keys is never visible to the
// AST extractor because it is built at runtime — t(`admin.activities.${type}`),
// Dynamic keys to preserve (e.g.: t(`errors.${code}`)) // t(`admin.notificationMessages.${type}`), t(`projects.status.${status}`) — or held in
preservePatterns: [], // constant tables the extractor does not resolve (AdminSidebar `nameKey`,
// CrmDevelopmentPage `titleKey`/`descKey`, the crmSettings toggle map). Enabling it
// deletes ~355 live keys per locale, so removal stays a manual decision.
removeUnusedKeys: false,
preserveContextVariants: true, preserveContextVariants: true,
@@ -72,7 +72,13 @@ interface AdminLayoutInnerProps {
const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => { const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => {
return ( return (
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 flex overflow-hidden"> // Explicit text colour on the admin shell: the branding theme sets
// --color-text on <html> app-wide (GlobalThemeProvider applies it on every
// non-gallery page, by design), so any admin component that forgot its own
// colour class inherited it through `body { color: var(--color-text) }` and
// rendered near-invisible on a dark-toned theme. Components with an
// explicit class or `text-theme` still win over this.
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 flex overflow-hidden">
{/* Mandatory Password Change Modal */} {/* Mandatory Password Change Modal */}
{mustChangePassword && <MandatoryPasswordChangeModal />} {mustChangePassword && <MandatoryPasswordChangeModal />}
@@ -64,7 +64,10 @@ interface NavItem {
// Feature-gated (only render when the corresponding feature flag is on): // Feature-gated (only render when the corresponding feature flag is on):
// Analytics → flags.analytics // Analytics → flags.analytics
// Users → flags.userManagement // Users → flags.userManagement
const navigation: NavItem[] = [ // Exported so Settings → Features can render its "Sidebar preview" against
// the same declaration the real sidebar uses (it used to keep a second,
// hand-maintained array that only knew about 2 of the feature gates).
export const adminNavigation: NavItem[] = [
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false }, { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' }, { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
@@ -156,7 +159,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl; const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl;
const brandAlt = publicSettings?.branding_company_name?.trim() || t('admin.title'); const brandAlt = publicSettings?.branding_company_name?.trim() || t('admin.title');
const filteredNavigation = navigation.filter((item) => { const filteredNavigation = adminNavigation.filter((item) => {
if (item.permission && !hasPermission(item.permission as string)) return false; if (item.permission && !hasPermission(item.permission as string)) return false;
if (item.featureFlag && !flags[item.featureFlag]) return false; if (item.featureFlag && !flags[item.featureFlag]) return false;
// featureFlagsAny: entry is hidden when none of the listed // featureFlagsAny: entry is hidden when none of the listed
@@ -27,7 +27,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '../common'; import { Button } from '../common';
import { customerAdminService } from '../../services/customerAdmin.service'; import { customerAdminService } from '../../services/customerAdmin.service';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import type { Event as AdminEvent } from '../../services/events.service'; import type { Event as AdminEvent } from '../../types';
interface SelectedEvent { interface SelectedEvent {
id: number; id: number;
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Archive, AlertTriangle, X } from 'lucide-react'; import { Archive, AlertTriangle, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../common'; import { Button, Card } from '../common';
import type { Event } from '../../types'; import type { Event } from '../../types';
@@ -18,18 +19,25 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
selectedEvents, selectedEvents,
isLoading = false, isLoading = false,
}) => { }) => {
const { t } = useTranslation();
if (!isOpen) return null; if (!isOpen) return null;
const count = selectedEvents.length;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Confirm Bulk Archive</h2> <h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.bulkArchive.title', 'Confirm Bulk Archive')}
</h2>
<button <button
onClick={onClose} onClick={onClose}
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors" className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
disabled={isLoading} disabled={isLoading}
aria-label={t('common.close', 'Close')}
> >
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" /> <X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
</button> </button>
@@ -40,21 +48,22 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" /> <AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-neutral-700"> <div className="text-sm text-neutral-700">
<p className="mb-2"> <p className="mb-2">
You are about to archive <strong>{selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}</strong>. {t('events.bulkArchive.intro', 'You are about to archive {{count}} events. This action will:', { count })}
This action will:
</p> </p>
<ul className="list-disc list-inside space-y-1 text-neutral-600"> <ul className="list-disc list-inside space-y-1 text-neutral-600">
<li>Create a ZIP archive of all photos for each event</li> <li>{t('events.bulkArchive.effectZip', 'Create a ZIP archive of all photos for each event')}</li>
<li>Make the galleries inaccessible to guests</li> <li>{t('events.bulkArchive.effectInaccessible', 'Make the galleries inaccessible to guests')}</li>
<li>Remove the events from active listings</li> <li>{t('events.bulkArchive.effectDelisted', 'Remove the events from active listings')}</li>
<li>Free up storage space by compressing photos</li> <li>{t('events.bulkArchive.effectStorage', 'Free up storage space by compressing photos')}</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="border border-neutral-200 rounded-lg max-h-48 overflow-y-auto"> <div className="border border-neutral-200 rounded-lg max-h-48 overflow-y-auto">
<div className="p-3"> <div className="p-3">
<h3 className="text-sm font-medium text-neutral-700 mb-2">Events to be archived:</h3> <h3 className="text-sm font-medium text-neutral-700 mb-2">
{t('events.bulkArchive.listHeading', 'Events to be archived:')}
</h3>
<ul className="space-y-1"> <ul className="space-y-1">
{selectedEvents.map((event) => ( {selectedEvents.map((event) => (
<li key={event.id} className="text-sm text-neutral-600"> <li key={event.id} className="text-sm text-neutral-600">
@@ -72,7 +81,7 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
onClick={onClose} onClick={onClose}
disabled={isLoading} disabled={isLoading}
> >
Cancel {t('common.cancel', 'Cancel')}
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
@@ -80,7 +89,7 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
isLoading={isLoading} isLoading={isLoading}
leftIcon={<Archive className="w-4 h-4" />} leftIcon={<Archive className="w-4 h-4" />}
> >
Archive {selectedEvents.length} Event{selectedEvents.length > 1 ? 's' : ''} {t('events.bulkArchive.submit', 'Archive {{count}} events', { count })}
</Button> </Button>
</div> </div>
</div> </div>
+69 -63
View File
@@ -1,4 +1,5 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useEditor, EditorContent, type Editor } from '@tiptap/react'; import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
@@ -52,6 +53,7 @@ interface CMSEditorProps {
type ViewMode = 'edit' | 'preview' | 'split'; type ViewMode = 'edit' | 'preview' | 'split';
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => { export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
const { t } = useTranslation();
const [linkUrl, setLinkUrl] = useState(''); const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false); const [showLinkDialog, setShowLinkDialog] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('edit'); const [viewMode, setViewMode] = useState<ViewMode>('edit');
@@ -91,7 +93,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
}, },
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: 'Start typing your content here...', placeholder: t('cms.editor.placeholder', 'Start typing your content here...'),
}), }),
CharacterCount.configure({ CharacterCount.configure({
limit: null, limit: null,
@@ -193,15 +195,15 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => setViewMode('edit')} className={viewModeChipClass('edit')}> <button onClick={() => setViewMode('edit')} className={viewModeChipClass('edit')}>
<Edit3 className="w-4 h-4 inline-block mr-1" /> <Edit3 className="w-4 h-4 inline-block mr-1" />
Edit {t('cms.editor.viewEdit', 'Edit')}
</button> </button>
<button onClick={() => setViewMode('preview')} className={viewModeChipClass('preview')}> <button onClick={() => setViewMode('preview')} className={viewModeChipClass('preview')}>
<Eye className="w-4 h-4 inline-block mr-1" /> <Eye className="w-4 h-4 inline-block mr-1" />
Preview {t('cms.editor.viewPreview', 'Preview')}
</button> </button>
<button onClick={() => setViewMode('split')} className={viewModeChipClass('split')}> <button onClick={() => setViewMode('split')} className={viewModeChipClass('split')}>
<Columns className="w-4 h-4 inline-block mr-1" /> <Columns className="w-4 h-4 inline-block mr-1" />
Split {t('cms.editor.viewSplit', 'Split')}
</button> </button>
</div> </div>
@@ -213,20 +215,22 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
isLoading={isSaving} isLoading={isSaving}
leftIcon={<Save className="w-4 h-4" />} leftIcon={<Save className="w-4 h-4" />}
> >
Save {t('cms.editor.save', 'Save')}
</Button> </Button>
)} )}
<MenuButton <MenuButton
onClick={() => setShowHelp(true)} onClick={() => setShowHelp(true)}
title="Help & Keyboard Shortcuts" title={t('cms.editor.help', 'Help & Keyboard Shortcuts')}
> >
<HelpCircle className="w-4 h-4" /> <HelpCircle className="w-4 h-4" />
</MenuButton> </MenuButton>
<MenuButton <MenuButton
onClick={toggleFullscreen} onClick={toggleFullscreen}
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"} title={isFullscreen
? t('cms.editor.exitFullscreen', 'Exit Fullscreen')
: t('cms.editor.enterFullscreen', 'Enter Fullscreen')}
active={isFullscreen} active={isFullscreen}
> >
<Maximize2 className="w-4 h-4" /> <Maximize2 className="w-4 h-4" />
@@ -240,7 +244,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })} active={editor.isActive('heading', { level: 1 })}
title="Heading 1 (Ctrl+Alt+1)" title={t('cms.editor.tool.heading1', "Heading 1 (Ctrl+Alt+1)")}
> >
<Heading1 className="w-4 h-4" /> <Heading1 className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -248,7 +252,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })} active={editor.isActive('heading', { level: 2 })}
title="Heading 2 (Ctrl+Alt+2)" title={t('cms.editor.tool.heading2', "Heading 2 (Ctrl+Alt+2)")}
> >
<Heading2 className="w-4 h-4" /> <Heading2 className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -256,7 +260,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })} active={editor.isActive('heading', { level: 3 })}
title="Heading 3 (Ctrl+Alt+3)" title={t('cms.editor.tool.heading3', "Heading 3 (Ctrl+Alt+3)")}
> >
<Heading3 className="w-4 h-4" /> <Heading3 className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -264,7 +268,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
active={editor.isActive('heading', { level: 4 })} active={editor.isActive('heading', { level: 4 })}
title="Heading 4 (Ctrl+Alt+4)" title={t('cms.editor.tool.heading4', "Heading 4 (Ctrl+Alt+4)")}
> >
<Heading4 className="w-4 h-4" /> <Heading4 className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -272,7 +276,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
active={editor.isActive('heading', { level: 5 })} active={editor.isActive('heading', { level: 5 })}
title="Heading 5 (Ctrl+Alt+5)" title={t('cms.editor.tool.heading5', "Heading 5 (Ctrl+Alt+5)")}
> >
<Heading5 className="w-4 h-4" /> <Heading5 className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -280,7 +284,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
active={editor.isActive('heading', { level: 6 })} active={editor.isActive('heading', { level: 6 })}
title="Heading 6 (Ctrl+Alt+6)" title={t('cms.editor.tool.heading6', "Heading 6 (Ctrl+Alt+6)")}
> >
<Heading6 className="w-4 h-4" /> <Heading6 className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -290,7 +294,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')} active={editor.isActive('bold')}
title="Bold (Ctrl+B)" title={t('cms.editor.tool.bold', "Bold (Ctrl+B)")}
> >
<Bold className="w-4 h-4" /> <Bold className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -298,7 +302,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')} active={editor.isActive('italic')}
title="Italic (Ctrl+I)" title={t('cms.editor.tool.italic', "Italic (Ctrl+I)")}
> >
<Italic className="w-4 h-4" /> <Italic className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -306,7 +310,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleCode().run()} onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')} active={editor.isActive('code')}
title="Inline Code (Ctrl+E)" title={t('cms.editor.tool.inlineCode', "Inline Code (Ctrl+E)")}
> >
<Code className="w-4 h-4" /> <Code className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -314,7 +318,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()} onClick={() => editor.chain().focus().toggleCodeBlock().run()}
active={editor.isActive('codeBlock')} active={editor.isActive('codeBlock')}
title="Code Block (Ctrl+Alt+C)" title={t('cms.editor.tool.codeBlock', "Code Block (Ctrl+Alt+C)")}
> >
<Code2 className="w-4 h-4" /> <Code2 className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -324,7 +328,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()} onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')} active={editor.isActive('bulletList')}
title="Bullet List (Ctrl+Shift+8)" title={t('cms.editor.tool.bulletList', "Bullet List (Ctrl+Shift+8)")}
> >
<List className="w-4 h-4" /> <List className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -332,7 +336,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()} onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')} active={editor.isActive('orderedList')}
title="Numbered List (Ctrl+Shift+9)" title={t('cms.editor.tool.numberedList', "Numbered List (Ctrl+Shift+9)")}
> >
<ListOrdered className="w-4 h-4" /> <ListOrdered className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -340,7 +344,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')} active={editor.isActive('blockquote')}
title="Blockquote (Ctrl+Shift+B)" title={t('cms.editor.tool.blockquote', "Blockquote (Ctrl+Shift+B)")}
> >
<Quote className="w-4 h-4" /> <Quote className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -350,14 +354,14 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => setShowLinkDialog(true)} onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')} active={editor.isActive('link')}
title="Add Link (Ctrl+K)" title={t('cms.editor.tool.addLink', "Add Link (Ctrl+K)")}
> >
<LinkIcon className="w-4 h-4" /> <LinkIcon className="w-4 h-4" />
</MenuButton> </MenuButton>
<MenuButton <MenuButton
onClick={() => editor.chain().focus().setHorizontalRule().run()} onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule" title={t('cms.editor.tool.horizontalRule', "Horizontal Rule")}
> >
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -367,7 +371,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()} onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editor.isActive({ textAlign: 'left' })} active={editor.isActive({ textAlign: 'left' })}
title="Align Left" title={t('cms.editor.tool.alignLeft', "Align Left")}
> >
<AlignLeft className="w-4 h-4" /> <AlignLeft className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -375,7 +379,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().setTextAlign('center').run()} onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editor.isActive({ textAlign: 'center' })} active={editor.isActive({ textAlign: 'center' })}
title="Align Center" title={t('cms.editor.tool.alignCenter', "Align Center")}
> >
<AlignCenter className="w-4 h-4" /> <AlignCenter className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -383,7 +387,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().setTextAlign('right').run()} onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editor.isActive({ textAlign: 'right' })} active={editor.isActive({ textAlign: 'right' })}
title="Align Right" title={t('cms.editor.tool.alignRight', "Align Right")}
> >
<AlignRight className="w-4 h-4" /> <AlignRight className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -391,7 +395,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().setTextAlign('justify').run()} onClick={() => editor.chain().focus().setTextAlign('justify').run()}
active={editor.isActive({ textAlign: 'justify' })} active={editor.isActive({ textAlign: 'justify' })}
title="Justify" title={t('cms.editor.tool.justify', "Justify")}
> >
<AlignJustify className="w-4 h-4" /> <AlignJustify className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -400,7 +404,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()} onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Clear Formatting" title={t('cms.editor.tool.clearFormatting', "Clear Formatting")}
> >
<RemoveFormatting className="w-4 h-4" /> <RemoveFormatting className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -410,7 +414,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().undo().run()} onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()} disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)" title={t('cms.editor.tool.undo', "Undo (Ctrl+Z)")}
> >
<Undo className="w-4 h-4" /> <Undo className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -418,7 +422,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<MenuButton <MenuButton
onClick={() => editor.chain().focus().redo().run()} onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()} disabled={!editor.can().redo()}
title="Redo (Ctrl+Y)" title={t('cms.editor.tool.redo', "Redo (Ctrl+Y)")}
> >
<Redo className="w-4 h-4" /> <Redo className="w-4 h-4" />
</MenuButton> </MenuButton>
@@ -434,16 +438,16 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
value={linkUrl} value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)} onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()} onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder="Enter URL..." placeholder={t('cms.editor.linkUrlPlaceholder', 'Enter URL...')}
className="flex-1 px-3 py-1 border border-accent-dark/30 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500" className="flex-1 px-3 py-1 border border-accent-dark/30 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus autoFocus
/> />
<Button size="sm" onClick={addLink}>Add Link</Button> <Button size="sm" onClick={addLink}>{t('cms.editor.addLink', 'Add Link')}</Button>
<Button size="sm" variant="outline" onClick={() => { <Button size="sm" variant="outline" onClick={() => {
setShowLinkDialog(false); setShowLinkDialog(false);
setLinkUrl(''); setLinkUrl('');
}}> }}>
Cancel {t('common.cancel', 'Cancel')}
</Button> </Button>
</div> </div>
)} )}
@@ -475,11 +479,11 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
{/* Status Bar */} {/* Status Bar */}
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 dark:bg-neutral-800 border-t border-neutral-200 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-300"> <div className="flex items-center justify-between px-4 py-2 bg-neutral-50 dark:bg-neutral-800 border-t border-neutral-200 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-300">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span>{wordCount} words</span> <span>{t('cms.editor.wordCount', '{{count}} words', { count: wordCount })}</span>
<span>{charCount} characters</span> <span>{t('cms.editor.charCount', '{{count}} characters', { count: charCount })}</span>
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
Press Shift+Enter for line break, Enter for new paragraph {t('cms.editor.lineBreakHint', 'Press Shift+Enter for line break, Enter for new paragraph')}
</div> </div>
</div> </div>
</div> </div>
@@ -489,68 +493,70 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto"> <div className="bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
<div className="p-6"> <div className="p-6">
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2> <h2 className="text-xl font-semibold mb-4">
{t('cms.editor.helpTitle', 'Editor Help & Keyboard Shortcuts')}
</h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="font-semibold mb-2">Text Formatting</h3> <h3 className="font-semibold mb-2">{t('cms.editor.helpFormatting', 'Text Formatting')}</h3>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+B</kbd> - Bold</div> <div><kbd>Ctrl+B</kbd> - {t('cms.editor.tool.boldShort', 'Bold')}</div>
<div><kbd>Ctrl+I</kbd> - Italic</div> <div><kbd>Ctrl+I</kbd> - {t('cms.editor.tool.italicShort', 'Italic')}</div>
<div><kbd>Ctrl+E</kbd> - Inline code</div> <div><kbd>Ctrl+E</kbd> - {t('cms.editor.tool.inlineCodeShort', 'Inline code')}</div>
<div><kbd>Ctrl+K</kbd> - Add link</div> <div><kbd>Ctrl+K</kbd> - {t('cms.editor.tool.addLinkShort', 'Add link')}</div>
</div> </div>
</div> </div>
<div> <div>
<h3 className="font-semibold mb-2">Headings</h3> <h3 className="font-semibold mb-2">{t('cms.editor.helpHeadings', 'Headings')}</h3>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Alt+1</kbd> - Heading 1</div> <div><kbd>Ctrl+Alt+1</kbd> - {t('cms.editor.tool.heading1Short', 'Heading 1')}</div>
<div><kbd>Ctrl+Alt+2</kbd> - Heading 2</div> <div><kbd>Ctrl+Alt+2</kbd> - {t('cms.editor.tool.heading2Short', 'Heading 2')}</div>
<div><kbd>Ctrl+Alt+3</kbd> - Heading 3</div> <div><kbd>Ctrl+Alt+3</kbd> - {t('cms.editor.tool.heading3Short', 'Heading 3')}</div>
<div><kbd>Ctrl+Alt+4</kbd> - Heading 4</div> <div><kbd>Ctrl+Alt+4</kbd> - {t('cms.editor.tool.heading4Short', 'Heading 4')}</div>
<div><kbd>Ctrl+Alt+5</kbd> - Heading 5</div> <div><kbd>Ctrl+Alt+5</kbd> - {t('cms.editor.tool.heading5Short', 'Heading 5')}</div>
<div><kbd>Ctrl+Alt+6</kbd> - Heading 6</div> <div><kbd>Ctrl+Alt+6</kbd> - {t('cms.editor.tool.heading6Short', 'Heading 6')}</div>
</div> </div>
</div> </div>
<div> <div>
<h3 className="font-semibold mb-2">Lists & Blocks</h3> <h3 className="font-semibold mb-2">{t('cms.editor.helpLists', 'Lists & Blocks')}</h3>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Shift+8</kbd> - Bullet list</div> <div><kbd>Ctrl+Shift+8</kbd> - {t('cms.editor.tool.bulletListShort', 'Bullet list')}</div>
<div><kbd>Ctrl+Shift+9</kbd> - Numbered list</div> <div><kbd>Ctrl+Shift+9</kbd> - {t('cms.editor.tool.numberedListShort', 'Numbered list')}</div>
<div><kbd>Ctrl+Shift+B</kbd> - Blockquote</div> <div><kbd>Ctrl+Shift+B</kbd> - {t('cms.editor.tool.blockquoteShort', 'Blockquote')}</div>
<div><kbd>Ctrl+Alt+C</kbd> - Code block</div> <div><kbd>Ctrl+Alt+C</kbd> - {t('cms.editor.tool.codeBlockShort', 'Code block')}</div>
</div> </div>
</div> </div>
<div> <div>
<h3 className="font-semibold mb-2">Text Alignment</h3> <h3 className="font-semibold mb-2">{t('cms.editor.helpAlignment', 'Text Alignment')}</h3>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<div>Click alignment buttons in toolbar</div> <div>{t('cms.editor.helpAlignmentClick', 'Click alignment buttons in toolbar')}</div>
<div>Works on paragraphs and headings</div> <div>{t('cms.editor.helpAlignmentScope', 'Works on paragraphs and headings')}</div>
</div> </div>
</div> </div>
<div> <div>
<h3 className="font-semibold mb-2">Line Breaks</h3> <h3 className="font-semibold mb-2">{t('cms.editor.helpLineBreaks', 'Line Breaks')}</h3>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<div><kbd>Enter</kbd> - New paragraph</div> <div><kbd>Enter</kbd> - {t('cms.editor.helpNewParagraph', 'New paragraph')}</div>
<div><kbd>Shift+Enter</kbd> - Line break (preserves formatting)</div> <div><kbd>Shift+Enter</kbd> - {t('cms.editor.helpLineBreak', 'Line break (preserves formatting)')}</div>
</div> </div>
</div> </div>
<div> <div>
<h3 className="font-semibold mb-2">Navigation</h3> <h3 className="font-semibold mb-2">{t('cms.editor.helpNavigation', 'Navigation')}</h3>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Z</kbd> - Undo</div> <div><kbd>Ctrl+Z</kbd> - {t('cms.editor.tool.undoShort', 'Undo')}</div>
<div><kbd>Ctrl+Y</kbd> - Redo</div> <div><kbd>Ctrl+Y</kbd> - {t('cms.editor.tool.redoShort', 'Redo')}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button onClick={() => setShowHelp(false)}>Close</Button> <Button onClick={() => setShowHelp(false)}>{t('common.close', 'Close')}</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -138,6 +138,7 @@ export const CategoryManager: React.FC = () => {
onChange={(e) => setNewCategoryName(e.target.value)} onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()} onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder={t('categories.categoryName')} placeholder={t('categories.categoryName')}
maxLength={100}
className="flex-1 px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500" className="flex-1 px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
autoFocus autoFocus
/> />
@@ -188,6 +189,7 @@ export const CategoryManager: React.FC = () => {
if (e.key === 'Enter') handleUpdate(category.id); if (e.key === 'Enter') handleUpdate(category.id);
if (e.key === 'Escape') cancelEdit(); if (e.key === 'Escape') cancelEdit();
}} }}
maxLength={100}
className="flex-1 px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500" className="flex-1 px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
autoFocus autoFocus
/> />
@@ -26,7 +26,11 @@ import { useMutationWithToast } from '../../hooks';
const ACCOUNT_TYPES: AccountType[] = ['asset', 'liability', 'equity', 'revenue', 'expense']; const ACCOUNT_TYPES: AccountType[] = ['asset', 'liability', 'equity', 'revenue', 'expense'];
const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1'; const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
const SETTING_ACCOUNT_KEYS: (keyof LedgerSettings)[] = [ // Narrowed to the `ledger_account_*` keys so `patch[k] = settings[k]` below
// typechecks: they all share the value type `string | undefined`, whereas
// `keyof LedgerSettings` also spans the Record-valued VAT maps.
type LedgerAccountSettingKey = Extract<keyof LedgerSettings, `ledger_account_${string}`>;
const SETTING_ACCOUNT_KEYS: LedgerAccountSettingKey[] = [
'ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', 'ledger_account_cash', 'ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', 'ledger_account_cash',
'ledger_account_default_revenue', 'ledger_account_default_expense', 'ledger_account_default_revenue', 'ledger_account_default_expense',
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue', 'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
@@ -14,7 +14,7 @@
import React from 'react'; import React from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Briefcase, UserCog, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar, FolderKanban } from 'lucide-react'; import { Briefcase, UserCog, FileText, Receipt, Wrench, Clock, ScrollText, Calendar, FolderKanban } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -24,6 +24,21 @@ interface Props {
value: SelectedCustomer[]; value: SelectedCustomer[];
onChange: (next: SelectedCustomer[]) => void; onChange: (next: SelectedCustomer[]) => void;
disabled?: boolean; disabled?: boolean;
/**
* Event-form mode (default): this picker IS part of the customer-portal
* feature it assigns portal logins to a gallery, so it hides itself
* when `customerPortal` is off and explains the password bypass.
*
* Pass false where the picker only needs to identify an existing
* customer record (Accounting "bill this to a client"). Those
* surfaces have their own gates (`accounting` / `expenses` /
* `incomingInvoices`) and their data path never touches the portal:
* /admin/customers{,/search} are permission-gated, not flag-gated, and
* POST /admin/customers explicitly creates passive, portal-less
* customers "to attach a quote / invoice / gallery to". Callers in this
* mode render their own field label.
*/
portalAssignment?: boolean;
} }
const labelFor = (c: { email: string; displayName?: string | null; companyName?: string | null }) => { const labelFor = (c: { email: string; displayName?: string | null; companyName?: string | null }) => {
@@ -31,7 +46,7 @@ const labelFor = (c: { email: string; displayName?: string | null; companyName?:
return display ? `${display} · ${c.email}` : c.email; return display ? `${display} · ${c.email}` : c.email;
}; };
export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabled }) => { export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabled, portalAssignment = true }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Rules of Hooks: the feature-flag gate (early-return) is moved to // Rules of Hooks: the feature-flag gate (early-return) is moved to
// the very end of this hook list (see end of function). The previous // the very end of this hook list (see end of function). The previous
@@ -111,19 +126,24 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
); );
// Feature-flag gate (deliberately placed AFTER all hooks — see the // Feature-flag gate (deliberately placed AFTER all hooks — see the
// long comment at the top of this component for why). When the // long comment at the top of this component for why). Only applies to
// customerPortal flag is off the backend returns 410 on // the event-assignment mode: hiding the UI there keeps the event form
// /admin/customers/search anyway, but hiding the UI here keeps the // clean and removes the dangling "Customer accounts" label that would
// event form clean and removes the dangling "Customer accounts" // otherwise appear above an empty placeholder. Non-portal call sites
// label that would otherwise appear above an empty placeholder. // must NOT be gated — their required customer field would render as a
if (!customerPortalEnabled) return null; // lone label with no input at all (QA S10).
if (portalAssignment && !customerPortalEnabled) return null;
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
{portalAssignment && (
<>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1"> <label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
{t('events.customerPicker.label', 'Customer accounts')} {t('events.customerPicker.label', 'Customer accounts')}
</label> </label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{helpText}</p> <p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{helpText}</p>
</>
)}
{/* Selected chips */} {/* Selected chips */}
{value.length > 0 && ( {value.length > 0 && (

Some files were not shown because too many files have changed in this diff Show More