diff --git a/backend/Dockerfile b/backend/Dockerfile index e8e66a74..7192433d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 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//*.ttf — the +# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg +# rasterises an SVG logo its 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 '\n\n\n /app/assets/fonts\n\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 diff --git a/backend/__tests__/services/customerHoursService.test.js b/backend/__tests__/services/customerHoursService.test.js index 23302408..4396a36a 100644 --- a/backend/__tests__/services/customerHoursService.test.js +++ b/backend/__tests__/services/customerHoursService.test.js @@ -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. diff --git a/backend/__tests__/utils/businessHours.test.js b/backend/__tests__/utils/businessHours.test.js new file mode 100644 index 00000000..b77d6754 --- /dev/null +++ b/backend/__tests__/utils/businessHours.test.js @@ -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); + }); +}); diff --git a/backend/migrations/core/110_normalize_country_code_fl_to_li.js b/backend/migrations/core/110_normalize_country_code_fl_to_li.js new file mode 100644 index 00000000..26cba940 --- /dev/null +++ b/backend/migrations/core/110_normalize_country_code_fl_to_li.js @@ -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() {}; diff --git a/backend/migrations/core/111_backfill_imported_invoice_dates.js b/backend/migrations/core/111_backfill_imported_invoice_dates.js new file mode 100644 index 00000000..47068067 --- /dev/null +++ b/backend/migrations/core/111_backfill_imported_invoice_dates.js @@ -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() {}; diff --git a/backend/migrations/core/112_add_customer_skonto_disabled.js b/backend/migrations/core/112_add_customer_skonto_disabled.js new file mode 100644 index 00000000..89982547 --- /dev/null +++ b/backend/migrations/core/112_add_customer_skonto_disabled.js @@ -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'); + }); +}; diff --git a/backend/migrations/core/113_add_default_hourly_rate.js b/backend/migrations/core/113_add_default_hourly_rate.js new file mode 100644 index 00000000..4b9a3c01 --- /dev/null +++ b/backend/migrations/core/113_add_default_hourly_rate.js @@ -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'); + }); +}; diff --git a/backend/migrations/core/114_add_business_hours_to_profile.js b/backend/migrations/core/114_add_business_hours_to_profile.js new file mode 100644 index 00000000..7fcd8167 --- /dev/null +++ b/backend/migrations/core/114_add_business_hours_to_profile.js @@ -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'); + }); + } +}; diff --git a/backend/migrations/core/115_add_quote_decline_reason.js b/backend/migrations/core/115_add_quote_decline_reason.js new file mode 100644 index 00000000..9915b0a3 --- /dev/null +++ b/backend/migrations/core/115_add_quote_decline_reason.js @@ -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'); + }); +}; diff --git a/backend/migrations/core/116_backfill_imported_paid_amount.js b/backend/migrations/core/116_backfill_imported_paid_amount.js new file mode 100644 index 00000000..07677019 --- /dev/null +++ b/backend/migrations/core/116_backfill_imported_paid_amount.js @@ -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. +}; diff --git a/backend/server.js b/backend/server.js index afb377e7..bc1427fc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 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 , 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