Merge pull request #603 from Luca-Timo/feat/crm-improvements
CRM improvements: invoicing & payments, hours, email queue/scheduling, branding (dark mode + favicon), country pickers
This commit is contained in:
+18
-1
@@ -43,7 +43,16 @@ RUN npm install -g npm@10
|
||||
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
|
||||
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
|
||||
# pipeline calls via fluent-ffmpeg.ffprobe()).
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec
|
||||
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
|
||||
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
|
||||
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
|
||||
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
|
||||
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
|
||||
# PDFKit + the web UI use) are registered with fontconfig further down so the
|
||||
# logo's text renders in its actual typeface, not a fallback.
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
|
||||
fontconfig ttf-dejavu ttf-liberation && \
|
||||
fc-cache -f
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
@@ -55,6 +64,14 @@ COPY --chown=nodejs:nodejs . .
|
||||
# Ensure all source files are readable and wait script is executable
|
||||
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
|
||||
|
||||
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
|
||||
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
|
||||
# rasterises an SVG logo its <text> renders in the actual brand typeface
|
||||
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
|
||||
# internal family name and recurses into the per-family subdirectories.
|
||||
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
|
||||
fc-cache -f /app/assets/fonts
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
|
||||
chown -R nodejs:nodejs storage data logs
|
||||
|
||||
@@ -58,13 +58,30 @@ describe('resolveEffectiveRate', () => {
|
||||
)).toBe(15000);
|
||||
});
|
||||
|
||||
it('throws when both override and customer rate are unset', () => {
|
||||
it('throws when override, customer rate, AND install default are all unset', () => {
|
||||
expect(() => resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: null },
|
||||
null,
|
||||
)).toThrow(/No hourly rate/);
|
||||
});
|
||||
|
||||
it('falls back to the install-wide default when override + customer rate are unset', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: null },
|
||||
12000,
|
||||
)).toBe(12000);
|
||||
});
|
||||
|
||||
it('customer rate wins over the install-wide default', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
12000,
|
||||
)).toBe(15000);
|
||||
});
|
||||
|
||||
it('treats override=0 as "explicitly zero" (not null)', () => {
|
||||
// Override === 0 is unusual but legal — pro bono blocks, internal
|
||||
// tracking. Must NOT fall through to the customer default.
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Unit tests for the per-weekday business-hours floor (migration 114).
|
||||
* Exercises the pure snap logic against a fixed IANA zone so the results
|
||||
* don't drift with the CI box's local timezone.
|
||||
*
|
||||
* All scenarios use Europe/Zurich (the regulatory-scope default).
|
||||
*/
|
||||
|
||||
const {
|
||||
snapToBusinessHours,
|
||||
parseHHMM,
|
||||
minutesToHHMM,
|
||||
normaliseSchedule,
|
||||
hasAnyBlocks,
|
||||
_internal,
|
||||
} = require('../../src/utils/businessHours');
|
||||
|
||||
const TZ = 'Europe/Zurich';
|
||||
|
||||
// Mon–Fri 09:00–18:00, weekend closed. Plain string-block storage shape.
|
||||
const STANDARD = {
|
||||
'1': [{ start: '09:00', end: '18:00' }],
|
||||
'2': [{ start: '09:00', end: '18:00' }],
|
||||
'3': [{ start: '09:00', end: '18:00' }],
|
||||
'4': [{ start: '09:00', end: '18:00' }],
|
||||
'5': [{ start: '09:00', end: '18:00' }],
|
||||
'6': [],
|
||||
'7': [],
|
||||
};
|
||||
|
||||
// Mon–Fri with a lunch break (09:00–12:00, 13:00–18:00).
|
||||
const LUNCH = {
|
||||
'1': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'2': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'3': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'4': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'5': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'6': [],
|
||||
'7': [],
|
||||
};
|
||||
|
||||
const cfg = (schedule, overrides = {}) => ({
|
||||
enabled: true,
|
||||
timezone: TZ,
|
||||
schedule,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Build a UTC instant from a Zurich wall-clock so the assertions read in
|
||||
// local terms. Reuses the module's own converter (covered separately).
|
||||
function zurich(y, mo, d, hh, mi) {
|
||||
return _internal.zonedWallClockToUtc(y, mo, d, hh, mi, TZ);
|
||||
}
|
||||
|
||||
function partsOf(date) {
|
||||
const p = _internal.getZonedParts(date, TZ);
|
||||
return [p.y, p.mo, p.d, p.hh, p.mi];
|
||||
}
|
||||
|
||||
describe('parseHHMM / minutesToHHMM', () => {
|
||||
it('parses valid times to minutes', () => {
|
||||
expect(parseHHMM('09:00')).toBe(540);
|
||||
expect(parseHHMM('00:00')).toBe(0);
|
||||
expect(parseHHMM('23:59')).toBe(1439);
|
||||
});
|
||||
it('rejects malformed input', () => {
|
||||
expect(parseHHMM('9:00')).toBeNull();
|
||||
expect(parseHHMM('24:00')).toBeNull();
|
||||
expect(parseHHMM('12:60')).toBeNull();
|
||||
expect(parseHHMM('')).toBeNull();
|
||||
expect(parseHHMM(null)).toBeNull();
|
||||
});
|
||||
it('round-trips minutesToHHMM', () => {
|
||||
expect(minutesToHHMM(540)).toBe('09:00');
|
||||
expect(minutesToHHMM(0)).toBe('00:00');
|
||||
expect(minutesToHHMM(1439)).toBe('23:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliseSchedule', () => {
|
||||
it('parses, sorts, and drops invalid blocks', () => {
|
||||
const out = normaliseSchedule({
|
||||
'1': [{ start: '13:00', end: '18:00' }, { start: '09:00', end: '12:00' }],
|
||||
'2': [{ start: '18:00', end: '09:00' }], // end<=start → dropped
|
||||
'3': [{ start: 'bad', end: '18:00' }], // malformed → dropped
|
||||
});
|
||||
expect(out['1']).toEqual([
|
||||
{ start: '09:00', end: '12:00' },
|
||||
{ start: '13:00', end: '18:00' },
|
||||
]);
|
||||
expect(out['2']).toEqual([]);
|
||||
expect(out['3']).toEqual([]);
|
||||
expect(out['7']).toEqual([]);
|
||||
});
|
||||
it('accepts [start,end] pair blocks and a JSON string', () => {
|
||||
const out = normaliseSchedule(JSON.stringify({ '4': [['09:00', '17:00']] }));
|
||||
expect(out['4']).toEqual([{ start: '09:00', end: '17:00' }]);
|
||||
});
|
||||
it('garbage input → all-empty week', () => {
|
||||
expect(hasAnyBlocks(normaliseSchedule('not json'))).toBe(false);
|
||||
expect(hasAnyBlocks(normaliseSchedule(null))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToBusinessHours — single window (Mon–Fri 09:00–18:00)', () => {
|
||||
it('weekday before open (Tue 02:11) → SAME day 09:00', () => {
|
||||
// 2026-06-02 is a Tuesday.
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 2, 11), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
|
||||
});
|
||||
|
||||
it('weekday inside window (Tue 10:30) → unchanged', () => {
|
||||
const input = zurich(2026, 6, 2, 10, 30);
|
||||
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('weekday after close (Tue 20:00) → next business day 09:00 (Wed)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 20, 0), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
|
||||
});
|
||||
|
||||
it('Sunday 14:00 → Monday 09:00', () => {
|
||||
// 2026-06-07 is a Sunday; 2026-06-08 is the Monday.
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 7, 14, 0), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
|
||||
});
|
||||
|
||||
it('Saturday before open (Sat 02:11) → Monday 09:00 (closed day, not same-day)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 6, 2, 11), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
|
||||
});
|
||||
|
||||
it('Friday after close (Fri 19:30) → Monday 09:00 (skips weekend)', () => {
|
||||
// 2026-06-05 is a Friday.
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 5, 19, 30), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
|
||||
});
|
||||
|
||||
it('exactly at open (Tue 09:00) → unchanged (inclusive lower bound)', () => {
|
||||
const input = zurich(2026, 6, 2, 9, 0);
|
||||
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('exactly at close (Tue 18:00) → next business day 09:00 (exclusive upper bound)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 18, 0), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToBusinessHours — lunch break (09:00–12:00, 13:00–18:00)', () => {
|
||||
it('morning block (Tue 10:30) → unchanged', () => {
|
||||
const input = zurich(2026, 6, 2, 10, 30);
|
||||
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('during lunch (Tue 12:30) → SAME day 13:00 (next block open)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 30), cfg(LUNCH));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
|
||||
});
|
||||
|
||||
it('exactly at lunch start (Tue 12:00) → 13:00 (block end is exclusive)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 0), cfg(LUNCH));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
|
||||
});
|
||||
|
||||
it('afternoon block (Tue 17:59) → unchanged', () => {
|
||||
const input = zurich(2026, 6, 2, 17, 59);
|
||||
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('before open (Tue 07:00) → SAME day 09:00 (first block)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 7, 0), cfg(LUNCH));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
|
||||
});
|
||||
|
||||
it('after close (Tue 19:00) → next day 09:00', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 19, 0), cfg(LUNCH));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToBusinessHours — per-day differing hours', () => {
|
||||
const PERDAY = {
|
||||
'1': [{ start: '08:00', end: '12:00' }], // Mon morning only
|
||||
'2': [], // Tue closed
|
||||
'3': [{ start: '14:00', end: '20:00' }], // Wed afternoon/evening
|
||||
'4': [], '5': [], '6': [], '7': [],
|
||||
};
|
||||
|
||||
it('Mon after its noon close (Mon 13:00) → skips closed Tue → Wed 14:00', () => {
|
||||
// 2026-06-01 is a Monday; 2026-06-03 is the Wednesday.
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 1, 13, 0), cfg(PERDAY));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
|
||||
});
|
||||
|
||||
it('closed Tuesday (Tue 10:00) → Wed 14:00', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 10, 0), cfg(PERDAY));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
|
||||
});
|
||||
|
||||
it('Wed before its 14:00 open (Wed 09:00) → SAME day 14:00', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 3, 9, 0), cfg(PERDAY));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToBusinessHours — passthrough cases', () => {
|
||||
it('floor disabled → unchanged even when outside hours', () => {
|
||||
const input = zurich(2026, 6, 2, 2, 11);
|
||||
expect(snapToBusinessHours(input, cfg(STANDARD, { enabled: false })).getTime())
|
||||
.toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('empty schedule → unchanged (nothing to snap to)', () => {
|
||||
const empty = normaliseSchedule(null);
|
||||
const input = zurich(2026, 6, 7, 14, 0);
|
||||
expect(snapToBusinessHours(input, cfg(empty)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('non-Date / invalid input is passed through untouched', () => {
|
||||
expect(snapToBusinessHours(null, cfg(STANDARD))).toBeNull();
|
||||
const bad = new Date('not-a-date');
|
||||
expect(Number.isNaN(snapToBusinessHours(bad, cfg(STANDARD)).getTime())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_internal round-trips', () => {
|
||||
it('zonedWallClockToUtc → getZonedParts reconstructs the wall-clock', () => {
|
||||
const d = _internal.zonedWallClockToUtc(2026, 6, 2, 9, 0, TZ);
|
||||
const p = _internal.getZonedParts(d, TZ);
|
||||
expect([p.y, p.mo, p.d, p.hh, p.mi]).toEqual([2026, 6, 2, 9, 0]);
|
||||
});
|
||||
|
||||
it('isoWeekday: 2026-06-07 is Sunday (7), 2026-06-08 is Monday (1)', () => {
|
||||
expect(_internal.isoWeekday(2026, 6, 7)).toBe(7);
|
||||
expect(_internal.isoWeekday(2026, 6, 8)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Migration: normalize the Liechtenstein country code from the
|
||||
* colloquial vehicle-plate code `FL` to the ISO 3166-1 alpha-2 code
|
||||
* `LI`.
|
||||
*
|
||||
* Background: the customer create/edit UI used to accept a free-text
|
||||
* 2-char country code and the placeholder suggested `FL` for
|
||||
* Liechtenstein. That code isn't ISO — the PDF renderer's locale-aware
|
||||
* lookup (services/pdfService.js countryName) and the new country
|
||||
* dropdown both key on ISO, so `FL` rows render as the bare code
|
||||
* instead of "Liechtenstein". The dropdown now stores `LI`; this
|
||||
* migration brings existing rows in line so they display correctly and
|
||||
* match new records.
|
||||
*
|
||||
* Scope: customer_accounts.country_code and business_profile.country_code.
|
||||
* Case-insensitive so a hand-entered `fl` is caught too. The free-text
|
||||
* country_name override column is left untouched — it exists precisely
|
||||
* for operators who want a custom display string.
|
||||
*
|
||||
* Idempotent: re-runs only touch rows still holding FL, so a second run
|
||||
* is a no-op.
|
||||
*/
|
||||
|
||||
async function normalizeColumn(knex, table) {
|
||||
if (!(await knex.schema.hasTable(table))) return;
|
||||
if (!(await knex.schema.hasColumn(table, 'country_code'))) return;
|
||||
await knex(table)
|
||||
.whereRaw('UPPER(country_code) = ?', ['FL'])
|
||||
.update({ country_code: 'LI' });
|
||||
}
|
||||
|
||||
exports.up = async function(knex) {
|
||||
await normalizeColumn(knex, 'customer_accounts');
|
||||
await normalizeColumn(knex, 'business_profile');
|
||||
};
|
||||
|
||||
// Irreversible by design: once normalized to the ISO code there's no
|
||||
// way to know which `LI` rows were originally `FL`, and reverting would
|
||||
// reintroduce the non-ISO value the rest of the system can't read.
|
||||
exports.down = async function() {};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Migration: backfill historical send/payment dates on already-imported
|
||||
* invoices.
|
||||
*
|
||||
* Background: the invoice-import endpoint (POST /admin/invoices/import)
|
||||
* used to stamp `sent_at` and `paid_at` with the moment of import
|
||||
* (`new Date()`) rather than the document's own historical dates. The
|
||||
* CRM dashboard's "Revenue · last 30 days" card keys on `paid_at`, so a
|
||||
* year-old paid invoice imported today wrongly counted toward the
|
||||
* rolling window. The route now anchors both timestamps to `issue_date`
|
||||
* (with an optional explicit `paidAt`); this migration brings the rows
|
||||
* imported under the old behaviour in line.
|
||||
*
|
||||
* Scope: rows with `imported_pdf_path` set — i.e. historical documents,
|
||||
* never invoices issued by picpeak itself. For those, no real payment
|
||||
* date was ever captured (the column held the import timestamp), so the
|
||||
* issue date is the best available anchor. Note: `paid_at`/`sent_at` are
|
||||
* operational timestamps, not part of the invoice's immutable legal
|
||||
* content — correcting an import-time bug on them doesn't alter the
|
||||
* issued document.
|
||||
*
|
||||
* Idempotent: re-runs just re-assign the same issue_date value.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('invoices'))) return;
|
||||
const cols = ['imported_pdf_path', 'issue_date', 'sent_at', 'paid_at'];
|
||||
for (const c of cols) {
|
||||
if (!(await knex.schema.hasColumn('invoices', c))) return;
|
||||
}
|
||||
|
||||
// sent_at → issue_date for every imported row that has one.
|
||||
await knex('invoices')
|
||||
.whereNotNull('imported_pdf_path')
|
||||
.whereNotNull('sent_at')
|
||||
.update({ sent_at: knex.ref('issue_date') });
|
||||
|
||||
// paid_at → issue_date for imported rows that recorded a payment.
|
||||
await knex('invoices')
|
||||
.whereNotNull('imported_pdf_path')
|
||||
.whereNotNull('paid_at')
|
||||
.update({ paid_at: knex.ref('issue_date') });
|
||||
};
|
||||
|
||||
// Irreversible by design: the original import-time stamps were wrong
|
||||
// data, and there's no record of them to restore.
|
||||
exports.down = async function() {};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Migration: per-customer Skonto opt-out.
|
||||
*
|
||||
* Background: invoices already carry a per-invoice `skonto_disabled`
|
||||
* flag (migration 126). For B2B customers who negotiated "no early-
|
||||
* payment discount" as a standing contract term, the admin had to tick
|
||||
* that toggle on every single invoice. This adds a customer-level flag
|
||||
* so the opt-out is set once and applies to all of that customer's
|
||||
* invoices. The resolver chain becomes:
|
||||
* customer.skonto_disabled → invoice.skonto_disabled →
|
||||
* invoice snapshot → source-quote snapshot → global default.
|
||||
*
|
||||
* Default false so existing customers keep inheriting whatever Skonto
|
||||
* the template / global default offers — no behaviour change on upgrade
|
||||
* (see migration-preserve-existing-state guidance).
|
||||
*
|
||||
* Idempotent: guarded by hasColumn so a re-run is a no-op.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('customer_accounts'))) return;
|
||||
if (await knex.schema.hasColumn('customer_accounts', 'skonto_disabled')) return;
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.boolean('skonto_disabled').notNullable().defaultTo(false);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('customer_accounts'))) return;
|
||||
if (!(await knex.schema.hasColumn('customer_accounts', 'skonto_disabled'))) return;
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.dropColumn('skonto_disabled');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Migration: install-wide default hourly rate.
|
||||
*
|
||||
* Background: hour entries resolve a billing rate through
|
||||
* entry.hourly_rate_minor_override → customer.hourly_rate_minor.
|
||||
* When a customer had neither set, saving an entry hard-failed with
|
||||
* HOURLY_RATE_REQUIRED — a confusing save-time error on the hours page.
|
||||
* This adds an install-wide fallback so a single global rate covers
|
||||
* every customer who hasn't been given an individual one. The chain
|
||||
* becomes:
|
||||
* entry override → customer rate → business_profile default → (CTA).
|
||||
*
|
||||
* Stored in minor units (matches customer_accounts.hourly_rate_minor).
|
||||
* Nullable, default NULL: existing installs keep today's behaviour
|
||||
* (no implicit rate) until the admin sets one — no surprise rate gets
|
||||
* applied on upgrade (migration-preserve-existing-state guidance).
|
||||
*
|
||||
* Idempotent: guarded by hasColumn so a re-run is a no-op.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('business_profile'))) return;
|
||||
if (await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor')) return;
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.bigInteger('default_hourly_rate_minor');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('business_profile'))) return;
|
||||
if (!(await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor'))) return;
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.dropColumn('default_hourly_rate_minor');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Migration: configurable business hours on the business profile.
|
||||
*
|
||||
* Adds two columns to the singleton business_profile row (id=1):
|
||||
*
|
||||
* business_hours TEXT — JSON, per-ISO-weekday opening
|
||||
* blocks. Shape:
|
||||
* {"1":[{"start":"09:00","end":"12:00"},
|
||||
* {"start":"13:00","end":"18:00"}],
|
||||
* ...,"7":[]}
|
||||
* Keys are ISO weekdays 1=Mon … 7=Sun;
|
||||
* a day with no blocks is closed.
|
||||
* Multiple blocks per day model lunch
|
||||
* breaks (Google-style).
|
||||
* scheduled_email_floor_enabled BOOLEAN default TRUE — master switch for
|
||||
* holding scheduled emails until the
|
||||
* next open block.
|
||||
*
|
||||
* business_hours defaults to NULL (no hours configured). A null / empty
|
||||
* schedule makes the scheduled-email floor a no-op, so existing installs
|
||||
* keep today's behaviour — emails send at their requested instant until
|
||||
* the admin actually defines opening hours (migration-preserve-state).
|
||||
*
|
||||
* The timezone the blocks are interpreted in is the EXISTING
|
||||
* business_profile.timezone column (added earlier for the admin calendar)
|
||||
* — no new tz column. The whole business-hours definition lives on the
|
||||
* business profile, which is where the admin edits it.
|
||||
*
|
||||
* Idempotent: each column guarded by hasColumn so a re-run is a no-op.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('business_profile'))) return;
|
||||
|
||||
if (!(await knex.schema.hasColumn('business_profile', 'business_hours'))) {
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.text('business_hours');
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled'))) {
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.boolean('scheduled_email_floor_enabled').notNullable().defaultTo(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('business_profile'))) return;
|
||||
|
||||
if (await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled')) {
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.dropColumn('scheduled_email_floor_enabled');
|
||||
});
|
||||
}
|
||||
if (await knex.schema.hasColumn('business_profile', 'business_hours')) {
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.dropColumn('business_hours');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Migration: admin decline-quote reason.
|
||||
*
|
||||
* Background: admins can now decline a quote on the customer's behalf
|
||||
* ("customer told us by phone they're not going ahead") instead of
|
||||
* waiting for the public response link. This stores an optional free-text
|
||||
* reason alongside the existing `declined_at` timestamp so the quote
|
||||
* detail page can show WHY it was declined.
|
||||
*
|
||||
* Nullable, no default — existing declined rows simply carry no reason,
|
||||
* which is exactly how customer-side declines already look. No behaviour
|
||||
* change on upgrade.
|
||||
*
|
||||
* Idempotent: guarded by hasColumn so a re-run is a no-op.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('quotes'))) return;
|
||||
if (await knex.schema.hasColumn('quotes', 'decline_reason')) return;
|
||||
await knex.schema.alterTable('quotes', (table) => {
|
||||
table.text('decline_reason');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('quotes'))) return;
|
||||
if (!(await knex.schema.hasColumn('quotes', 'decline_reason'))) return;
|
||||
await knex.schema.alterTable('quotes', (table) => {
|
||||
table.dropColumn('decline_reason');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Migration: backfill paid_amount_minor for imported PAID invoices that
|
||||
* stored 0.
|
||||
*
|
||||
* Background: the historical-invoice import only sent paidAmountMinor when
|
||||
* the admin separately filled the "paid amount" field. Left blank (easy to
|
||||
* miss — the total was already entered), it stored paid_amount_minor = 0
|
||||
* even with status='paid'. The dashboard revenue windows sum
|
||||
* paid_amount_minor (not total), so those paid imports contributed NOTHING
|
||||
* to revenue. The import route now defaults a blank paid amount to the
|
||||
* total; this fixes the rows already created before that change.
|
||||
*
|
||||
* Scope: imported (imported_pdf_path set) + status='paid' + paid_amount_minor
|
||||
* 0/null → set paid_amount_minor = total_amount_minor (fully paid). Operational
|
||||
* payment field, not immutable legal content (same reasoning as migration 111).
|
||||
*
|
||||
* Idempotent: re-running sets the same value; rows already > 0 are untouched.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('invoices'))) return;
|
||||
if (!(await knex.schema.hasColumn('invoices', 'imported_pdf_path'))) return;
|
||||
if (!(await knex.schema.hasColumn('invoices', 'paid_amount_minor'))) return;
|
||||
|
||||
await knex('invoices')
|
||||
.whereNotNull('imported_pdf_path')
|
||||
.where('status', 'paid')
|
||||
.andWhere(function() {
|
||||
this.where('paid_amount_minor', 0).orWhereNull('paid_amount_minor');
|
||||
})
|
||||
.update({ paid_amount_minor: knex.raw('total_amount_minor') });
|
||||
};
|
||||
|
||||
exports.down = async function() {
|
||||
// Irreversible data backfill — we can't tell which rows we changed apart
|
||||
// from legitimately-full payments. No-op.
|
||||
};
|
||||
@@ -559,6 +559,52 @@ app.get('/robots.txt', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Dynamic favicon endpoints. Browsers — notably Safari — request
|
||||
// /favicon.ico and /apple-touch-icon*.png directly at the site root and are
|
||||
// unreliable about honouring JS-injected <link rel="icon"> tags. Serving the
|
||||
// admin's configured branding favicon here makes it work without client-side
|
||||
// JS (and survive aggressive favicon caches). Falls back to the bundled asset
|
||||
// shipped with the frontend build when no custom favicon is set.
|
||||
app.get(
|
||||
['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { getAppSetting } = require('./src/utils/appSettings');
|
||||
const raw = await getAppSetting('branding_favicon_url', null);
|
||||
const url = (raw && String(raw).trim()) || null;
|
||||
if (url) {
|
||||
// External URL — can't stream the bytes, so redirect (best effort).
|
||||
if (/^https?:\/\//i.test(url)) return res.redirect(302, url);
|
||||
// Local upload → stream the file bytes DIRECTLY rather than 302'ing.
|
||||
// Safari does NOT reliably follow a redirect for favicon requests
|
||||
// (it falls back to the HTML <link>, i.e. the bundled default),
|
||||
// whereas Firefox/Chrome do — so a 302 worked everywhere except
|
||||
// Safari. sendFile sets the right content-type from the extension.
|
||||
const rel = String(url).replace(/^\/+/, '').replace(/^uploads\//, '');
|
||||
const uploadsRoot = path.resolve(path.join(storagePath, 'uploads'));
|
||||
const resolved = path.resolve(path.join(uploadsRoot, rel));
|
||||
// Path containment — never serve outside the uploads dir.
|
||||
if (resolved.startsWith(uploadsRoot + path.sep) && fs.existsSync(resolved)) {
|
||||
// This route streams the file directly, bypassing the secureStatic
|
||||
// middleware — so re-apply its SVG hardening here. An admin-uploaded
|
||||
// SVG favicon could contain <script>; served at the top-level
|
||||
// /favicon.ico origin without CSP that would be stored XSS. Keep in
|
||||
// sync with secureStatic.js.
|
||||
if (/\.svg$/i.test(resolved)) {
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
return res.sendFile(resolved);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Favicon lookup failed; serving bundled default', { error: error.message });
|
||||
}
|
||||
return res.redirect(302, '/favicon-32x32.png');
|
||||
}
|
||||
);
|
||||
|
||||
// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E
|
||||
// watchdog) detect a silent process restart between two checks.
|
||||
app.get('/health', async (req, res) => {
|
||||
|
||||
@@ -31,7 +31,22 @@ function secureStatic(basePath, options = {}) {
|
||||
// Disable directory listing for security
|
||||
index: false,
|
||||
// Don't allow dotfiles
|
||||
dotfiles: 'deny'
|
||||
dotfiles: 'deny',
|
||||
setHeaders: (resp, filePath) => {
|
||||
// Preserve any caller-provided header logic (e.g. font caching).
|
||||
if (typeof options.setHeaders === 'function') options.setHeaders(resp, filePath);
|
||||
// SVGs are admin-uploadable (logos, favicon). Served from our
|
||||
// own origin, a malicious SVG opened directly could run embedded
|
||||
// <script>/on* handlers (stored XSS). A restrictive CSP lets the
|
||||
// browser RENDER the vector but blocks all script execution —
|
||||
// so we keep real SVGs (scalable) instead of rasterising them.
|
||||
// `default-src 'none'` already implies script-src 'none';
|
||||
// style-src + img-src(data:) keep normal SVG rendering working.
|
||||
if (/\.svg$/i.test(filePath)) {
|
||||
resp.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
|
||||
resp.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return staticMiddleware(req, res, next);
|
||||
|
||||
@@ -142,6 +142,10 @@ function transformProfile(p) {
|
||||
taxId: p.tax_id || '',
|
||||
vatLabel: p.vat_label || 'MwSt.',
|
||||
vatRateDefault: p.vat_rate_default == null ? null : Number(p.vat_rate_default),
|
||||
// Install-wide fallback hourly rate (migration 113), minor units.
|
||||
// null = no global default; the hours page then requires a per-
|
||||
// customer or per-entry rate.
|
||||
defaultHourlyRateMinor: p.default_hourly_rate_minor == null ? null : Number(p.default_hourly_rate_minor),
|
||||
defaultCurrency: p.default_currency || 'CHF',
|
||||
defaultLocale: p.default_locale || 'de',
|
||||
defaultQrFormat: p.default_qr_format || 'none',
|
||||
@@ -163,11 +167,34 @@ function transformProfile(p) {
|
||||
// Migration 137 — IANA timezone for the admin calendar. Null when
|
||||
// the admin hasn't picked one; frontend falls back to the browser.
|
||||
timezone: p.timezone || null,
|
||||
// Migration 114 — per-ISO-weekday opening hours (object keyed
|
||||
// "1".."7"). Stored as JSON TEXT; parse to an object for the API.
|
||||
// null/blank = no hours configured.
|
||||
businessHours: parseBusinessHours(p.business_hours),
|
||||
// Migration 114 — master switch for the scheduled-email floor.
|
||||
// Defaults true (column is NOT NULL default true).
|
||||
scheduledEmailFloorEnabled: p.scheduled_email_floor_enabled == null
|
||||
? true
|
||||
: (p.scheduled_email_floor_enabled === true
|
||||
|| p.scheduled_email_floor_enabled === 1
|
||||
|| p.scheduled_email_floor_enabled === '1'),
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse the stored business_hours JSON to an object, or null. */
|
||||
function parseBusinessHours(raw) {
|
||||
if (raw == null || raw === '') return null;
|
||||
if (typeof raw === 'object') return raw; // pg jsonb path (column is text today)
|
||||
try {
|
||||
const obj = JSON.parse(raw);
|
||||
return obj && typeof obj === 'object' ? obj : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function transformBank(b) {
|
||||
if (!b) return null;
|
||||
return {
|
||||
@@ -346,6 +373,11 @@ router.put(
|
||||
body('taxId').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||
body('vatLabel').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||
body('vatRateDefault').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||
// Migration 113 — install-wide default hourly rate, minor units.
|
||||
// nullable so the admin can clear it; values: 'falsy' would drop a
|
||||
// legitimate 0 (which we treat as "explicitly free"), so use the
|
||||
// nullable form and let the service coerce.
|
||||
body('defaultHourlyRateMinor').optional({ nullable: true }).isInt({ min: 0 }),
|
||||
body('defaultCurrency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||
body('defaultLocale').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||
body('defaultQrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']),
|
||||
@@ -371,6 +403,15 @@ router.put(
|
||||
// "Europe/Zurich"). Free-text; backend stores up to 64 chars.
|
||||
// Frontend falls back to browser Intl when this is blank.
|
||||
body('timezone').optional({ values: 'falsy', nullable: true }).isString().isLength({ max: 64 }),
|
||||
// Migration 114 — per-weekday opening hours. Object keyed "1".."7" or
|
||||
// null to clear. Shape is validated + sanitised in the service layer
|
||||
// (normaliseSchedule); here we only reject obviously-wrong types.
|
||||
body('businessHours').optional({ nullable: true }).custom((v) => {
|
||||
if (v === null || typeof v === 'object') return true;
|
||||
throw new Error('businessHours must be an object or null');
|
||||
}),
|
||||
// Migration 114 — scheduled-email floor master switch.
|
||||
body('scheduledEmailFloorEnabled').optional().isBoolean(),
|
||||
],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
@@ -393,6 +434,7 @@ router.put(
|
||||
taxId: 'tax_id',
|
||||
vatLabel: 'vat_label',
|
||||
vatRateDefault: 'vat_rate_default',
|
||||
defaultHourlyRateMinor: 'default_hourly_rate_minor',
|
||||
defaultCurrency: 'default_currency',
|
||||
defaultLocale: 'default_locale',
|
||||
defaultQrFormat: 'default_qr_format',
|
||||
@@ -408,6 +450,9 @@ router.put(
|
||||
pdfQuoteShowSkonto: 'pdf_quote_show_skonto',
|
||||
// Migration 137 — admin calendar timezone.
|
||||
timezone: 'timezone',
|
||||
// Migration 114 — business hours + scheduled-email floor switch.
|
||||
businessHours: 'business_hours',
|
||||
scheduledEmailFloorEnabled: 'scheduled_email_floor_enabled',
|
||||
};
|
||||
for (const [api, db] of Object.entries(map)) {
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
|
||||
|
||||
@@ -286,7 +286,7 @@ router.get(
|
||||
query('status').optional().isString(),
|
||||
query('customerAccountId').optional().isInt({ min: 1 }),
|
||||
query('q').optional().isString(),
|
||||
query('sort').optional().isIn(['newest', 'oldest', 'customer_asc']),
|
||||
query('sort').optional().isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'customer_asc', 'customer_desc']),
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('pageSize').optional().isInt({ min: 1, max: 200 }),
|
||||
],
|
||||
@@ -302,7 +302,7 @@ router.get(
|
||||
const pageSize = parseInt(req.query.pageSize, 10) || 25;
|
||||
const result = await contractService.listContracts({
|
||||
filters,
|
||||
sort: req.query.sort || 'newest',
|
||||
sort: req.query.sort || 'issue_desc',
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
@@ -67,6 +67,10 @@ function transformCustomer(c) {
|
||||
// per-entry override on every logged block.
|
||||
featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1,
|
||||
hourlyRateMinor: c.hourly_rate_minor != null ? Number(c.hourly_rate_minor) : null,
|
||||
// Per-customer Skonto opt-out (migration 112). When true, none of
|
||||
// this customer's invoices qualify for an early-payment discount,
|
||||
// regardless of template / global defaults.
|
||||
skontoDisabled: c.skonto_disabled === true || c.skonto_disabled === 1,
|
||||
lastLogin: c.last_login,
|
||||
createdAt: c.created_at,
|
||||
updatedAt: c.updated_at,
|
||||
@@ -245,6 +249,17 @@ router.post('/', [
|
||||
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
|
||||
body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||
body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }),
|
||||
// At least one human-readable identifier so the record isn't a
|
||||
// nameless row that's impossible to recognise in lists later.
|
||||
body('prefill').custom((prefill) => {
|
||||
const p = prefill || {};
|
||||
const hasName = ['company_name', 'display_name', 'first_name', 'last_name']
|
||||
.some((k) => typeof p[k] === 'string' && p[k].trim());
|
||||
if (!hasName) {
|
||||
throw new Error('At least a company name or a contact name is required');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const { id } = await customerAccountsService.createDirect({
|
||||
@@ -380,9 +395,11 @@ router.put('/:id', [
|
||||
// generated invoice to billing_cycle_day of the next period.
|
||||
// Cycle day spans -15..-1 (days before month end) and 1..28
|
||||
// (day of month) per migration 128 + service-layer clamp.
|
||||
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']),
|
||||
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly', 'manual']),
|
||||
body('billing_cycle_day').optional().isInt({ min: -15, max: 28 })
|
||||
.withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'),
|
||||
// Per-customer Skonto opt-out (migration 112).
|
||||
body('skonto_disabled').optional().isBoolean(),
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const customer = await customerAccountsService.updateCustomer(
|
||||
@@ -518,6 +535,18 @@ router.put('/:id/events', [
|
||||
// surface.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Aggregate landing view for /admin/clients/hours — every customer with
|
||||
// open (unbilled) hours + the open monetary amount. Registered before
|
||||
// the /:id/hour-entries routes; the literal first segment ("hour-entries")
|
||||
// can't collide with the int-validated :id pattern.
|
||||
router.get('/hour-entries/unbilled-summary', [
|
||||
adminAuth,
|
||||
requirePermission('customers.view'),
|
||||
], handleAsync(async (req, res) => {
|
||||
const summary = await customerHoursService.getUnbilledSummaryByCustomer();
|
||||
successResponse(res, { summary });
|
||||
}));
|
||||
|
||||
router.get('/:id/hour-entries', [
|
||||
adminAuth,
|
||||
requirePermission('customers.view'),
|
||||
|
||||
@@ -429,11 +429,18 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
||||
if (r.status in invoiceCounts) invoiceCounts[r.status] = Number(r.count) || 0;
|
||||
}
|
||||
|
||||
// Revenue windows: sum of `paid_amount_minor` for invoices
|
||||
// marked PAID where paid_at falls inside the window. Using
|
||||
// paid_amount (not total) so partial payments are tracked
|
||||
// accurately. Stornos excluded — they're never status='paid'
|
||||
// in normal flow but the guard is defensive.
|
||||
// Revenue windows: sum of `paid_amount_minor` for invoices marked
|
||||
// PAID whose payment date (`paid_at`) falls inside the window —
|
||||
// cash-basis recognition (revenue counts when the money arrives).
|
||||
// Using paid_amount (not total) so partial payments are tracked
|
||||
// accurately. Stornos excluded — never status='paid' in normal flow,
|
||||
// the guard is defensive.
|
||||
//
|
||||
// `paid_at` is admin-controllable, so an old/imported invoice lands
|
||||
// in the right window without any special-casing here: the mark-paid
|
||||
// dialog takes an optional payment date (backdate to when the money
|
||||
// actually arrived) and the historical-import route anchors paid_at
|
||||
// to the invoice's issue_date.
|
||||
const winSum = async (cutoff) => {
|
||||
const row = await db('invoices')
|
||||
.where('status', 'paid')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const express = require('express');
|
||||
const nodemailer = require('nodemailer');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { wrapEmailHtml } = require('../services/emailProcessor');
|
||||
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
|
||||
const router = express.Router();
|
||||
|
||||
// Get email configuration
|
||||
@@ -94,6 +94,16 @@ router.post('/config', [
|
||||
await db('email_configs').insert(configData);
|
||||
}
|
||||
|
||||
// Refresh the cached transporter so the new SMTP settings take effect
|
||||
// immediately. Without this, a previously-initialised transporter stays
|
||||
// cached (the queue processor only re-inits when it's null), so changing
|
||||
// the email account had no effect until a backend restart — emails kept
|
||||
// failing against the old/empty config. initializeTransporter catches its
|
||||
// own errors and returns null, so this never throws; an invalid config
|
||||
// simply leaves the transporter null (surfaced via the Test-email button).
|
||||
const { initializeTransporter } = require('../services/emailProcessor');
|
||||
await initializeTransporter(true);
|
||||
|
||||
// Log activity
|
||||
await logActivity('email_config_updated',
|
||||
{ smtp_host, from_email },
|
||||
@@ -248,6 +258,114 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Flush the email queue now. Sends every pending email immediately,
|
||||
// bypassing the business-hours floor (`scheduled_at`) — the escape hatch
|
||||
// for "drain the queue before I take the server down for an update".
|
||||
router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
const summary = await processEmailQueue({ ignoreSchedule: true, limit: 1000 });
|
||||
try {
|
||||
await logActivity('email_queue_flushed',
|
||||
{ processed: summary.processed, sent: summary.sent, failed: summary.failed },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
} catch (_) { /* activity logging is best-effort */ }
|
||||
res.json({ message: 'Email queue flushed', ...summary });
|
||||
} catch (error) {
|
||||
console.error('Flush email queue error:', error);
|
||||
res.status(500).json({ error: 'Failed to flush email queue', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Read-only "Sent emails" feed — paginated view of email_queue with
|
||||
// filters (status, type, recipient search, date range). email_data is
|
||||
// deliberately NOT returned (it can carry attachment paths / PII); the
|
||||
// list only needs the envelope + delivery state. event_id is joined to
|
||||
// events so the UI can link back to the source gallery when present.
|
||||
router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
|
||||
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
query('from').optional({ values: 'falsy' }).isISO8601(),
|
||||
query('to').optional({ values: 'falsy' }).isISO8601(),
|
||||
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const page = req.query.page ? parseInt(req.query.page, 10) : 1;
|
||||
const pageSize = req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25;
|
||||
|
||||
const applyFilters = (qb) => {
|
||||
if (req.query.status) qb.where('email_queue.status', req.query.status);
|
||||
if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType);
|
||||
if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from));
|
||||
if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
|
||||
if (req.query.q) {
|
||||
const term = `%${String(req.query.q).trim()}%`;
|
||||
qb.where(function () {
|
||||
this.where('email_queue.recipient_email', 'like', term)
|
||||
.orWhere('email_queue.email_type', 'like', term);
|
||||
});
|
||||
}
|
||||
return qb;
|
||||
};
|
||||
|
||||
const [{ count }] = await applyFilters(db('email_queue')).count({ count: '*' });
|
||||
const total = parseInt(count, 10) || 0;
|
||||
|
||||
const rows = await applyFilters(
|
||||
db('email_queue')
|
||||
.leftJoin('events', 'events.id', 'email_queue.event_id')
|
||||
.select(
|
||||
'email_queue.id',
|
||||
'email_queue.recipient_email',
|
||||
'email_queue.email_type',
|
||||
'email_queue.status',
|
||||
'email_queue.created_at',
|
||||
'email_queue.scheduled_at',
|
||||
'email_queue.sent_at',
|
||||
'email_queue.error_message',
|
||||
'email_queue.retry_count',
|
||||
'email_queue.event_id',
|
||||
'events.event_name as event_name',
|
||||
'events.slug as event_slug'
|
||||
)
|
||||
)
|
||||
.orderBy('email_queue.created_at', 'desc')
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
recipientEmail: r.recipient_email,
|
||||
emailType: r.email_type,
|
||||
status: r.status,
|
||||
createdAt: r.created_at,
|
||||
scheduledAt: r.scheduled_at,
|
||||
sentAt: r.sent_at,
|
||||
errorMessage: r.error_message,
|
||||
retryCount: r.retry_count,
|
||||
eventId: r.event_id,
|
||||
eventName: r.event_name || null,
|
||||
eventSlug: r.event_slug || null,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
items,
|
||||
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List email queue error:', error);
|
||||
res.status(500).json({ error: 'Failed to load email queue', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
|
||||
@@ -240,6 +240,9 @@ const INVOICE_BODY_VALIDATORS = [
|
||||
body('skontoDisabled').optional().isBoolean(),
|
||||
// Inline event snapshot (migration 123). Mirrors quotes — kept
|
||||
// optional because standalone invoices may not have an event yet.
|
||||
// eventId links the invoice to a gallery event (FK) when created from
|
||||
// the event detail page; persisted by createInvoice (event_id).
|
||||
body('eventId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
|
||||
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||
@@ -319,7 +322,7 @@ router.get(
|
||||
query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||
query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(),
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc']),
|
||||
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc', 'customer_desc']),
|
||||
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
|
||||
],
|
||||
@@ -336,7 +339,7 @@ router.get(
|
||||
unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true,
|
||||
q: req.query.q,
|
||||
},
|
||||
sort: req.query.sort || 'newest',
|
||||
sort: req.query.sort || 'issue_desc',
|
||||
page: req.query.page ? parseInt(req.query.page, 10) : 1,
|
||||
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
|
||||
});
|
||||
@@ -417,6 +420,8 @@ router.post(
|
||||
// currency 3-letter ISO (optional, default profile/CHF)
|
||||
// status 'sent' | 'paid' | 'overdue' (default 'sent')
|
||||
// paidAmountMinor int (optional, for status='paid')
|
||||
// paidAt ISO date (optional — the real historical payment
|
||||
// date; defaults to issueDate, never import time)
|
||||
// language string (optional, default 'de')
|
||||
router.post(
|
||||
'/import',
|
||||
@@ -425,12 +430,15 @@ router.post(
|
||||
[
|
||||
body('customerAccountId').isInt({ min: 1 }),
|
||||
body('invoiceNumber').isString().isLength({ min: 1, max: 64 }),
|
||||
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
|
||||
body('issueDate').isISO8601(),
|
||||
body('dueDate').optional({ values: 'falsy' }).isISO8601(),
|
||||
body('totalAmountMinor').isInt({ min: 0 }),
|
||||
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||
body('status').optional({ values: 'falsy' }).isIn(['sent', 'paid', 'overdue']),
|
||||
body('paidAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
|
||||
body('paidAt').optional({ values: 'falsy' }).isISO8601(),
|
||||
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||
],
|
||||
handleAsync(async (req, res) => {
|
||||
@@ -466,10 +474,24 @@ router.post(
|
||||
}
|
||||
|
||||
const totalMinor = parseInt(req.body.totalAmountMinor, 10);
|
||||
const paidMinor = parseInt(req.body.paidAmountMinor || '0', 10) || 0;
|
||||
const status = req.body.status || 'sent';
|
||||
// A paid import with no explicit paid amount means FULLY paid — default
|
||||
// paid_amount_minor to the total. The dashboard revenue windows sum
|
||||
// paid_amount_minor (not total), so a blank paid amount used to store 0
|
||||
// and the paid invoice contributed nothing to revenue.
|
||||
const explicitPaid = req.body.paidAmountMinor != null && String(req.body.paidAmountMinor) !== '';
|
||||
const paidMinor = explicitPaid
|
||||
? (parseInt(req.body.paidAmountMinor, 10) || 0)
|
||||
: (status === 'paid' ? totalMinor : 0);
|
||||
const issueDate = req.body.issueDate;
|
||||
const dueDate = req.body.dueDate || issueDate;
|
||||
// Imported docs are historical: their real send/payment dates are
|
||||
// the document's own dates, NOT the moment of import. Stamping
|
||||
// import-time here put year-old paid invoices inside the dashboard's
|
||||
// rolling "Revenue · last 30 days" window (which keys on paid_at).
|
||||
// Anchor to the historical date; let the admin override paid_at when
|
||||
// they know the exact payment date.
|
||||
const paidAt = req.body.paidAt || issueDate;
|
||||
const currency = (req.body.currency || customer.preferred_currency || 'CHF').toUpperCase();
|
||||
const language = req.body.language || customer.preferred_language || 'de';
|
||||
|
||||
@@ -477,7 +499,12 @@ router.post(
|
||||
invoice_number: req.body.invoiceNumber,
|
||||
customer_account_id: customer.id,
|
||||
source_quote_id: null,
|
||||
// No FK link on import — the event may predate picpeak. Store the
|
||||
// free-text snapshot only, mirroring createInvoice's event_name /
|
||||
// event_date columns (migration 107).
|
||||
event_id: null,
|
||||
event_name: req.body.eventName || null,
|
||||
event_date: req.body.eventDate || null,
|
||||
language,
|
||||
currency,
|
||||
issue_date: issueDate,
|
||||
@@ -488,14 +515,14 @@ router.post(
|
||||
installment_trigger: null,
|
||||
status,
|
||||
scheduled_send_at: null,
|
||||
sent_at: status !== 'scheduled' ? new Date() : null,
|
||||
sent_at: new Date(issueDate),
|
||||
net_amount_minor: totalMinor, // imported docs lack a breakdown
|
||||
vat_rate: 0, // VAT info lives in the imported PDF
|
||||
vat_amount_minor: 0,
|
||||
shipping_amount_minor: 0,
|
||||
total_amount_minor: totalMinor,
|
||||
paid_amount_minor: paidMinor,
|
||||
paid_at: status === 'paid' ? new Date() : null,
|
||||
paid_at: status === 'paid' ? new Date(paidAt) : null,
|
||||
// Store the path RELATIVE to STORAGE_PATH so the value survives
|
||||
// a host migration (Docker volume remount on a new host with a
|
||||
// different absolute path).
|
||||
|
||||
@@ -105,6 +105,7 @@ function transformQuote(q) {
|
||||
responseLockedAt: q.response_locked_at,
|
||||
acceptedAt: q.accepted_at,
|
||||
declinedAt: q.declined_at,
|
||||
declineReason: q.decline_reason ?? null,
|
||||
convertedEventId: q.converted_event_id,
|
||||
// Migration 130 lineage. Null until quoteService.createFromQuote
|
||||
// sets it. Surfaced so QuoteDetailPage can render a "Linked
|
||||
@@ -257,7 +258,7 @@ router.get(
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
query('from').optional({ values: 'falsy' }).isISO8601(),
|
||||
query('to').optional({ values: 'falsy' }).isISO8601(),
|
||||
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'customer_asc', 'value_asc', 'value_desc']),
|
||||
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'customer_asc', 'customer_desc', 'value_asc', 'value_desc']),
|
||||
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
|
||||
],
|
||||
@@ -272,7 +273,7 @@ router.get(
|
||||
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
|
||||
from: req.query.from, to: req.query.to, q: req.query.q,
|
||||
},
|
||||
sort: req.query.sort || 'newest',
|
||||
sort: req.query.sort || 'issue_desc',
|
||||
page: req.query.page ? parseInt(req.query.page, 10) : 1,
|
||||
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
|
||||
});
|
||||
@@ -450,6 +451,24 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
// Admin "decline on behalf" — flips a draft/sent/expired quote to
|
||||
// `declined` without the customer's public link. For "they said no by
|
||||
// phone" workflows. Optional free-text reason persisted on the row.
|
||||
router.post(
|
||||
'/:id/decline',
|
||||
requirePermission('quotes.manage'),
|
||||
[
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('reason').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||
],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const result = await quoteService.adminDeclineQuote(id, req.admin.id, req.body.reason);
|
||||
return successResponse(res, result, 200, 'Quote declined');
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/convert',
|
||||
requirePermission('quotes.manage'),
|
||||
|
||||
@@ -73,10 +73,11 @@ const faviconStorage = multer.diskStorage({
|
||||
|
||||
const faviconUpload = multer({
|
||||
storage: faviconStorage,
|
||||
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — roomy enough for a 512×512+ square PNG
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
|
||||
|
||||
const name = file.originalname.toLowerCase();
|
||||
|
||||
// For ICO files, we can't use the standard validateFileType
|
||||
if (file.mimetype === 'image/png') {
|
||||
if (validateFileType(file.originalname, file.mimetype, ['image/png'])) {
|
||||
@@ -84,12 +85,16 @@ const faviconUpload = multer({
|
||||
} else {
|
||||
cb(new Error('Invalid PNG file'));
|
||||
}
|
||||
} else if (allowedMimeTypes.includes(file.mimetype) &&
|
||||
(file.originalname.toLowerCase().endsWith('.ico') ||
|
||||
file.originalname.toLowerCase().endsWith('.png'))) {
|
||||
} else if (file.mimetype === 'image/svg+xml' && name.endsWith('.svg')) {
|
||||
// SVG favicons are supported by modern browsers and are crisp at any
|
||||
// size. Served SVGs are CSP-locked (no script execution) by the
|
||||
// secureStatic middleware, so an admin-uploaded SVG is render-only.
|
||||
cb(null, true);
|
||||
} else if (allowedMimeTypes.includes(file.mimetype) &&
|
||||
(name.endsWith('.ico') || name.endsWith('.png'))) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Favicon must be PNG or ICO format'));
|
||||
cb(new Error('Favicon must be PNG, ICO, or SVG format'));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -544,9 +549,16 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
|
||||
return res.status(400).json({ error: 'No logo file uploaded' });
|
||||
}
|
||||
|
||||
// ?variant=dark stores a separate dark-mode logo (branding_logo_*_dark);
|
||||
// anything else is the default (light) logo. Consumers pick the dark
|
||||
// variant when the active theme is dark, falling back to the light one.
|
||||
const isDark = req.query.variant === 'dark' || req.body.variant === 'dark';
|
||||
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
|
||||
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
|
||||
|
||||
// Get old logo to delete
|
||||
const oldLogoSetting = await db('app_settings')
|
||||
.where('setting_key', 'branding_logo_path')
|
||||
.where('setting_key', pathKey)
|
||||
.first();
|
||||
|
||||
if (oldLogoSetting && oldLogoSetting.setting_value) {
|
||||
@@ -568,7 +580,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
|
||||
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: 'branding_logo_path',
|
||||
setting_key: pathKey,
|
||||
setting_value: JSON.stringify(logoPath),
|
||||
setting_type: 'branding',
|
||||
updated_at: new Date()
|
||||
@@ -582,7 +594,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
|
||||
// Save public URL
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: 'branding_logo_url',
|
||||
setting_key: urlKey,
|
||||
setting_value: JSON.stringify(publicPath),
|
||||
setting_type: 'branding',
|
||||
updated_at: new Date()
|
||||
@@ -603,6 +615,36 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
|
||||
}
|
||||
});
|
||||
|
||||
// Remove a logo. ?variant=dark clears the dark-mode logo
|
||||
// (branding_logo_*_dark); otherwise the default logo. Best-effort file
|
||||
// unlink, then blanks the url + path settings.
|
||||
router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const isDark = req.query.variant === 'dark';
|
||||
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
|
||||
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
|
||||
|
||||
const pathSetting = await db('app_settings').where('setting_key', pathKey).first();
|
||||
if (pathSetting && pathSetting.setting_value) {
|
||||
try {
|
||||
let p = pathSetting.setting_value;
|
||||
if (p.startsWith('"')) p = JSON.parse(p);
|
||||
await fs.unlink(p);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete logo file:', error);
|
||||
}
|
||||
}
|
||||
await db('app_settings')
|
||||
.whereIn('setting_key', [pathKey, urlKey])
|
||||
.update({ setting_value: JSON.stringify(''), updated_at: new Date() });
|
||||
|
||||
res.json({ message: 'Logo removed' });
|
||||
} catch (error) {
|
||||
console.error('Logo delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to remove logo' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload watermark logo
|
||||
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -23,6 +23,7 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { verifyDocumentArtefacts } = require('../services/backupIntegrityService');
|
||||
const { getCoverageReport } = require('../services/backupCoverageService');
|
||||
const { db } = require('../database/db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -84,4 +85,81 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/admin/system-health/failures
|
||||
*
|
||||
* Surfaces background failures that would otherwise go unnoticed. v1
|
||||
* covers stuck/failed outbound emails: rows the queue processor has
|
||||
* given up on (status='failed') or exhausted its retries on
|
||||
* (status='pending' AND retry_count >= 3 — the processor only picks up
|
||||
* retry_count < 3). Trigger: a 14h window where 'quote_sent' template
|
||||
* errors left invoices unsent with no admin-visible signal.
|
||||
*/
|
||||
router.get(
|
||||
'/failures',
|
||||
requirePermission('settings.view'),
|
||||
handleAsync(async (req, res) => {
|
||||
const stuckEmails = await db('email_queue')
|
||||
.where(function () {
|
||||
this.where('status', 'failed')
|
||||
.orWhere(function () {
|
||||
this.where('status', 'pending').andWhere('retry_count', '>=', 3);
|
||||
});
|
||||
})
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(200)
|
||||
.select('id', 'recipient_email', 'email_type', 'status', 'retry_count', 'error_message', 'created_at');
|
||||
|
||||
return successResponse(res, {
|
||||
stuckEmails: stuckEmails.map((r) => ({
|
||||
id: r.id,
|
||||
recipientEmail: r.recipient_email,
|
||||
emailType: r.email_type,
|
||||
status: r.status,
|
||||
retryCount: r.retry_count,
|
||||
errorMessage: r.error_message,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
counts: { stuckEmails: stuckEmails.length },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /failures/email/:id/retry — re-queue a stuck email (status back to
|
||||
* pending, retry_count reset, error cleared, scheduled_at cleared so the
|
||||
* 60s processor picks it up on its next pass).
|
||||
*/
|
||||
router.post(
|
||||
'/failures/email/:id/retry',
|
||||
requirePermission('settings.edit'),
|
||||
handleAsync(async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
|
||||
const updated = await db('email_queue').where({ id }).update({
|
||||
status: 'pending',
|
||||
retry_count: 0,
|
||||
error_message: null,
|
||||
scheduled_at: null,
|
||||
});
|
||||
if (!updated) return res.status(404).json({ error: 'Email not found' });
|
||||
return successResponse(res, { retried: true });
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /failures/email/:id — dismiss a stuck email (remove the row so
|
||||
* it stops surfacing). Use when the failure is understood + won't be sent.
|
||||
*/
|
||||
router.delete(
|
||||
'/failures/email/:id',
|
||||
requirePermission('settings.edit'),
|
||||
handleAsync(async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
|
||||
await db('email_queue').where({ id }).del();
|
||||
return successResponse(res, { dismissed: true });
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -24,6 +24,15 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const contractService = require('../services/contractService');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
|
||||
// Normalise a Settings → Branding logo value (absolute URL, /-rooted path,
|
||||
// or bare `uploads/...` filename) into a URL the public page can load.
|
||||
function normalizeBrandingLogoUrl(raw) {
|
||||
const value = (raw && String(raw).trim()) || null;
|
||||
if (!value) return null;
|
||||
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
|
||||
return `/uploads/${value.replace(/^uploads\//, '')}`;
|
||||
}
|
||||
const { clientIpForAudit } = require('../utils/clientIp');
|
||||
const { loadActionToken, preMulterTokenGuard } = require('../utils/publicTokenGuards');
|
||||
const { db } = require('../database/db');
|
||||
@@ -69,7 +78,7 @@ const signedPdfUpload = multer({
|
||||
* The IP / signature image paths are NEVER exposed publicly even after
|
||||
* signing — they're audit evidence.
|
||||
*/
|
||||
function publicContractView(contract, inclusions, customer, profile, locale) {
|
||||
function publicContractView(contract, inclusions, customer, profile, locale, brandingLogoUrl, brandingLogoUrlDark) {
|
||||
const orderedSections = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
|
||||
const blocksBySection = {};
|
||||
for (const s of orderedSections) blocksBySection[s] = [];
|
||||
@@ -143,6 +152,12 @@ function publicContractView(contract, inclusions, customer, profile, locale) {
|
||||
city: profile.city,
|
||||
email: profile.email,
|
||||
website: profile.website,
|
||||
// Light + dark branding logos (Settings → Branding), so the public
|
||||
// sign page renders the logo that reads in its resolved colour mode.
|
||||
// Mirrors publicQuotes — the print-only business_profile.logo_path is
|
||||
// intentionally NOT used here.
|
||||
logoUrl: normalizeBrandingLogoUrl(brandingLogoUrl),
|
||||
logoUrlDark: normalizeBrandingLogoUrl(brandingLogoUrlDark),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
@@ -168,12 +183,16 @@ router.get(
|
||||
// re-enforces both, so client tampering only changes the UX.
|
||||
const allowPdfUpload = (await getAppSetting('crm_contracts_allow_pdf_upload')) !== false;
|
||||
const requireDrawnSignature = (await getAppSetting('crm_contracts_require_drawn_signature')) === true;
|
||||
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
|
||||
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
|
||||
const view = publicContractView(
|
||||
data.contract,
|
||||
data.inclusions,
|
||||
customer,
|
||||
profile,
|
||||
data.contract.language || 'de',
|
||||
brandingLogoUrl,
|
||||
brandingLogoUrlDark,
|
||||
);
|
||||
view.allowPdfUpload = allowPdfUpload;
|
||||
view.requireDrawnSignature = requireDrawnSignature;
|
||||
|
||||
@@ -50,16 +50,20 @@ router.get(
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const profile = await db('business_profile').where({ id: 1 }).first();
|
||||
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
|
||||
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
|
||||
const toUrl = (raw) => {
|
||||
const value = (raw && String(raw).trim()) || null;
|
||||
if (!value) return null;
|
||||
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
|
||||
return `/uploads/${value.replace(/^uploads\//, '')}`;
|
||||
};
|
||||
const issuer = profile ? {
|
||||
companyName: profile.company_name || '',
|
||||
email: profile.email || '',
|
||||
website: profile.website || '',
|
||||
logoUrl: (() => {
|
||||
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null;
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw;
|
||||
return `/uploads/${raw.replace(/^uploads\//, '')}`;
|
||||
})(),
|
||||
// Light + dark branding logos — the page picks per its colour mode.
|
||||
logoUrl: toUrl(brandingLogoUrl),
|
||||
logoUrlDark: toUrl(brandingLogoUrlDark),
|
||||
} : null;
|
||||
|
||||
return successResponse(res, { invoice: view, issuer });
|
||||
|
||||
@@ -23,6 +23,15 @@ const { loadActionToken } = require('../utils/publicTokenGuards');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Normalise a Settings → Branding logo value (absolute URL, /-rooted path,
|
||||
// or bare `uploads/...` filename) into a URL the public page can load.
|
||||
function normalizeBrandingLogoUrl(raw) {
|
||||
const value = (raw && String(raw).trim()) || null;
|
||||
if (!value) return null;
|
||||
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
|
||||
return `/uploads/${value.replace(/^uploads\//, '')}`;
|
||||
}
|
||||
|
||||
// Rate-limit: 30 token previews per IP per minute, 10 responses.
|
||||
const previewLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
|
||||
@@ -31,7 +40,7 @@ const respondLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
|
||||
});
|
||||
|
||||
function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl) {
|
||||
function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl, brandingLogoUrlDark) {
|
||||
return {
|
||||
quoteNumber: quote.quote_number,
|
||||
status: quote.status,
|
||||
@@ -98,13 +107,11 @@ function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosTe
|
||||
// there). On the web page the existing site branding already
|
||||
// serves both light + dark modes correctly, so falling back
|
||||
// to a PDF-only image would override that with a light
|
||||
// version that doesn't read in dark mode.
|
||||
logoUrl: (() => {
|
||||
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null;
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw;
|
||||
return `/uploads/${raw.replace(/^uploads\//, '')}`;
|
||||
})(),
|
||||
// version that doesn't read in dark mode. Both light + dark
|
||||
// branding URLs are surfaced so the page can pick the one that
|
||||
// matches its resolved colour mode (see usePublicDarkMode).
|
||||
logoUrl: normalizeBrandingLogoUrl(brandingLogoUrl),
|
||||
logoUrlDark: normalizeBrandingLogoUrl(brandingLogoUrlDark),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
@@ -137,9 +144,10 @@ router.get(
|
||||
// — admins typically upload one logo via Settings → Branding and
|
||||
// expect it to flow through the customer-facing pages too.
|
||||
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
|
||||
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
|
||||
|
||||
return successResponse(res, {
|
||||
quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl),
|
||||
quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl, brandingLogoUrlDark),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -59,6 +59,7 @@ router.get('/', async (req, res) => {
|
||||
branding_watermark_size: settingsObject.branding_watermark_size || 15,
|
||||
branding_favicon_url: settingsObject.branding_favicon_url || '',
|
||||
branding_logo_url: settingsObject.branding_logo_url || '',
|
||||
branding_logo_url_dark: settingsObject.branding_logo_url_dark || '',
|
||||
branding_logo_size: settingsObject.branding_logo_size || 'medium',
|
||||
branding_logo_max_height: settingsObject.branding_logo_max_height || 48,
|
||||
branding_logo_position: settingsObject.branding_logo_position || 'left',
|
||||
|
||||
@@ -17,6 +17,7 @@ const { db, withRetry } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { normaliseSchedule } = require('../utils/businessHours');
|
||||
|
||||
const ALLOWED_PROFILE_FIELDS = [
|
||||
'company_name',
|
||||
@@ -41,6 +42,10 @@ const ALLOWED_PROFILE_FIELDS = [
|
||||
'tax_id',
|
||||
'vat_label',
|
||||
'vat_rate_default',
|
||||
// Install-wide fallback hourly rate (migration 113), minor units.
|
||||
// Last link in the hour-entry rate chain after the per-entry
|
||||
// override and the per-customer default.
|
||||
'default_hourly_rate_minor',
|
||||
'default_currency',
|
||||
'default_locale',
|
||||
'default_qr_format',
|
||||
@@ -75,6 +80,11 @@ const ALLOWED_PROFILE_FIELDS = [
|
||||
// by the calendar UI to render timed blocks in the operator's
|
||||
// working tz. Admin-only; never exposed via publicSettings.
|
||||
'timezone',
|
||||
// Per-ISO-weekday opening hours (migration 114). JSON TEXT; drives the
|
||||
// scheduled-email floor and is interpreted in `timezone`.
|
||||
'business_hours',
|
||||
// Master switch for the scheduled-email business-hours floor (mig 114).
|
||||
'scheduled_email_floor_enabled',
|
||||
];
|
||||
|
||||
const ALLOWED_BANK_FIELDS = [
|
||||
@@ -161,6 +171,32 @@ function sanitiseProfilePayload(payload) {
|
||||
? Math.max(24, Math.min(200, n))
|
||||
: 56;
|
||||
}
|
||||
// Install-wide default hourly rate (minor units). Empty / null clears
|
||||
// it back to "no global default"; otherwise coerce to a non-negative
|
||||
// integer so a stray decimal can't land sub-cent values in the column.
|
||||
if (updates.default_hourly_rate_minor !== undefined) {
|
||||
if (updates.default_hourly_rate_minor === null || updates.default_hourly_rate_minor === '') {
|
||||
updates.default_hourly_rate_minor = null;
|
||||
} else {
|
||||
const n = parseInt(updates.default_hourly_rate_minor, 10);
|
||||
updates.default_hourly_rate_minor = Number.isFinite(n) && n >= 0 ? n : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-weekday opening hours. Accept the API object (or a JSON string),
|
||||
// run it through the shared validator (drops bad blocks, sorts, fills
|
||||
// all 7 days), and persist the canonical JSON string. An explicit
|
||||
// null / '' clears the schedule back to "no hours configured".
|
||||
if (updates.business_hours !== undefined) {
|
||||
if (updates.business_hours === null || updates.business_hours === '') {
|
||||
updates.business_hours = null;
|
||||
} else {
|
||||
updates.business_hours = JSON.stringify(normaliseSchedule(updates.business_hours));
|
||||
}
|
||||
}
|
||||
if (updates.scheduled_email_floor_enabled !== undefined) {
|
||||
updates.scheduled_email_floor_enabled = formatBoolean(Boolean(updates.scheduled_email_floor_enabled));
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ async function buildRenderContext(contract, inclusions) {
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function listContracts({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) {
|
||||
async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
|
||||
return await withRetry(async () => {
|
||||
let query = db('contracts')
|
||||
.leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id')
|
||||
@@ -658,11 +658,22 @@ async function listContracts({ filters = {}, sort = 'newest', page = 1, pageSize
|
||||
case 'oldest':
|
||||
query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc');
|
||||
break;
|
||||
case 'issue_asc':
|
||||
query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc');
|
||||
break;
|
||||
case 'issue_desc':
|
||||
query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc');
|
||||
break;
|
||||
case 'customer_asc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
|
||||
.orderBy('contracts.id', 'desc');
|
||||
break;
|
||||
case 'customer_desc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
|
||||
.orderBy('contracts.id', 'desc');
|
||||
break;
|
||||
case 'newest':
|
||||
default:
|
||||
query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc');
|
||||
|
||||
@@ -555,6 +555,9 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
||||
// Hour-logging default rate (migration 129). Minor units; null
|
||||
// means admin must enter a per-entry override on every entry.
|
||||
'hourly_rate_minor',
|
||||
// Per-customer Skonto opt-out (migration 112). Boolean, coerced
|
||||
// via formatBoolean below for SQLite compatibility.
|
||||
'skonto_disabled',
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (updates[f] !== undefined) {
|
||||
@@ -567,6 +570,7 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
||||
} else if (
|
||||
f === 'feature_calendar' || f === 'feature_quotes'
|
||||
|| f === 'feature_bills' || f === 'feature_hours_logging'
|
||||
|| f === 'skonto_disabled'
|
||||
) {
|
||||
allowed[f] = formatBoolean(updates[f]);
|
||||
} else if (f === 'hourly_rate_minor') {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const logger = require('../utils/logger');
|
||||
const invoiceService = require('./invoiceService');
|
||||
|
||||
@@ -50,24 +51,52 @@ function computeDurationMinutes(start, end) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the rate this entry should bill at. Override on the entry
|
||||
* wins; otherwise we fall back to the customer's default rate. If
|
||||
* neither is set we throw — saves can't go through without a rate.
|
||||
* Resolve the rate this entry should bill at. Resolution chain:
|
||||
* 1. per-entry override
|
||||
* 2. per-customer default rate
|
||||
* 3. install-wide default rate (business_profile, migration 113)
|
||||
* Only when all three are unset do we throw — the hours UI surfaces a
|
||||
* "set a rate" CTA off the back of HOURLY_RATE_REQUIRED rather than a
|
||||
* raw error. `installDefaultMinor` is loaded once per request by the
|
||||
* caller (see getInstallDefaultRateMinor) and passed in so this stays
|
||||
* a pure function.
|
||||
*/
|
||||
function resolveEffectiveRate(entry, customer) {
|
||||
function resolveEffectiveRate(entry, customer, installDefaultMinor = null) {
|
||||
if (entry.hourly_rate_minor_override != null) {
|
||||
return Number(entry.hourly_rate_minor_override);
|
||||
}
|
||||
if (customer.hourly_rate_minor != null) {
|
||||
return Number(customer.hourly_rate_minor);
|
||||
}
|
||||
if (installDefaultMinor != null) {
|
||||
return Number(installDefaultMinor);
|
||||
}
|
||||
throw new AppError(
|
||||
'No hourly rate: set a per-entry override or a customer default.',
|
||||
'No hourly rate: set a per-entry override, a customer default, or an install-wide default rate.',
|
||||
400,
|
||||
'HOURLY_RATE_REQUIRED',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the install-wide default hourly rate (minor units) off the
|
||||
* singleton business_profile row. Returns null when unset OR when the
|
||||
* column doesn't exist yet (pre-migration-113 install) — callers then
|
||||
* fall through to the HOURLY_RATE_REQUIRED path. Accepts an optional
|
||||
* transaction so it joins the caller's atomic unit.
|
||||
*/
|
||||
async function getInstallDefaultRateMinor(trx) {
|
||||
const conn = trx || db;
|
||||
if (!(await hasColumnCached('business_profile', 'default_hourly_rate_minor'))) {
|
||||
return null;
|
||||
}
|
||||
const row = await conn('business_profile').where({ id: 1 })
|
||||
.first('default_hourly_rate_minor');
|
||||
return row && row.default_hourly_rate_minor != null
|
||||
? Number(row.default_hourly_rate_minor)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an entry is still editable. Pure function — callers
|
||||
* pass the loaded entry + (optionally) its current invoice row.
|
||||
@@ -181,9 +210,14 @@ async function createEntry(customerId, payload, adminId) {
|
||||
}
|
||||
const description = payload.description ? String(payload.description).slice(0, 1000) : null;
|
||||
|
||||
// Install-wide fallback rate (migration 113) — the last link in the
|
||||
// resolution chain. Loaded once and reused for the pre-validate and
|
||||
// the accumulator append below.
|
||||
const installDefaultMinor = await getInstallDefaultRateMinor();
|
||||
|
||||
// Pre-validate the rate resolves to something — fail before insert
|
||||
// if neither override nor customer default is set.
|
||||
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer);
|
||||
// if neither override, customer default, nor install default is set.
|
||||
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer, installDefaultMinor);
|
||||
|
||||
return await db.transaction(async (trx) => {
|
||||
const row = {
|
||||
@@ -202,10 +236,12 @@ async function createEntry(customerId, payload, adminId) {
|
||||
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
|
||||
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
// Monthly-mode customers get the auto-append treatment.
|
||||
if (customer.billing_cadence === 'monthly') {
|
||||
// Accumulator-mode customers (monthly + manual) get the auto-append
|
||||
// treatment — the entry lands on the running draft instead of staying
|
||||
// unbilled. Manual differs only in that its draft never auto-flushes.
|
||||
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
|
||||
const fullEntry = { ...row, id: entryId };
|
||||
const rate = resolveEffectiveRate(fullEntry, customer);
|
||||
const rate = resolveEffectiveRate(fullEntry, customer, installDefaultMinor);
|
||||
const lineItem = buildLineItemFromEntry(fullEntry, rate);
|
||||
const { invoiceId, lineItemId } = await invoiceService.appendOneLineItemToMonthlyDraft(
|
||||
customer, lineItem, adminId, trx,
|
||||
@@ -286,7 +322,8 @@ async function updateEntry(entryId, payload, adminId) {
|
||||
// Recompute the linked line item if the entry is billed (on a
|
||||
// draft — the lock check above already proved it's mutable).
|
||||
if (entry.invoice_id && entry.invoice_line_item_id) {
|
||||
const rate = resolveEffectiveRate(next, customer);
|
||||
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
|
||||
const rate = resolveEffectiveRate(next, customer, installDefaultMinor);
|
||||
const newLineItem = buildLineItemFromEntry(next, rate);
|
||||
await trx('invoice_line_items').where({ id: entry.invoice_line_item_id }).update({
|
||||
description: newLineItem.description,
|
||||
@@ -387,15 +424,16 @@ async function deleteEntry(entryId, adminId) {
|
||||
/**
|
||||
* Per-event flow: mint a standalone invoice from all unbilled entries
|
||||
* for this customer, one line per entry. Refuses when the customer is
|
||||
* monthly-mode (those entries auto-billed on save, so there should be
|
||||
* no unbilled rows). Returns the new invoice id.
|
||||
* in an accumulator mode (monthly / manual) — those entries auto-billed
|
||||
* onto the running draft on save, so there should be no unbilled rows.
|
||||
* Returns the new invoice id.
|
||||
*/
|
||||
async function billUnbilledEntries(customerId, adminId) {
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
if (!customer) throw new AppError('Customer not found', 404);
|
||||
if (customer.billing_cadence === 'monthly') {
|
||||
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
|
||||
throw new AppError(
|
||||
'Monthly-mode customers auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
|
||||
'Accumulator-mode customers (monthly / manual) auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
|
||||
409,
|
||||
'CADENCE_MISMATCH',
|
||||
);
|
||||
@@ -409,8 +447,9 @@ async function billUnbilledEntries(customerId, adminId) {
|
||||
throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED');
|
||||
}
|
||||
|
||||
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
|
||||
const lineItems = unbilled.map((entry, idx) => {
|
||||
const rate = resolveEffectiveRate(entry, customer);
|
||||
const rate = resolveEffectiveRate(entry, customer, installDefaultMinor);
|
||||
const li = buildLineItemFromEntry(entry, rate);
|
||||
return { ...li, position: idx + 1 };
|
||||
});
|
||||
@@ -456,12 +495,85 @@ async function billUnbilledEntries(customerId, adminId) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing aggregate for /admin/clients/hours: one row per customer that
|
||||
* currently carries unbilled hour entries, with the open hours + open
|
||||
* monetary amount. In practice only per-event customers surface here —
|
||||
* monthly/manual cadences auto-append each entry onto the running draft
|
||||
* at save time (status flips straight to 'billed'), so they never leave
|
||||
* unbilled rows behind. Each entry's amount resolves through the usual
|
||||
* override → customer-rate → install-default chain; if an entry has no
|
||||
* resolvable rate it still counts toward hours/entries but the row is
|
||||
* flagged rateResolvable=false so the UI can prompt for a rate rather
|
||||
* than silently undercounting. Sorted by open amount desc.
|
||||
*/
|
||||
async function getUnbilledSummaryByCustomer() {
|
||||
const installDefaultMinor = await getInstallDefaultRateMinor();
|
||||
const rows = await db('customer_hour_entries as h')
|
||||
.join('customer_accounts as c', 'h.customer_account_id', 'c.id')
|
||||
.where('h.status', 'unbilled')
|
||||
.select(
|
||||
'h.customer_account_id',
|
||||
'h.duration_minutes',
|
||||
'h.hourly_rate_minor_override',
|
||||
'c.hourly_rate_minor as customer_hourly_rate_minor',
|
||||
'c.company_name',
|
||||
'c.display_name',
|
||||
'c.first_name',
|
||||
'c.last_name',
|
||||
'c.email',
|
||||
'c.password_hash',
|
||||
'c.billing_cadence',
|
||||
);
|
||||
|
||||
const byCustomer = new Map();
|
||||
for (const r of rows) {
|
||||
let agg = byCustomer.get(r.customer_account_id);
|
||||
if (!agg) {
|
||||
agg = {
|
||||
customerAccountId: r.customer_account_id,
|
||||
companyName: r.company_name || null,
|
||||
displayName: r.display_name || null,
|
||||
firstName: r.first_name || null,
|
||||
lastName: r.last_name || null,
|
||||
email: r.email || null,
|
||||
// passive = no portal password set, same rule as the customer
|
||||
// list / picker (adminCustomers transform).
|
||||
isPassive: r.password_hash == null,
|
||||
billingCadence: r.billing_cadence || null,
|
||||
entryCount: 0,
|
||||
totalMinutes: 0,
|
||||
openAmountMinor: 0,
|
||||
rateResolvable: true,
|
||||
};
|
||||
byCustomer.set(r.customer_account_id, agg);
|
||||
}
|
||||
agg.entryCount += 1;
|
||||
const minutes = Number(r.duration_minutes || 0);
|
||||
agg.totalMinutes += minutes;
|
||||
let rateMinor = null;
|
||||
if (r.hourly_rate_minor_override != null) rateMinor = Number(r.hourly_rate_minor_override);
|
||||
else if (r.customer_hourly_rate_minor != null) rateMinor = Number(r.customer_hourly_rate_minor);
|
||||
else if (installDefaultMinor != null) rateMinor = installDefaultMinor;
|
||||
if (rateMinor == null) {
|
||||
agg.rateResolvable = false;
|
||||
} else {
|
||||
agg.openAmountMinor += Math.round((minutes / 60) * rateMinor);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byCustomer.values())
|
||||
.sort((a, b) => b.openAmountMinor - a.openAmountMinor);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listEntries,
|
||||
getUnbilledSummaryByCustomer,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
billUnbilledEntries,
|
||||
getInstallDefaultRateMinor,
|
||||
_internal: {
|
||||
computeDurationMinutes,
|
||||
resolveEffectiveRate,
|
||||
|
||||
@@ -2,6 +2,11 @@ const nodemailer = require('nodemailer');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
const {
|
||||
snapToBusinessHours,
|
||||
normaliseSchedule,
|
||||
} = require('../utils/businessHours');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
|
||||
let transporter = null;
|
||||
let lastConfigHash = null;
|
||||
@@ -735,10 +740,22 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
}
|
||||
}
|
||||
|
||||
// Process email queue
|
||||
async function processEmailQueue() {
|
||||
// Process email queue.
|
||||
//
|
||||
// Options:
|
||||
// ignoreSchedule when true, send every pending email regardless of its
|
||||
// `scheduled_at` floor (used by the admin "send now" flush
|
||||
// before maintenance/updates). The scheduled interval run
|
||||
// leaves it false so future-dated emails keep waiting.
|
||||
// limit max emails per pass. The flush raises this to drain the
|
||||
// whole queue in a single pass (no re-query, so a failing
|
||||
// email isn't retried in a tight loop within one flush).
|
||||
//
|
||||
// Returns { processed, sent, failed }.
|
||||
async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) {
|
||||
logger.info('Email queue processor: Checking for pending emails...');
|
||||
|
||||
const result = { processed: 0, sent: 0, failed: 0 };
|
||||
|
||||
try {
|
||||
// Try to initialize transporter if it's null (in case it failed at startup)
|
||||
if (!transporter) {
|
||||
@@ -746,37 +763,46 @@ async function processEmailQueue() {
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
logger.warn('Email transporter could not be initialized, skipping queue processing');
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let pendingEmails = [];
|
||||
try {
|
||||
// Pick up emails that are pending AND either have no `scheduled_at`
|
||||
// or whose scheduled_at is in the past. Used by CRM invoices to
|
||||
// queue split-payment emails relative to the event date.
|
||||
const now = new Date();
|
||||
pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.andWhere(function() {
|
||||
const query = db('email_queue')
|
||||
.where('status', 'pending');
|
||||
if (!ignoreSchedule) {
|
||||
// Automatic runs: respect the retry cap (don't hammer a failing
|
||||
// address) AND the schedule (business-hours floor / future send).
|
||||
query.where('retry_count', '<', 3).andWhere(function() {
|
||||
this.whereNull('scheduled_at').orWhere('scheduled_at', '<=', now);
|
||||
})
|
||||
});
|
||||
}
|
||||
// A manual "send now" (ignoreSchedule) deliberately bypasses BOTH the
|
||||
// schedule and the retry cap: the admin is forcing a retry, typically
|
||||
// right after fixing SMTP. Without this, emails that failed 3× during
|
||||
// an SMTP outage are stuck "pending" forever with no way to resend.
|
||||
pendingEmails = await query
|
||||
.orderBy('scheduled_at', 'asc')
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(10);
|
||||
.limit(limit);
|
||||
} catch (dbError) {
|
||||
logger.error('Failed to query email queue:', dbError);
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
if (pendingEmails.length === 0) {
|
||||
logger.info('Email queue processor: No pending emails found');
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
logger.info(`Processing ${pendingEmails.length} emails from queue`);
|
||||
|
||||
result.processed = pendingEmails.length;
|
||||
|
||||
for (const email of pendingEmails) {
|
||||
try {
|
||||
const emailData = typeof email.email_data === 'string'
|
||||
@@ -796,9 +822,11 @@ async function processEmailQueue() {
|
||||
status: 'sent',
|
||||
sent_at: new Date()
|
||||
});
|
||||
|
||||
|
||||
result.sent += 1;
|
||||
logger.info(`Email ${email.id} sent successfully`);
|
||||
} catch (error) {
|
||||
result.failed += 1;
|
||||
// Increment retry count
|
||||
try {
|
||||
await db('email_queue')
|
||||
@@ -825,12 +853,66 @@ async function processEmailQueue() {
|
||||
} catch (error) {
|
||||
logger.error('Error processing email queue:', error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Load + normalise the business-hours config used by queueEmail. The
|
||||
// definition lives on the singleton business_profile row (migration 114):
|
||||
// business_hours JSON, per-ISO-weekday opening blocks
|
||||
// scheduled_email_floor_enabled master on/off switch
|
||||
// timezone IANA zone the blocks are read in
|
||||
// Any failure (column missing on a half-migrated install, bad data) or an
|
||||
// unconfigured schedule degrades to `enabled: false` so a queued email is
|
||||
// never lost — it just sends at its original time.
|
||||
async function getScheduledEmailConfig() {
|
||||
try {
|
||||
if (!(await hasColumnCached('business_profile', 'business_hours'))) {
|
||||
return { enabled: false };
|
||||
}
|
||||
const hasToggle = await hasColumnCached('business_profile', 'scheduled_email_floor_enabled');
|
||||
const cols = ['business_hours', 'timezone'];
|
||||
if (hasToggle) cols.push('scheduled_email_floor_enabled');
|
||||
const profile = await db('business_profile').where({ id: 1 }).first(cols);
|
||||
if (!profile) return { enabled: false };
|
||||
|
||||
const enabled = hasToggle
|
||||
? (profile.scheduled_email_floor_enabled === true
|
||||
|| profile.scheduled_email_floor_enabled === 1
|
||||
|| profile.scheduled_email_floor_enabled === '1')
|
||||
: true;
|
||||
if (!enabled) return { enabled: false };
|
||||
|
||||
const schedule = normaliseSchedule(profile.business_hours);
|
||||
|
||||
let timezone = (profile.timezone || '').trim();
|
||||
if (!timezone) timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
// Reject a bogus tz before it reaches Intl in the snap helper.
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
||||
} catch (_) {
|
||||
timezone = 'UTC';
|
||||
}
|
||||
|
||||
return { enabled: true, timezone, schedule };
|
||||
} catch (err) {
|
||||
logger.warn(`Business-hours config unavailable, skipping email floor: ${err.message}`);
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Queue an email for sending. Optionally takes a 5th `options` arg:
|
||||
// options.scheduledAt — Date | ISO string; row only picks up once
|
||||
// this moment has passed (used by CRM split-
|
||||
// payment invoices). NULL = send immediately.
|
||||
// options.respectBusinessHours — when true, snap the send time to the
|
||||
// next open business-hours block (from "now").
|
||||
// Use for automated/relationship mail (dunning
|
||||
// reminders, gallery-expiry warnings) so we don't
|
||||
// ping customers overnight. No-op when the floor
|
||||
// is off / business hours unconfigured / already
|
||||
// inside a block. Leave it off for transactional
|
||||
// + admin-initiated mail so those send instantly.
|
||||
// Attachments + cc travel inside `emailData` (keys: attachments, cc)
|
||||
// so callers don't need a new signature for every email shape.
|
||||
async function queueEmail(eventId, recipientEmail, emailType, emailData, options = {}) {
|
||||
@@ -846,15 +928,36 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
|
||||
retry_count: 0,
|
||||
created_at: new Date(),
|
||||
};
|
||||
if (options.scheduledAt) {
|
||||
row.scheduled_at = options.scheduledAt instanceof Date
|
||||
? options.scheduledAt
|
||||
: new Date(options.scheduledAt);
|
||||
let snappedFrom = null;
|
||||
// Base time to schedule from:
|
||||
// - explicit options.scheduledAt (CRM split-payment invoices), OR
|
||||
// - "now" when the caller opts into the business-hours floor via
|
||||
// options.respectBusinessHours — automated / relationship mail
|
||||
// like dunning reminders + gallery-expiry warnings, so we don't
|
||||
// ping the customer at 02:00.
|
||||
// Both snap to the next open business-hours block. No-op when the
|
||||
// floor is disabled, business hours are unconfigured, or the instant
|
||||
// already lands inside a block. Transactional / admin-initiated mail
|
||||
// (invoice_sent, storno, invitations, password resets) passes neither
|
||||
// option and sends immediately.
|
||||
const baseTime = options.scheduledAt
|
||||
? (options.scheduledAt instanceof Date ? options.scheduledAt : new Date(options.scheduledAt))
|
||||
: (options.respectBusinessHours ? new Date() : null);
|
||||
if (baseTime) {
|
||||
const cfg = await getScheduledEmailConfig();
|
||||
const snapped = snapToBusinessHours(baseTime, cfg);
|
||||
if (snapped.getTime() !== baseTime.getTime()) snappedFrom = baseTime;
|
||||
// Persist a future scheduled_at for an explicit scheduledAt always;
|
||||
// for the respectBusinessHours floor only when it actually moved the
|
||||
// time forward (inside hours → leave null → processor sends at once).
|
||||
if (options.scheduledAt || snappedFrom) row.scheduled_at = snapped;
|
||||
}
|
||||
await db('email_queue').insert(row);
|
||||
|
||||
logger.info(`Email queued: ${emailType} to ${recipientEmail}${
|
||||
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()})` : ''
|
||||
row.scheduled_at ? ` (scheduled ${row.scheduled_at.toISOString()}${
|
||||
snappedFrom ? `, floored from ${snappedFrom.toISOString()}` : ''
|
||||
})` : ''
|
||||
}`);
|
||||
} catch (error) {
|
||||
logger.error('Error queueing email:', error);
|
||||
|
||||
@@ -85,7 +85,8 @@ async function queueExpirationWarning(event) {
|
||||
expiry_date: event.expires_at,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: '{{password_security_message}}'
|
||||
});
|
||||
// Relationship mail — hold to business hours (no-op unless configured).
|
||||
}, { respectBusinessHours: true });
|
||||
|
||||
logger.info(`Queued expiration warning for event ${event.slug}`);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,65 @@ function computeDueDate(scheduledSendAt, netDays = 30) {
|
||||
return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the net-days a new invoice's due date should be anchored to.
|
||||
* Single source of truth so the editor (split picker), legacy callers,
|
||||
* and quote→invoice conversion all land on the same number. Priority:
|
||||
*
|
||||
* 1. `payload.netDays` — explicit caller override (installment spawn
|
||||
* passes the snapshot's net_days here).
|
||||
* 2. Split picker (migration 124): payment_net_days_templates.net_days
|
||||
* via `payload.paymentNetDaysTemplateId`. This is what the bill
|
||||
* editor actually sends; the old code only read the legacy FK and
|
||||
* so silently ignored Net 60 / 90 selections.
|
||||
* 3. Legacy single FK: payment_term_templates.net_days via
|
||||
* `payload.paymentTermTemplateId`.
|
||||
* 4. The `crm_payment_default_net_days` setting (admin-configured).
|
||||
* 5. 30 — historical hard default.
|
||||
*/
|
||||
async function resolveNetDays(payload, trx = db) {
|
||||
if (payload && payload.netDays != null && payload.netDays !== '') {
|
||||
const n = ensureInt(payload.netDays);
|
||||
if (n) return n;
|
||||
}
|
||||
if (payload && payload.paymentNetDaysTemplateId) {
|
||||
const probe = await trx('payment_net_days_templates')
|
||||
.where({ id: payload.paymentNetDaysTemplateId })
|
||||
.select('net_days')
|
||||
.first();
|
||||
if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
|
||||
}
|
||||
if (payload && payload.paymentTermTemplateId) {
|
||||
const probe = await trx('payment_term_templates')
|
||||
.where({ id: payload.paymentTermTemplateId })
|
||||
.select('net_days')
|
||||
.first();
|
||||
if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
|
||||
}
|
||||
const setting = ensureInt(await getAppSetting('crm_payment_default_net_days'));
|
||||
if (setting) return setting;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Net-days for an already-persisted invoice row (no payload). Reads the
|
||||
* snapshot's net_days, then the crm_payment_default_net_days setting,
|
||||
* then 30. Used at send time to re-anchor the due date when the issue
|
||||
* date is stamped. Mirrors resolveNetDays' tail.
|
||||
*/
|
||||
async function resolveNetDaysForRow(invoice) {
|
||||
const snap = typeof invoice.payment_term_snapshot === 'string'
|
||||
? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })()
|
||||
: invoice.payment_term_snapshot;
|
||||
if (snap && snap.net_days != null) {
|
||||
const n = ensureInt(snap.net_days);
|
||||
if (n) return n;
|
||||
}
|
||||
const setting = ensureInt(await getAppSetting('crm_payment_default_net_days'));
|
||||
if (setting) return setting;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deal_uuid for a new invoice row (migration 140). Priority:
|
||||
*
|
||||
@@ -234,6 +293,13 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Manual cadence has no billing cycle: the draft accumulates
|
||||
// indefinitely and ships ONLY via the admin "Trigger invoice now"
|
||||
// gesture, so it carries NO period_end. The scheduler's auto-flush
|
||||
// filter is `monthly_period_end <= today`, which a NULL period_end
|
||||
// can never satisfy — keeping manual drafts out of the cron path.
|
||||
const isManual = customer.billing_cadence === 'manual';
|
||||
|
||||
// Resolve period_end: prefer the cadence in the current month, but
|
||||
// if it has already passed, roll to next month so the new draft
|
||||
// gathers items toward the NEXT bill.
|
||||
@@ -243,8 +309,11 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
const nextMonth = today.getMonth() + 1;
|
||||
target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay);
|
||||
}
|
||||
const periodStart = new Date(target.getFullYear(), target.getMonth(), 1);
|
||||
const periodEnd = target;
|
||||
const periodStart = isManual ? null : new Date(target.getFullYear(), target.getMonth(), 1);
|
||||
const periodEnd = isManual ? null : target;
|
||||
// Placeholder issue/due date for the empty draft row — recomputed at
|
||||
// issuance time. Manual drafts have no period_end, so fall back to today.
|
||||
const placeholderDate = (periodEnd || today).toISOString().slice(0, 10);
|
||||
|
||||
// Look up any existing open draft for this customer. We deliberately
|
||||
// do NOT filter by monthly_period_end here — only one draft can be
|
||||
@@ -282,8 +351,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
event_id: null,
|
||||
language,
|
||||
currency,
|
||||
issue_date: periodEnd.toISOString().slice(0, 10),
|
||||
due_date: periodEnd.toISOString().slice(0, 10), // recomputed at issuance time
|
||||
issue_date: placeholderDate,
|
||||
due_date: placeholderDate, // recomputed at issuance time
|
||||
installment_index: 0,
|
||||
installment_total: 1,
|
||||
status: 'scheduled',
|
||||
@@ -296,8 +365,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
business_bank_account_id: bank?.id || null,
|
||||
qr_format: null,
|
||||
is_monthly_draft: true,
|
||||
monthly_period_start: periodStart.toISOString().slice(0, 10),
|
||||
monthly_period_end: periodEnd.toISOString().slice(0, 10),
|
||||
monthly_period_start: periodStart ? periodStart.toISOString().slice(0, 10) : null,
|
||||
monthly_period_end: periodEnd ? periodEnd.toISOString().slice(0, 10) : null,
|
||||
// Migration 140 — each monthly-draft cycle is its own deal (no
|
||||
// quote/contract chain). Fresh UUID at creation; subsequent line
|
||||
// appends just mutate this same row, so the uuid sticks.
|
||||
@@ -341,7 +410,7 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) {
|
||||
async function listInvoices({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
|
||||
return await withRetry(async () => {
|
||||
let query = db('invoices')
|
||||
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
|
||||
@@ -408,6 +477,8 @@ async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize
|
||||
// reflects when the row landed in the DB. id is the tiebreaker
|
||||
// for rows that share a created_at second.
|
||||
case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break;
|
||||
case 'issue_asc': query = query.orderBy('invoices.issue_date', 'asc').orderBy('invoices.id', 'asc'); break;
|
||||
case 'issue_desc': query = query.orderBy('invoices.issue_date', 'desc').orderBy('invoices.id', 'desc'); break;
|
||||
case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break;
|
||||
case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break;
|
||||
case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break;
|
||||
@@ -417,6 +488,11 @@ async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
|
||||
.orderBy('invoices.id', 'desc');
|
||||
break;
|
||||
case 'customer_desc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
|
||||
.orderBy('invoices.id', 'desc');
|
||||
break;
|
||||
case 'newest':
|
||||
default:
|
||||
query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc');
|
||||
@@ -618,15 +694,19 @@ async function createInvoice(payload, adminId, trx = db) {
|
||||
const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first();
|
||||
ensureCustomerCanBill(customer);
|
||||
|
||||
// Monthly-billing intercept (migration 128). For customers in
|
||||
// billing_cadence='monthly' mode every createInvoice call APPENDS
|
||||
// line items onto the running monthly-draft instead of minting a
|
||||
// Accumulator intercept (migration 128). For customers in
|
||||
// billing_cadence='monthly' OR 'manual' mode every createInvoice call
|
||||
// APPENDS line items onto a single running draft instead of minting a
|
||||
// fresh invoice. Admin sees the editor flow exactly as before; the
|
||||
// returned id is the draft's id so the UI can redirect to the
|
||||
// accumulator. `_skipMonthlyRouting` is the escape hatch used by
|
||||
// internal helpers that need to mint a non-draft row (e.g. the
|
||||
// accumulator itself, or future test fixtures).
|
||||
if (customer.billing_cadence === 'monthly' && !payload._skipMonthlyRouting) {
|
||||
// accumulator. The two modes differ only in WHEN the draft ships:
|
||||
// 'monthly' auto-flushes on the cadence day (scheduler), 'manual'
|
||||
// never auto-flushes (no period_end) and ships only via the admin
|
||||
// "Trigger invoice now" gesture. `_skipMonthlyRouting` is the escape
|
||||
// hatch used by internal helpers that need to mint a non-draft row
|
||||
// (e.g. the accumulator itself, or future test fixtures).
|
||||
if ((customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual')
|
||||
&& !payload._skipMonthlyRouting) {
|
||||
const draft = await appendToMonthlyDraft(payload, customer, adminId, trx);
|
||||
return { invoiceIds: draft?.id ? [draft.id] : [] };
|
||||
}
|
||||
@@ -642,18 +722,13 @@ async function createInvoice(payload, adminId, trx = db) {
|
||||
// used `invoiceNumber` here.
|
||||
const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10);
|
||||
const scheduledSendAt = payload.scheduledSendAt ? new Date(payload.scheduledSendAt) : null;
|
||||
// Resolve the selected payment-term template's net_days BEFORE
|
||||
// computing the due date so Net 60 / 90 templates actually push
|
||||
// the due date out. Falls back to 30 when no template is set
|
||||
// (matches the historical default).
|
||||
let resolvedNetDays = 30;
|
||||
if (payload.paymentTermTemplateId) {
|
||||
const probe = await trx('payment_term_templates')
|
||||
.where({ id: payload.paymentTermTemplateId })
|
||||
.select('net_days')
|
||||
.first();
|
||||
if (probe && probe.net_days != null) resolvedNetDays = ensureInt(probe.net_days) || 30;
|
||||
}
|
||||
// Resolve net_days BEFORE computing the due date so Net 60 / 90
|
||||
// selections actually push the due date out. resolveNetDays honors
|
||||
// the split picker FK the editor sends, the legacy single FK, and
|
||||
// the crm_payment_default_net_days setting (see helper). The clock
|
||||
// starts on the SEND date when the invoice is scheduled, otherwise
|
||||
// the issue date — so a future send pushes the due date out too.
|
||||
const resolvedNetDays = await resolveNetDays(payload, trx);
|
||||
const dueDate = payload.dueDate || computeDueDate(scheduledSendAt || new Date(issueDate), resolvedNetDays)
|
||||
.toISOString().slice(0, 10);
|
||||
|
||||
@@ -927,10 +1002,13 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre
|
||||
}
|
||||
|
||||
// netDays drives the due-date offset on every scheduled invoice
|
||||
// created here. Defaults to 30 when the caller doesn't pass one;
|
||||
// callers in quoteService now pass the converting quote's
|
||||
// payment-term net_days so Net 60 / 90 templates flow through.
|
||||
const resolvedNetDays = ensureInt(netDays) || 30;
|
||||
// created here. Callers in quoteService pass the converting quote's
|
||||
// payment-term net_days so Net 60 / 90 templates flow through; when
|
||||
// absent we fall back to the crm_payment_default_net_days setting
|
||||
// (then 30) rather than silently using 30, matching createInvoice.
|
||||
const resolvedNetDays = ensureInt(netDays)
|
||||
|| ensureInt(await getAppSetting('crm_payment_default_net_days'))
|
||||
|| 30;
|
||||
const total = installments.length;
|
||||
const acceptanceTime = new Date();
|
||||
const invoiceIds = [];
|
||||
@@ -1633,8 +1711,10 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
||||
// but still printed the discount row on the PDF. Zero out both
|
||||
// fields here so pdfService.drawPaymentBlock's
|
||||
// `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays`
|
||||
// guard suppresses the row.
|
||||
if (invoice.skonto_disabled) {
|
||||
// guard suppresses the row. The per-customer opt-out (migration 112)
|
||||
// is honoured here too — a customer flagged skonto_disabled never
|
||||
// prints the discount row, mirroring resolveSkontoPercentForInvoice.
|
||||
if (invoice.skonto_disabled || customer?.skonto_disabled) {
|
||||
paymentTerm.skontoPercent = null;
|
||||
paymentTerm.skontoWithinDays = null;
|
||||
}
|
||||
@@ -1866,6 +1946,39 @@ async function sendInvoice(id, adminId) {
|
||||
invoice.language = customer.preferred_language;
|
||||
}
|
||||
|
||||
// Stamp the issue date at the moment the invoice actually goes out.
|
||||
// A scheduled invoice's issue_date is provisional — set to the
|
||||
// authoring day at creation — but the legal issue date is when it
|
||||
// ships. Anchoring it here keeps the printed invoice date, the Skonto
|
||||
// window (a relative "pay within N working days" counted from that
|
||||
// date) and the net-days due date all consistent with the send date.
|
||||
// Only on the first send (status 'scheduled'); 'sent' / 'overdue'
|
||||
// rows are immutable legal records and keep their stamped date.
|
||||
if (invoice.status === 'scheduled') {
|
||||
const sendDateIso = new Date().toISOString().slice(0, 10);
|
||||
const netDays = await resolveNetDaysForRow(invoice);
|
||||
// Re-anchor the due date too, but only when it was machine-set: if
|
||||
// the stored due_date still equals the auto formula off the OLD
|
||||
// base (scheduled_send_at, else the old issue_date), the admin never
|
||||
// hand-edited it and we slide it to the new issue date. A divergent
|
||||
// value means a manual override (the editor's "Override due date"
|
||||
// toggle) — leave it untouched.
|
||||
const oldBase = invoice.scheduled_send_at
|
||||
? new Date(invoice.scheduled_send_at)
|
||||
: new Date(invoice.issue_date);
|
||||
const oldAutoDue = computeDueDate(oldBase, netDays).toISOString().slice(0, 10);
|
||||
const storedDue = invoice.due_date
|
||||
? new Date(invoice.due_date).toISOString().slice(0, 10)
|
||||
: null;
|
||||
const updates = { issue_date: sendDateIso, updated_at: new Date() };
|
||||
if (storedDue && storedDue === oldAutoDue) {
|
||||
updates.due_date = computeDueDate(new Date(sendDateIso), netDays).toISOString().slice(0, 10);
|
||||
}
|
||||
await db('invoices').where({ id }).update(updates);
|
||||
invoice.issue_date = updates.issue_date;
|
||||
if (updates.due_date) invoice.due_date = updates.due_date;
|
||||
}
|
||||
|
||||
const ctx = await buildInvoiceRenderContext(invoice, lineItems);
|
||||
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
|
||||
|
||||
@@ -2537,7 +2650,9 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
contentPath: pdfPath,
|
||||
contentType: 'application/pdf',
|
||||
}],
|
||||
});
|
||||
// Dunning reminders are relationship mail — hold to business hours so
|
||||
// the customer isn't pinged overnight (no-op unless hours configured).
|
||||
}, { respectBusinessHours: true });
|
||||
|
||||
try {
|
||||
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor },
|
||||
@@ -2577,6 +2692,17 @@ async function resolveSkontoPercentForInvoice(invoice) {
|
||||
// installments that shouldn't qualify for the discount even when
|
||||
// the global default offers it.
|
||||
if (invoice.skonto_disabled) return null;
|
||||
// Per-customer opt-out (migration 112) — a customer that negotiated
|
||||
// "no Skonto" as a contract term never qualifies, so the admin
|
||||
// doesn't have to tick the per-invoice toggle on every invoice.
|
||||
// Falls through customer → invoice → snapshot → quote → global.
|
||||
if (invoice.customer_account_id) {
|
||||
const cust = await db('customer_accounts')
|
||||
.where({ id: invoice.customer_account_id })
|
||||
.select('skonto_disabled')
|
||||
.first();
|
||||
if (cust && cust.skonto_disabled) return null;
|
||||
}
|
||||
const parseSnap = (raw) => {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return raw;
|
||||
|
||||
@@ -39,6 +39,7 @@ const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
||||
const pdfService = require('./pdfService');
|
||||
const emailProcessor = require('./emailProcessor');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -333,7 +334,7 @@ function ensureCustomerFeatureEnabled(customer, feature) {
|
||||
* Filters: { status[], customerAccountId, from, to, q }
|
||||
* Sort: 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc'
|
||||
*/
|
||||
async function listQuotes({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) {
|
||||
async function listQuotes({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
|
||||
return await withRetry(async () => {
|
||||
let query = db('quotes')
|
||||
.leftJoin('customer_accounts', 'quotes.customer_account_id', 'customer_accounts.id')
|
||||
@@ -385,11 +386,22 @@ async function listQuotes({ filters = {}, sort = 'newest', page = 1, pageSize =
|
||||
case 'oldest':
|
||||
query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc');
|
||||
break;
|
||||
case 'issue_asc':
|
||||
query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc');
|
||||
break;
|
||||
case 'issue_desc':
|
||||
query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
case 'customer_asc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
|
||||
.orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
case 'customer_desc':
|
||||
query = query
|
||||
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
|
||||
.orderBy('quotes.id', 'desc');
|
||||
break;
|
||||
case 'value_asc':
|
||||
query = query.orderBy('quotes.total_amount_minor', 'asc');
|
||||
break;
|
||||
@@ -1162,6 +1174,69 @@ async function adminAcceptQuote(id, adminId) {
|
||||
return { status: 'accepted', lockedAt: responseLockedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin "decline on behalf of customer" — records the quote as
|
||||
* `declined` directly, bypassing the public token + response window.
|
||||
* Used when the customer says no by phone/email and the admin wants the
|
||||
* pipeline reflected without asking them to click the decline link.
|
||||
*
|
||||
* Mirrors adminAcceptQuote's guards: refuses quotes that are already
|
||||
* terminal (`accepted`, `declined`, `converted`) — those would overwrite
|
||||
* history. Allowed from `draft` / `sent` / `expired`.
|
||||
*
|
||||
* `reason` is optional free text persisted to `quotes.decline_reason`
|
||||
* (migration 115) and surfaced on the quote detail page.
|
||||
*
|
||||
* Any outstanding accept/decline tokens are invalidated so the customer
|
||||
* can't flip the quote back to accepted via a still-live emailed link.
|
||||
*/
|
||||
async function adminDeclineQuote(id, adminId, reason = null) {
|
||||
const quote = await db('quotes').where({ id }).first();
|
||||
if (!quote) throw new AppError('Quote not found', 404);
|
||||
if (quote.status === 'declined') {
|
||||
throw new AppError('Quote already declined', 409, 'QUOTE_ALREADY_DECLINED');
|
||||
}
|
||||
if (quote.status === 'accepted') {
|
||||
throw new AppError('Quote already accepted; duplicate it to start a fresh round.', 409, 'QUOTE_ALREADY_ACCEPTED');
|
||||
}
|
||||
if (quote.status === 'converted') {
|
||||
throw new AppError('Quote already converted to an event/invoice', 409, 'QUOTE_CONVERTED');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const cleanReason = typeof reason === 'string' && reason.trim() ? reason.trim().slice(0, 5000) : null;
|
||||
const hasReasonColumn = await hasColumnCached('quotes', 'decline_reason');
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const updates = {
|
||||
status: 'declined',
|
||||
responded_at: quote.responded_at || now,
|
||||
// Close the public response window immediately so a customer link
|
||||
// can't toggle the quote afterwards (recordResponse rejects once
|
||||
// now > response_locked_at).
|
||||
response_locked_at: now,
|
||||
declined_at: now,
|
||||
accepted_at: null,
|
||||
updated_at: now,
|
||||
};
|
||||
if (hasReasonColumn) updates.decline_reason = cleanReason;
|
||||
await trx('quotes').where({ id }).update(updates);
|
||||
|
||||
// Burn any unused tokens for this quote — defense in depth alongside
|
||||
// the closed response window above.
|
||||
await trx('quote_action_tokens')
|
||||
.where({ quote_id: id })
|
||||
.whereNull('used_at')
|
||||
.update({ used_at: now, used_action: 'declined' });
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
|
||||
return { status: 'declined', declinedAt: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an accepted quote to an event + scheduled invoices.
|
||||
* Wraps everything in a transaction so a half-finished conversion
|
||||
@@ -1775,6 +1850,7 @@ module.exports = {
|
||||
duplicateQuote,
|
||||
recordResponse,
|
||||
adminAcceptQuote,
|
||||
adminDeclineQuote,
|
||||
convertToEvent,
|
||||
convertToInvoiceOnly,
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Business hours + scheduled-email floor (migration 114).
|
||||
*
|
||||
* Pure, dependency-free time math. picpeak doesn't pull in a date library,
|
||||
* so timezone handling leans on Intl.DateTimeFormat, which every supported
|
||||
* Node build ships with full IANA data for.
|
||||
*
|
||||
* The schedule is per-ISO-weekday with any number of opening blocks, so a
|
||||
* day can carry a lunch break (e.g. 09:00–12:00 + 13:00–18:00) or differ
|
||||
* from its neighbours — the Google-business-hours model. Shape:
|
||||
*
|
||||
* { "1": [{ start: "09:00", end: "12:00" }, { start: "13:00", end: "18:00" }],
|
||||
* "2": [...], ..., "6": [], "7": [] }
|
||||
*
|
||||
* ISO weekday numbering throughout: 1=Mon … 7=Sun. A weekday with no
|
||||
* blocks is closed.
|
||||
*
|
||||
* Snap rule (nearest upcoming block-open; confirmed with the maintainer):
|
||||
* - floor disabled / empty schedule → unchanged
|
||||
* - instant falls inside any block → unchanged
|
||||
* - instant before a later block same day → that block's open
|
||||
* (covers before-first-open AND lunch gaps)
|
||||
* - otherwise → first block of the next
|
||||
* open day, at its open
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wall-clock components of `date` as observed in IANA zone `tz`.
|
||||
* Returns { y, mo, d, hh, mi, ss } with mo 1-12, hh 0-23.
|
||||
*/
|
||||
function getZonedParts(date, tz) {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
const map = {};
|
||||
for (const part of dtf.formatToParts(date)) {
|
||||
if (part.type !== 'literal') map[part.type] = part.value;
|
||||
}
|
||||
let hh = parseInt(map.hour, 10);
|
||||
// Some engines render midnight as "24" under hour12:false; normalise.
|
||||
if (hh === 24) hh = 0;
|
||||
return {
|
||||
y: parseInt(map.year, 10),
|
||||
mo: parseInt(map.month, 10),
|
||||
d: parseInt(map.day, 10),
|
||||
hh,
|
||||
mi: parseInt(map.minute, 10),
|
||||
ss: parseInt(map.second, 10) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** ISO weekday (1=Mon … 7=Sun) for a plain calendar date. */
|
||||
function isoWeekday(y, mo, d) {
|
||||
const dow = new Date(Date.UTC(y, mo - 1, d)).getUTCDay(); // 0=Sun … 6=Sat
|
||||
return dow === 0 ? 7 : dow;
|
||||
}
|
||||
|
||||
/** Offset (ms) between zone `tz` wall-clock and UTC at instant `utcMs`. */
|
||||
function tzOffsetMs(utcMs, tz) {
|
||||
const p = getZonedParts(new Date(utcMs), tz);
|
||||
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
|
||||
return asUtc - utcMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a wall-clock time (interpreted in zone `tz`) to a UTC instant.
|
||||
* Two-pass offset resolution so it stays correct across DST boundaries.
|
||||
*/
|
||||
function zonedWallClockToUtc(y, mo, d, hh, mi, tz) {
|
||||
const naiveUtc = Date.UTC(y, mo - 1, d, hh, mi, 0);
|
||||
let result = naiveUtc - tzOffsetMs(naiveUtc, tz);
|
||||
result = naiveUtc - tzOffsetMs(result, tz);
|
||||
return new Date(result);
|
||||
}
|
||||
|
||||
/** "HH:MM" → minutes from midnight, or null when malformed. */
|
||||
function parseHHMM(value) {
|
||||
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(value || ''));
|
||||
if (!m) return null;
|
||||
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
|
||||
}
|
||||
|
||||
/** minutes from midnight → "HH:MM" (24h, zero-padded). */
|
||||
function minutesToHHMM(minutes) {
|
||||
const hh = Math.floor(minutes / 60);
|
||||
const mi = minutes % 60;
|
||||
return `${String(hh).padStart(2, '0')}:${String(mi).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Add `n` calendar days to a {y,mo,d}, returning the same shape. */
|
||||
function addDays(y, mo, d, n) {
|
||||
const dt = new Date(Date.UTC(y, mo - 1, d + n));
|
||||
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + clean a raw weekly schedule into the canonical storage shape.
|
||||
*
|
||||
* Accepts the JSON object (or a JSON string), keyed by ISO weekday. Each
|
||||
* day's value is an array of blocks; a block may be {start,end} or a
|
||||
* [start,end] pair. Invalid blocks (bad HH:MM, end<=start) are dropped;
|
||||
* blocks are sorted by start. Days are emitted as string keys "1".."7"
|
||||
* with an array value (possibly empty = closed).
|
||||
*
|
||||
* Returns { "1": [{start,end}], ..., "7": [] } — never throws.
|
||||
*/
|
||||
function normaliseSchedule(raw) {
|
||||
let obj = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { obj = JSON.parse(raw); } catch (_) { obj = null; }
|
||||
}
|
||||
const out = {};
|
||||
for (let iso = 1; iso <= 7; iso += 1) out[String(iso)] = [];
|
||||
if (!obj || typeof obj !== 'object') return out;
|
||||
|
||||
for (let iso = 1; iso <= 7; iso += 1) {
|
||||
const dayRaw = obj[String(iso)] !== undefined ? obj[String(iso)] : obj[iso];
|
||||
if (!Array.isArray(dayRaw)) continue;
|
||||
const blocks = [];
|
||||
for (const b of dayRaw) {
|
||||
let startStr;
|
||||
let endStr;
|
||||
if (Array.isArray(b)) {
|
||||
[startStr, endStr] = b;
|
||||
} else if (b && typeof b === 'object') {
|
||||
startStr = b.start;
|
||||
endStr = b.end;
|
||||
}
|
||||
const startMin = parseHHMM(startStr);
|
||||
const endMin = parseHHMM(endStr);
|
||||
if (startMin == null || endMin == null || endMin <= startMin) continue;
|
||||
blocks.push({ start: minutesToHHMM(startMin), end: minutesToHHMM(endMin), startMin, endMin });
|
||||
}
|
||||
blocks.sort((a, b) => a.startMin - b.startMin);
|
||||
out[String(iso)] = blocks.map((b) => ({ start: b.start, end: b.end }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when at least one weekday carries at least one opening block. */
|
||||
function hasAnyBlocks(schedule) {
|
||||
if (!schedule || typeof schedule !== 'object') return false;
|
||||
for (let iso = 1; iso <= 7; iso += 1) {
|
||||
const day = schedule[String(iso)];
|
||||
if (Array.isArray(day) && day.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Blocks for an ISO weekday as sorted {startMin,endMin}, parsed fresh. */
|
||||
function blocksForDay(schedule, iso) {
|
||||
const day = schedule[String(iso)];
|
||||
if (!Array.isArray(day)) return [];
|
||||
const out = [];
|
||||
for (const b of day) {
|
||||
const startMin = parseHHMM(b && b.start);
|
||||
const endMin = parseHHMM(b && b.end);
|
||||
if (startMin == null || endMin == null || endMin <= startMin) continue;
|
||||
out.push({ startMin, endMin });
|
||||
}
|
||||
out.sort((a, b) => a.startMin - b.startMin);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a Date to the configured business-hours window.
|
||||
*
|
||||
* @param {Date} date the requested send instant
|
||||
* @param {Object} cfg
|
||||
* @param {boolean} cfg.enabled
|
||||
* @param {string} cfg.timezone IANA zone (resolved by caller; never "")
|
||||
* @param {Object} cfg.schedule per-ISO-weekday blocks (see module docs)
|
||||
* @returns {Date} the (possibly unchanged) send instant
|
||||
*/
|
||||
function snapToBusinessHours(date, cfg) {
|
||||
if (!cfg || !cfg.enabled) return date;
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return date;
|
||||
const { timezone, schedule } = cfg;
|
||||
if (!hasAnyBlocks(schedule)) return date; // nothing to snap to
|
||||
|
||||
const p = getZonedParts(date, timezone);
|
||||
const iso = isoWeekday(p.y, p.mo, p.d);
|
||||
const tMin = p.hh * 60 + p.mi;
|
||||
|
||||
const today = blocksForDay(schedule, iso);
|
||||
for (const block of today) {
|
||||
// Inside an open block → leave the instant untouched.
|
||||
if (tMin >= block.startMin && tMin < block.endMin) return date;
|
||||
}
|
||||
// Before a later block today (covers before-first-open AND lunch gaps):
|
||||
// snap up to the nearest block whose open is still ahead.
|
||||
for (const block of today) {
|
||||
if (tMin < block.startMin) {
|
||||
return zonedWallClockToUtc(
|
||||
p.y, p.mo, p.d, Math.floor(block.startMin / 60), block.startMin % 60, timezone
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After the last block today, or a closed day: walk forward to the first
|
||||
// open block of the next open day. Bounded at 14 days as a safety stop;
|
||||
// hasAnyBlocks above guarantees the loop terminates well within that.
|
||||
let cur = { y: p.y, mo: p.mo, d: p.d };
|
||||
for (let i = 1; i <= 14; i += 1) {
|
||||
cur = addDays(cur.y, cur.mo, cur.d, 1);
|
||||
const dayBlocks = blocksForDay(schedule, isoWeekday(cur.y, cur.mo, cur.d));
|
||||
if (dayBlocks.length > 0) {
|
||||
const open = dayBlocks[0].startMin;
|
||||
return zonedWallClockToUtc(
|
||||
cur.y, cur.mo, cur.d, Math.floor(open / 60), open % 60, timezone
|
||||
);
|
||||
}
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
snapToBusinessHours,
|
||||
parseHHMM,
|
||||
minutesToHHMM,
|
||||
normaliseSchedule,
|
||||
hasAnyBlocks,
|
||||
_internal: {
|
||||
getZonedParts,
|
||||
isoWeekday,
|
||||
zonedWallClockToUtc,
|
||||
addDays,
|
||||
blocksForDay,
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,12 @@ const { getStoragePath } = require('../config/storage');
|
||||
const { getAppSetting } = require('./appSettings');
|
||||
const logger = require('./logger');
|
||||
|
||||
// Bump this whenever the SVG→PNG rendering environment changes in a way that
|
||||
// changes output, to invalidate previously-cached rasterisations.
|
||||
// v2 (2026-06): backend image now ships fonts (fontconfig + brand fonts),
|
||||
// so SVG logos with live <text> render their text instead of tofu boxes.
|
||||
const RASTER_VERSION = 'v2-fonts';
|
||||
|
||||
const SUPPORTED_EXT = /\.(png|jpe?g)$/i;
|
||||
// Formats PDFKit can't embed directly but `sharp` can rasterise into
|
||||
// PNG for us. We transparently convert + cache.
|
||||
@@ -96,11 +102,14 @@ async function rasteriseToPng(sourcePath, storageRoot) {
|
||||
const stat = fs.statSync(sourcePath);
|
||||
const cacheDir = path.join(storageRoot, 'cache', 'logo-png');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
// Content-addressed cache: sha1(src path + mtime ns + size).
|
||||
// Including mtime means re-uploading the source invalidates the
|
||||
// cache entry naturally.
|
||||
// Content-addressed cache: sha1(renderer version + src path + mtime ns
|
||||
// + size). Including mtime means re-uploading the source invalidates the
|
||||
// cache entry naturally. RASTER_VERSION is bumped whenever the rendering
|
||||
// environment changes in a way that affects output (e.g. installing fonts
|
||||
// so SVG <text> stops rendering as tofu) — bumping it invalidates every
|
||||
// previously-cached PNG without having to clear the cache dir by hand.
|
||||
const key = crypto.createHash('sha1')
|
||||
.update(`${sourcePath}|${stat.mtimeMs}|${stat.size}`)
|
||||
.update(`${RASTER_VERSION}|${sourcePath}|${stat.mtimeMs}|${stat.size}`)
|
||||
.digest('hex');
|
||||
const cachedPath = path.join(cacheDir, `${key}.png`);
|
||||
if (fs.existsSync(cachedPath)) {
|
||||
|
||||
Reference in New Issue
Block a user