Commit Graph
1159 Commits
Author SHA1 Message Date
Paul Nothaft 5f0fcc225c Merge pull request #555 from Luca-Timo/feat/crm-pr
feat: CRM module — quotes, contracts, invoices, hours, calendar, tax
2026-05-29 11:27:23 +02:00
Luca d1aecaa180 fix(crm): thread trx through sequence-claim sites to unblock SQLite
Reviewer feedback on #555: nextQuoteNumber inside createQuote's
db.transaction was called without passing the outer trx, so
claimNextSequence opened its own connection — Postgres tolerated this
via the pool, SQLite (1-connection default) deadlocked on every quote
creation.
Audited the same pattern across invoiceService + contractService and
found five more matching call sites:
  - createInvoice (single-row path after installment auto-route)
  - spawnInstallmentInvoices (per-sibling claim inside the loop)
  - createStorno
  - createContract
  - createFromQuote
All now thread trx through to nextXxxNumber → claimNextSequence so
the claim joins the caller's transaction on both engines.
convertToInvoiceOnly's Path B (standalone-contract) is the lone
remaining nextInvoiceNumber() call without trx — that path isn't
wrapped in a transaction at all (separate concern: sequence-number
leak on insert failure, tracked separately).
2026-05-27 22:08:38 +02:00
Luca 5ce0b6edc3 fix(quote-response): compute minutes-remaining for the DE changeWithin string
Previous fix (45f0606) papered over the bug by changing the German
wording from "innerhalb von {{minutes}} Minuten" to "bis {{at}}" —
that worked but changed the UX intent. The original German wording
("you have N minutes left") was deliberate and clearer than an
absolute clock time; the actual bug was that no caller ever computed
`minutes` from `responseLockedAt`.

Revert the DE translation to its original wording, then build a
{ at, minutes } object at the call site so EN ("until {{at}}") and
DE ("innerhalb von {{minutes}} Minuten") each pick up the variable
they need. `minutes` rounds UP so a 14m 32s remainder displays as
"15 Minuten" rather than promising 14 the customer can't actually
hit.
2026-05-27 16:06:35 +02:00
Paul Nothaft 3ceeccd85a Merge pull request #562 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
chore(beta): release 3.55.0-beta.0
v3.55.0-beta.0
2026-05-27 16:01:01 +02:00
github-actions[bot] fd8ee5d52c chore(beta): release 3.55.0-beta.0 2026-05-27 13:53:11 +00:00
Paul Nothaft e016f510b6 Merge pull request #561 from the-luap/fix/android-download-latency-554
fix(lightbox+events): Android download lag, multi-photo Web Share re-land, theme branding inheritance
2026-05-27 15:52:44 +02:00
Paul Nothaft d5a37df2c4 fix(events): preserve branding inheritance when saving events with null color_theme
API-created events (and any event whose `color_theme` is NULL) had two
visible bugs in the admin edit page (#550 follow-up — PR #552 fixed the
v1 POST write path, this fixes the read/save path):

1. The theme picker initialised to the hardcoded `GALLERY_THEME_PRESETS
   .default.config` ("Classic Grid", green) — which had nothing to do
   with the admin's actual branding palette, while the gallery itself
   was rendering with the branding theme. Confusing visual mismatch.
2. Saving the event for ANY reason (changing the date, password, etc.)
   wrote `color_theme = 'default'` back to the row because the save
   handler always emitted the picker's initial preset name. That
   silently replaced "inherit from branding" with the literal Classic
   Grid preset, so the gallery's visuals jumped.

Two fixes, both in EventDetailsPage:

- Add a `themeChanged` flag, defaulted false. Flip in the picker's
  onChange / onPresetChange / onSyncFromBranding callbacks. The save
  handler now only writes `updateData.color_theme` when the flag is
  true, so saving without touching the picker preserves NULL.
- When `event.color_theme` is null and `publicSettings.theme_config`
  (the site branding) is available, initialise `currentTheme` from
  branding instead of the Classic Grid preset, with currentPresetName
  set to 'custom' (since inherited branding isn't a named preset).
  Falls back to the Classic Grid preset only when no branding theme
  exists either.

Combined effect: opening an API-created event shows the same palette
the gallery uses, and saving without changing the theme preserves the
inheritance. Existing events with a stored color_theme are unaffected
(themeChanged stays false → no write, just like before for the
common no-change-to-theme save).
2026-05-27 15:44:41 +02:00
Luca 45f0606ea3 fix(i18n): align quoteResponse.changeWithin DE placeholder with call site
The German string used `{{minutes}}` while the call site at
QuoteResponsePage.tsx:300 passes `{ at: <localized time> }`, matching
the English string's `{{at}}`. Result on the public quote page when
the customer had already responded: the literal text "{{minutes}}"
rendered instead of the unlock time.

Switched the German wording to match the English semantics
("until X:XX") since the underlying value is an absolute time, not a
minutes-remaining count — the previous DE wording was also wrong about
WHAT the variable meant.
2026-05-27 15:38:35 +02:00
Paul Nothaft d5823c79d9 feat(lightbox): multi-photo Web Share save-to-Photos on iOS (#557)
Extends #531 to the selection-based bulk-download flow. On iOS with a
selection at or under MAX_WEB_SHARE_FILES (25), galleryService
.downloadSelectedPhotos now routes through navigator.share({ files })
so the photos land directly in Photos via the share sheet's "Save N
Images" action. Above the cap, anywhere off-iOS, or on any failure,
the existing server-side zip path runs unchanged.

The 25-file cap is the empirically-safe ceiling: iOS Safari's share
sheet starts choking beyond ~25–30 files, and every File materialises
as an in-memory Blob before share() is invoked, so a 500-photo
selection would buffer multiple GB on the device.

trySaveMultipleToDevice exposes three outcomes:
- 'shared'    — share() resolved; flow ends
- 'dismissed' — user cancelled (AbortError); flow ends without zip
                fallback so dismissal isn't silently overridden
- 'fallback'  — capability missing or unexpected failure; caller
                takes the zip path

Partial shares are deliberately avoided: a single failed photo fetch
collapses the whole selection back to the zip endpoint rather than
sharing only the photos that resolved.

All 4 grid callers (PhotoGrid, PhotoGridWithLayouts, GalleryStoryLayout,
GalleryPremiumLayout) funnel through downloadSelectedPhotos, so no
caller-side changes are needed. Android, desktop, Firefox, and
"Download All" are untouched.

Layers on top of #556 (iOS-only gating via isIOS()). Builds against the
fix/android-download-web-share-554 branch.
2026-05-27 15:37:52 +02:00
Luca 83933baeec fix(crm): self-heal missing CRM email templates at boot + recover queue
The CRM template seeders (crmEmailTemplates / contractEmailTemplates /
eventReminderTemplates) were idempotent and ready, but only
contractEmailTemplates was actually called (lazily, by contractService
sends). crmEmailTemplates had no caller anywhere — every install that
didn't pre-exist its templates failed every quote_sent / invoice_sent /
storno_issued / invoice_reminder_* send with "Email template '<key>'
not found". The queue processor retries 3 times then leaves the row
in status='pending', retry_count=3, silently dead with no admin
surface (see project_crm_backlog for the eventual System Health page).

Fix: wire all three seeders into server.js startServer() right before
startEmailQueueProcessor. The new _emailTemplateBoot.js orchestrates
all three and then, for any template_key it just inserted, resets
retry_count on stuck email_queue rows of that email_type so the
queue processor's next tick picks them back up. Recovery is targeted:
unrelated retry-exhausted rows (e.g. SMTP-timeout failures) are not
touched.

Integration test boots a fresh CRM DB, pre-seeds a stuck quote_sent
row plus an unrelated stuck row, runs the boot helper, and asserts:
templates landed, stuck quote_sent row was reset, unrelated row was
left alone.

Already-deployed installs heal automatically on the next backend
restart after this lands.
2026-05-27 15:18:29 +02:00
Luca 09c5110d2b feat(crm): route billing docs to billing_email when set
Wires customer_accounts.billing_email into the invoice, Storno, and
payment-reminder send paths. Previously the column existed on the
schema and the customer-detail page rendered an input for it, but no
send path read it — every outbound email landed on customer_accounts.email
regardless. That mismatch is the failure mode flagged in
feedback_data_driven_completeness: a UI field that promises behavior
the backend silently doesn't deliver.

Routing matrix:
  - invoice / Storno / payment reminder
      To: billing_email (fallback email when unset)
      CC: email (when billing_email took the To slot) + per-doc cc_pdf_email
  - quote / contract / event reminder / gallery share
      To: email (unchanged — decision-maker address)
  - payment-check / paid-notification
      To: admin contact (unchanged — internal flow)

A new resolveBillingRecipients helper centralises the rules:
prefer billing_email, dedupe addresses case-insensitively, keep
per-doc cc_pdf_email as a supplemental CC. Lives in its own file
(_billingRecipients.js) to match the _renderContext.js convention.
2026-05-27 14:04:15 +02:00
Paul Nothaft 04795219a0 fix(lightbox): eliminate download lag on Android by skipping the blob round-trip
`savePhotoToDevice` previously buffered the full image through JS as a
Blob on every platform before clicking <a download>. On cellular this
added ~5s of dead air between the button press and the browser's
download dialog, prompting users to re-click and produce duplicate
downloads (#554 follow-up, post-#556).

The blob round-trip is only required for the iOS Web Share path
(`navigator.share({files})` needs File objects in hand). On Android and
desktop the browser can fetch the download URL itself and show its own
progress in the notification shade — instantly. So iOS keeps the
existing flow; everywhere else gets a direct anchor navigation.

The new `triggerDirectDownload` helper uses `api.getUri()` so the path
also works in split-origin deployments (where the existing hardcoded
`/api/...` pattern used by `downloadAllPhotos` would 404).

Tests updated: Android / desktop / regular-Mac branches now assert that
`fetchPhotoBlob` is NOT called and `triggerDirectDownload` is invoked
with a `/gallery/{slug}/download/{id}` URL. iOS tests unchanged.
2026-05-27 13:16:36 +02:00
Luca 3d37324080 feat(crm): allow negative line items for manual discount/Rabatt rows
Drops the isInt({ min: 0 }) constraint on lineItems.*.unitPriceMinor
in both the adminInvoices and adminQuotes POST/PUT validators so
admins can add Treuerabatt / Frühbucherrabatt rows as standalone
negative-priced lines (matches standard DE/CH invoice practice).

A service-layer guard rejects saves whose computed total goes below
zero (INVOICE_TOTAL_NEGATIVE / QUOTE_TOTAL_NEGATIVE, both 400) so a
mis-typed discount can't accidentally mint a credit-balance invoice
that would masquerade as a regular row in dashboards. Credit notes
still belong in the Storno path (createStorno), which is unchanged.

Quote-side integration coverage is omitted for now — createQuote's
cold-require path takes ~30s under the test harness; the invoice
test exercises the same validator + guard shape.
2026-05-26 23:54:50 +02:00
Paul Nothaft 9ae1f79769 Merge pull request #559 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
chore(beta): release 3.54.7-beta.0
v3.54.7-beta.0
2026-05-26 22:54:48 +02:00
github-actions[bot] 54b185db47 chore(beta): release 3.54.7-beta.0 2026-05-26 20:54:29 +00:00
Paul Nothaft 578397bc6b Merge pull request #556 from the-luap/fix/android-download-web-share-554
fix(lightbox): restrict Web Share save-to-Photos path to iOS (#554)
2026-05-26 22:54:06 +02:00
Paul Nothaft 2a309c75a7 fix(lightbox): restrict Web Share save-to-Photos path to iOS (#554)
PR #531 routed the single-photo download through navigator.share()
whenever canShare({files}) returned true, on the assumption that any
mobile share sheet would expose a "Save Image" action. That holds on
iOS — Safari's share sheet has a first-party "Save to Photos" entry —
but on Android the system share sheet only lists installed apps that
registered an image/* intent (WhatsApp, Telegram, Drive, etc.). There
is no built-in save-to-Gallery action, so Android users tapping the
download button got an app-picker instead of the file saved to their
device.

Fix: gate the Web Share branch behind a UA-based isIOS() check. Android,
desktop, and everything else fall through to the existing <a download>
path (file lands in Downloads, visible in the Photos / Gallery app
afterwards — same behaviour as before #531). iOS — including iPadOS
13+, which reports as MacIntel + touch — keeps the share-sheet flow
that drops directly into Photos.

UA-sniff is the only available signal here: canShare({files}) is true
on both iOS Safari and Chrome Android, so feature detection cannot
distinguish them.

Tests pin all six scenarios — iOS share path, Android download fallback
(even with canShare=true), desktop, iPadOS-as-Mac detected as iOS, regular
Mac NOT detected as iOS, AbortError dismissal preserved (no surprise
fallback), and non-Abort share() rejection falls back to download.
2026-05-26 22:37:01 +02:00
Luca 6d302e7998 feat(crm): add event_reminder_* templates to dev email tester
The pre-event reminder feature shipped with 5 seeded templates
(event_reminder_default + wedding/birthday/corporate/other) but the
CRM → Development "Send any CRM email to me" picker only listed the
quote/invoice/contract templates. Maintainer can now eyeball each
reminder category's body without staging a real event.

Backend:
- Extend TEMPLATES_KEYS in adminDev.js with all 5 reminder keys.
- Add event_date (today+2d), days_before (2), business_name (from
  business_profile.legal_name) to the common payload so the
  {{tokens}} in the reminder bodies resolve.

Frontend:
- Extend CrmEmailTemplateKey union.
- Add TEMPLATE_LABEL_KEYS entries.
- EN+DE i18n labels under crmDev.templates.label.event_reminder_*.

No PDF attachment — reminders are body-only emails (matches the
real flow).
2026-05-26 20:44:11 +02:00
Luca b064aab8ef feat(crm): unlock reminderEmails feature flag in Features tab
The full reminder-email implementation (eventReminderService,
eventReminderTemplates self-heal, ReminderTemplatesPage,
EventReminderOverrideCard) shipped in the CRM bundle but the
FeaturesTab card kept lockedReason=NOT_YET_AVAILABLE — so the
working feature was invisible.

Flip the card to the same shape as customerPortal: status="beta",
real setFlag handler, no disabled/lockedReason. The sub-tab in
Settings → Reminder templates already self-mounts when the flag
is on, and the per-event override card already self-renders on
the event detail page.

Description copy + EN/DE i18n updated to describe what the feature
actually does (per-category pre-event nudge) instead of the old
"coming soon" placeholder.
2026-05-26 20:31:14 +02:00
Luca 3240137f1e test(crm): integration harness + schema-shape regression net
Adds two pieces:

- __tests__/integration/helpers/crmDb.js — boots a temp-SQLite test
  DB by invoking every migrations/core/*.up() directly. Bypasses
  knex's Migrator because its exclusive write lock deadlocks
  001_init's nested initializeDatabase() call. ~1 second cold start.

- __tests__/integration/crmSchema.test.js — 36 assertions on the
  table + column layout after the consolidated CRM migration runs.
  Pins:
    - every CRM table present (quotes, contracts, invoices + the
      eight supporting tables)
    - deal_uuid columns on all three lineage tables (the column
      DocumentLineageCard joins on — drop it anywhere and the card
      silently returns partial data)
    - back-pointer FKs (converted_contract_id, source_contract_id,
      source_quote_id) — the exact columns that triggered the
      Postgres FK-ordering bug fixed earlier in this PR
    - Storno discriminator (kind, cancels_invoice_id, replaces_
      invoice_id) per feedback_storno_filter_everywhere
    - event time columns from migration 137

A full quote→contract→invoice lineage walk is deferred — quote
service's nextQuoteNumber() opens an inner transaction from inside
the createQuote outer transaction, which deadlocks SQLite's default
1-connection pool. Postgres dev DBs never see it. Either fix the
service to thread trx through, or run lineage tests against a real
Postgres in CI (mirror schema-drift.yml). Filed as separate work.
2026-05-26 19:50:57 +02:00
Luca 482043f786 ci: run backend Jest + frontend Vitest on every PR
The suites already existed (538 backend tests, 40 frontend tests, with
solid CRM coverage on quoteService/contractService/invoiceService/
customerHoursService/eventService.calendar) but no CI workflow invoked
them. Wire both into a single Tests workflow that triggers on any push
or PR to main/beta.

Six backend suites are excluded — they fail on upstream/beta too
(supertest fixture + knex mock chain issues unrelated to CRM). The
explicit ignore pattern keeps the workflow green on day 1; each
excluded suite is listed inline as test-infra debt to fix individually.

Backend job pins SKIP_S3_TESTS=true (the same default the test setup
file applies) so the backup-service integration doesn't try a real S3
round-trip when no MinIO is provisioned.
2026-05-26 19:08:48 +02:00
Luca b9cadf002c test(crm): update mocks for new createInvitation + OG date-format behavior
Two upstream tests regressed because the CRM PR added expected behavior
they didn't anticipate:

- galleryOgService.shareImage.test.js: formatEventDate is now async and
  routes through utils/dateFormatter so the OG card respects the admin's
  general_date_format setting (per feedback_respect_general_format_settings).
  That adds a third db('app_settings') call on every buildOgMetadata path.
  Mock the formatter module directly — the format itself is irrelevant
  to the cover-vs-logo contract this file pins.

- customerAccountsService.test.js: createInvitation now allows a duplicate
  email when the existing row is PASSIVE (password_hash IS NULL) — that's
  the "promote passive customer to portal" path. The active-customer
  rejection mock now has to set password_hash so the guard fires.

Both are test-only changes; no service code touched.
2026-05-26 19:07:31 +02:00
Luca 88a6b34c5e fix(migrations): defer cross-table FKs in 107_crm_consolidated
quotes.converted_contract_id and invoices.source_contract_id were
declared with inline FKs to contracts(id), but contracts is created
later in the same migration. SQLite accepted the forward reference;
Postgres rejected it ("relation \"contracts\" does not exist"), which
broke the Schema drift (#530) workflow and any fresh Postgres install.

Same pattern as events.hero_photo_id → photos.id in db.js: declare the
column without a constraint, then add the FK in a separate alterTable
after both sides exist. Wrapped in try/catch so re-runs against a DB
that already has the constraint are a no-op.

Verified locally against the #530 recovery scenario (initializeDatabase
then migrate:safe) and the fresh-install path: both converge cleanly,
both FKs land on the expected tables.
2026-05-26 18:49:38 +02:00
Luca e7db0bb866 docs(crm): legal/financial disclaimers — examples only
Adds a top-level disclaimer section to README + a dedicated
docs/crm-disclaimers.md spelling out two areas where picpeak ships
defaults the operator MUST review before going live:

1. Contract blocks (image rights, NDA, model release, cancellation,
   jurisdiction, …) — written by the maintainer, NOT by a lawyer.
   Every operator must have their lawyer review and adapt them
   before sending any contract to a customer.

2. QR-bills and SEPA EPC payloads — rendered from the data the
   operator typed. Picpeak is open source; we recommend scanning a
   test invoice with the operator's bank app to verify the QR
   actually works.

Matches the on-screen amber disclaimers already shown on the
Contract Block Library page and the Business Profile payment-block
editor.
2026-05-26 18:20:55 +02:00
Luca 409d414035 feat(crm): i18n EN + DE for CRM, machine fr/nl/pt/ru fallbacks
~940 new keys per primary locale covering every CRM surface:
quote / invoice / contract editor + list + detail + public response
pages, calendar, hours, tax report, deals lineage, reminder emails,
feature toggles, settings tabs, error toasts.

en.json + de.json are hand-translated by the maintainer and are
authoritative. fr / nl / pt / ru received the same key set but
machine-derived strings — flagged for native review in the PR
description per project policy (see memory feedback_translation_flagging).

3-way merge note: 1 conflict (fr.json) hand-resolved to keep
upstream's improved phrasing for previewLayout / livePreview /
heroPlaceholderText alongside feat/crm's pdfTypography keys.
2026-05-26 18:19:55 +02:00
Luca a7e16e7bf6 feat(crm): frontend code — pages + services + components
Brings in the full frontend CRM stack: admin authoring pages,
customer-portal surfaces, public response flows, typed services,
and the supporting component library. i18n locale JSON is the next
commit (kept separate so reviewers can read it as data).

Pages
  - Quotes: list / editor / detail / public accept-decline
  - Invoices: list / editor / detail / public payment-check
  - Contracts: list / editor / detail / block library / public sign
  - Calendar (FullCalendar — admin-only v1)
  - Tax report (period picker + CSV/PDF export)
  - Hours (logged time entries, per-customer)
  - Deals lineage (DocumentLineageCard surfaces)
  - CRM Development (admin dev tools, gated by crmDevelopment flag)
  - Customer-portal pages for quotes / invoices / contracts
  - Settings reorg: CRM-Settings group + dedicated tabs for Business
    Profile, CRM behaviour, Contracts block library, Reminder emails
  - BrandingPage typography (PDF font picker)
  - EventDetailsPage / CustomerDetailPage / CreateEventPage extensions
    (event-time fields, hours toggle, per-event reminder override)

Services (typed)
  - quotes.service, bills.service, contracts.service
  - customerAdmin.service, deals.service, calendar.service,
    taxReport.service, contracts-blocks.service
  - businessProfile.service (timezone, font picker, bank accounts)
  - useInstallmentDefaults hook, useLocalizedDate dateInputLang extension

Components (admin)
  - CustomerPicker (shared across quote/invoice/contract editors)
  - LineItemsTable (hierarchy + details_text, memoised pricing)
  - InstallmentsPanel (simple + advanced toggle, fixed-date vs trigger)
  - DocumentLineageCard (deal_uuid grouped view)
  - EditInstallmentPlanModal (atomic post-spawn plan reshape)
  - EventReminderOverrideCard, EmailTemplateEditor (tiptap),
    PdfFontPicker, IntegrityCheckCard
  - Feature-flag context + RequireFeature wrapper + AdminSidebar
    featureFlagsAny derivation + UI-hiding sweep

Build infra
  - vite.config: fullcalendar chunk carved off (~200 KB lazy-loaded)
  - frontend/package.json: tiptap, fullcalendar, signature_pad,
    react-international-phone, et al.
  - tailwind + prose styles updated for editor surfaces

3-way merge note: 1 conflict (CustomerDetailPage.tsx) hand-resolved
to keep upstream's SUPPORTED_LANGUAGES.map() data-driven pattern
over feat/crm's hardcoded option list; feat/crm's DecimalInput
import preserved alongside.
2026-05-26 18:19:32 +02:00
Luca d543949188 feat(crm): backend code — services + routes + utilities + tests
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).

Services (CRM)
  - quoteService — full lifecycle (draft → sent → accepted → converted
    to event/invoice), Skonto + Storno + reissue paths
  - invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
    monthly-billing accumulator, payment-check tokens, dunning ladder
  - contractService — block-composable contract editor, in-browser
    signature flow, wet-PDF upload path, integrity check, audit trail
  - customerHoursService — per-entry locking, billing integration
  - dealsService — cross-document lineage (deal_uuid)
  - taxReportService — quarterly aggregates + CSV/PDF export
  - eventReminderService — pre-event customer reminder cron pass
  - _renderContext — shared issuer/recipient blocks across PDF types
  - pdfService extensions — custom-font registration, font picker

Routes (admin + public)
  - adminQuotes, adminInvoices, adminContracts, adminCalendar,
    adminDeals, adminTaxReport, adminDev, adminBusinessProfile
  - publicQuotes (accept/decline), publicContracts (sign),
    publicPaymentCheck
  - Extensions on adminEvents, adminCustomers, adminSettings,
    adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
    adminCategories, adminUsers, adminArchives, adminDashboard
  - server.js wires the new mounts (kept upstream's noStoreCache on
    customer routes per 3-way merge)

Utilities
  - schemaCache (cached hasColumn lookups across services)
  - documentSequences (atomic gap-free numbering — §14 UStG)
  - safePath (path-containment guards at fs stream boundaries)
  - clientIp (sanctioned XFF reader for audit logs)
  - publicTokenGuards (pre-multer token validation + attempt counters)
  - numericHelpers (ensureInt / ensureNumber consolidation)
  - dateFormatter (formatShortDate + dateInputLang)
  - dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile

Infrastructure
  - Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
    Montserrat / Noto-Sans / Playfair-Display / Poppins)
  - Backend package.json + lock updates (pdfkit, signature_pad,
    qrcode, et al.)
  - Sample storage layout under storage/business-docs/quote/

Tests
  - 14 new test files covering quote/invoice/contract lifecycle,
    installment plan reshape, line-item hierarchy, customer hours,
    payment check, tax report PDF, IBAN parsing, filename sanitiser
2026-05-26 18:18:51 +02:00
Luca 60abe8c76d chore(migrations): consolidate CRM migrations 102-143 + extract email-template seeds to self-heal services
Replaces what would have been 42 individual in-flight migrations
(102→143 on feat/crm) with one consolidated migration that creates
every CRM table in its final shape — no ALTER chains. Coexists with
upstream's pre-existing 102-106 by filename suffix; the runner sorts
within same-number groups.

Tables consolidated:
  - business_profile + business_bank_accounts (issuer block, fonts,
    PDF layout knobs, tax_id, timezone)
  - payment_term_templates (legacy) + payment_net_days_templates +
    payment_timing_templates (124's split)
  - quotes / quote_line_items / quote_line_item_presets / quote_action_tokens
  - invoices / invoice_line_items / invoice_payment_log /
    invoice_payment_check_tokens
  - contracts / contract_blocks (13 system blocks seeded) /
    contract_block_inclusions / contract_action_tokens
  - event_payment_plans, customer_hour_entries, document_sequences

ALTER on upstream tables (hasColumn-guarded):
  - events: quote_id, calendar columns (event_time_*, is_full_day),
    event_reminder_*
  - customer_accounts: billing_cadence/cycle_day, country_name,
    feature_hours_logging, hourly_rate_minor

Seeds:
  - RBAC perms (quotes/bills/contracts .view/.manage) + customers.create
    split into edit + events (mig 134)
  - Feature flags (quotes, bills, contracts, hoursLogging, taxReport,
    calendar, calendarBooking, reminderEmails, crmDevelopment, messaging
    — all default OFF)
  - 30+ CRM app_settings rows (skonto/QR/reminder windows, payment
    defaults, installment defaults, ToS, event reminder defaults)
  - 4 + 5 + 4 payment-term system rows across the legacy + split tables

Email-template content moves out of the schema diff into three
runtime self-heal service files that idempotently create missing
rows + backfill empty translations on first access (per the maintainer's
"never ship compensation migrations" rule):

  - backend/src/services/crmEmailTemplates.js (NEW) — quote_sent,
    quote_accepted_*, quote_declined_admin, invoice_sent,
    invoice_reminder_first/second, invoice_paid_receipt,
    invoice_cancelled, invoice_payment_check,
    invoice_paid_admin_notification, storno_issued
  - backend/src/services/contractEmailTemplates.js — contract_sent,
    contract_fully_signed, contract_signed_admin_notification
  - backend/src/services/eventReminderTemplates.js — event_reminder_default
    + per-event-type variants

Smoke-tested on fresh sqlite DB: 84 migrations apply cleanly,
all CRM tables present, seeds populated.
2026-05-26 18:18:15 +02:00
Paul Nothaft b5e7f9cec1 Merge pull request #553 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
chore(beta): release 3.54.6-beta.0
v3.54.6-beta.0
2026-05-26 11:29:23 +02:00
github-actions[bot] 08fa2e9b63 chore(beta): release 3.54.6-beta.0 2026-05-25 20:19:09 +00:00
Paul Nothaft 7ef0e40e7c Merge pull request #552 from the-luap/fix/v1-events-feedback-theme-550
fix(api/v1): accept color_theme + create feedback row on event create (#550)
2026-05-25 22:18:49 +02:00
Paul Nothaft 1b521e761c fix(api/v1): accept color_theme + create feedback row on event create (#550)
POST /v1/events was a strict subset of the admin create path: it did not
accept color_theme on the body, and it skipped the event_feedback_settings
insert that adminEvents.js does. Two visible bugs followed.

1. Editing an API-created event in the admin UI snapped the theme picker
   to GALLERY_THEME_PRESETS.default (EventDetailsPage.tsx falls through to
   the default preset when event.color_theme is falsy), and saving wrote
   that default back. Inherited themes were silently clobbered.

2. The "Enable Guest Feedback by default" admin setting (#520) did not
   apply to API-created events. With no event_feedback_settings row the
   gallery UI reads feedback as off, regardless of
   event_default_feedback_enabled.

Fix mirrors the admin path:

  - color_theme accepted on the request body (optional, persisted as-is —
    preset name or JSON-encoded ThemeConfig, same shape adminEvents
    stores).
  - feedback_enabled accepted on the request body; when omitted, falls
    back to the event_default_feedback_enabled global setting (same
    behaviour adminEvents.js:511-520 implements via readBooleanSetting).
  - event_feedback_settings row inserted when feedback resolves to true,
    using the same sub-flag defaults as the admin form (everything on
    except require_name_email).

OpenAPI JSDoc updated so docs.picpeak.app picks up the new fields.

Tests cover all four scenarios — explicit color_theme persisted, JSON
theme persisted verbatim, explicit feedback_enabled creates the row,
omitted feedback_enabled honours the global setting, and a validator
regression for non-boolean feedback_enabled.
2026-05-25 10:29:33 +02:00
Paul Nothaft ce1ccf0a4d Merge pull request #549 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
chore(beta): release 3.54.5-beta.0
v3.54.5-beta.0
2026-05-22 14:43:15 +02:00
github-actions[bot] 793aa1247e chore(beta): release 3.54.5-beta.0 2026-05-22 12:41:21 +00:00
Paul Nothaft b351d17ee9 Merge pull request #548 from the-luap/fix/nginx-forwarded-proto-547
fix(nginx): honour outer X-Forwarded-Proto when behind a reverse proxy (#547)
2026-05-22 14:40:55 +02:00
Paul Nothaft 5488de3383 fix(nginx): honour outer X-Forwarded-Proto when behind a reverse proxy (#547)
When PicPeak runs behind NPM / Traefik / Caddy, the inner nginx receives
plain HTTP from the outer proxy. The previous `X-Forwarded-Proto $scheme`
therefore always forwarded "http" to the backend, even when the public URL
was HTTPS. Express has `trust proxy` enabled for loopback/linklocal, so
req.secure became false, the Secure cookie flag wasn't set, and generated
URLs (cookies, tokens) used http://.

Add a top-of-file `map` block that picks the incoming X-Forwarded-Proto
when present and falls back to `$scheme` for direct access. Applied to both
nginx.conf (bundled production image) and nginx.dev.conf.

Validated with `nginx -t` against nginx:1.28-alpine (the same image used
by Dockerfile.prod / Dockerfile).
2026-05-22 13:32:01 +02:00
Paul Nothaft 6b6ac64346 Merge pull request #537 from rpintodasilva/imp/french-translation
French Transalation - v2
2026-05-22 10:30:44 +02:00
Paul Nothaft 27e9b0535e Merge pull request #545 from the-luap/chore/clawpatch-review-fixes
chore: address clawpatch review findings
2026-05-21 19:07:17 +02:00
Paul Nothaft dba98f1325 chore: address clawpatch review findings (test scope, deps, legal-page hardening)
- frontend: `npm test` now runs all 7 vitest suites instead of one hardcoded
  file; the previously skipped ProtectedImage / Skeleton / usePublicSettings /
  contrast / themeMigration / url suites are now active in CI
- frontend: wrap ThemeCustomizerEnhanced test in QueryClientProvider so the
  newly-enabled run passes (component uses useQuery internally)
- root: drop unused better-sqlite3 / canvas / node-fetch + their
  prebuild-install/tar-fs override (backend keeps its own copies); add dotenv
  so playwright.config.ts can load on a clean install; add name/version/private
- LegalPage: scheme-validate external_url before window.location.replace so a
  CMS edit can't redirect visitors to javascript:/data:
- LegalPage: force rel="noopener noreferrer" on target="_blank" anchors in
  sanitized CMS HTML to block reverse-tabnabbing
2026-05-21 17:10:34 +02:00
Paul Nothaft 1cf492f4b9 Merge pull request #544 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
chore(beta): release 3.54.4-beta.0
v3.54.4-beta.0
2026-05-21 10:24:50 +02:00
github-actions[bot] 955ada4945 chore(beta): release 3.54.4-beta.0 2026-05-21 08:24:22 +00:00
Paul Nothaft 9607b4666c Merge pull request #542 from the-luap/fix/recover-orphaned-527
fix: recover three orphaned commits from #527 (BRAND_TITLE runtime, Web Share, pan zoom)
2026-05-21 10:23:59 +02:00
Paul Nothaft 8c36f80471 Merge pull request #543 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
chore(beta): release 3.54.3-beta.0
v3.54.3-beta.0
2026-05-21 10:22:59 +02:00
github-actions[bot] c1e8e0d73a chore(beta): release 3.54.3-beta.0 2026-05-21 08:22:26 +00:00
Paul Nothaft 3e39112a12 Merge pull request #541 from the-luap/fix/lightbox-heart-icon-fill-538
fix(lightbox): fill the heart icon when liked (#538 follow-up)
2026-05-21 10:21:59 +02:00
Paul Nothaft efa6b4a205 fix(brand-title): runtime substitution so GHCR-image users can override (#521 follow-up)
@Rekoo-PS confirmed the prior #521 fix landed on beta but reported the
preview still shows the default "PicPeak" title — their brand is
"arkan-studio". Root cause: that fix used Vite's build-time
%VITE_DEFAULT_TITLE% substitution. Self-hosters running the pre-built
ghcr.io/the-luap/picpeak/frontend image can't override at build time
without rebuilding, so they were stuck with whatever the upstream
build baked in.

Pivot to runtime substitution: the frontend container now reads
BRAND_TITLE / BRAND_DESCRIPTION env vars on startup and envsubsts
them into index.html. Change the values in .env, restart the frontend
service, done — no rebuild required.

Mechanics:
  - frontend/index.html: tokens are now ${BRAND_TITLE} /
    ${BRAND_DESCRIPTION} (shell expansion syntax, passes through Vite
    unchanged into the built dist).
  - frontend/Dockerfile: install gettext (provides envsubst), snapshot
    /usr/share/nginx/html/index.html → index.html.tpl at build, install
    docker-entrypoint.sh, wire ENTRYPOINT to it. The .tpl is the
    immutable source — every container start re-renders index.html
    from .tpl, so restarts pick up new env values cleanly (no
    accidental "first-boot env stuck forever" trap).
  - frontend/docker-entrypoint.sh: applies defaults if env unset,
    runs envsubst (locked to BRAND_TITLE + BRAND_DESCRIPTION
    explicitly so /assets/*.js template literals aren't touched if
    anyone ever extends substitution to the bundle), execs nginx.
  - frontend/vite.config.ts: drop the htmlTitleDefaults plugin — no
    longer needed since substitution is fully runtime.
  - frontend/.env.example + .env.production.example: drop the
    VITE_DEFAULT_* docs (the vars no longer have effect).
  - docker-compose.yml + docker-compose.production.yml: pass
    BRAND_TITLE / BRAND_DESCRIPTION env into the frontend service
    with sensible defaults so unconfigured installs work unchanged.
  - .env.example: add BRAND_TITLE / BRAND_DESCRIPTION with comment
    pointing at the social-preview use case.

Verified end-to-end against the built image:
  - BRAND_TITLE="Arkan Studio" BRAND_DESCRIPTION="Wedding photographs
    by Arkan Studio" → index.html serves <title>Arkan Studio</title>
    + og:title="Arkan Studio" + og:description correctly substituted.
  - .tpl preserves ${...} tokens so the next restart can re-substitute.
  - Bundle assets unaffected.
  - Defaults applied when env unset → <title>PicPeak</title>.

Docs PR in picpeak-docs describes the two new env vars under
"Social link preview fallback" in the environment-variables reference.

Refs: #521
2026-05-21 10:00:21 +02:00
Paul Nothaft 53139b8cb8 fix(lightbox): pan zoomed image with single-finger touch on mobile (#532)
@Rekoo-PS reported zoom on mobile only shows the centre crop — the
image zooms but you can't pan around to see other parts. Single-finger
touch was being routed through the carousel-swipe branch which is
gated on zoom <= 1 (so swipe doesn't fight with pan), so when zoomed
the touch hit no handler at all.

Desktop has the equivalent path via handleMouseDown / handleMouseMove
(line 364), which is why this only manifests on mobile.

Add a single-finger pan branch to the touch handlers that mirrors the
mouse path:
  - handleTouchStart: when zoom > 1 and one finger, record dragStart
    relative to the existing dragOffset (so subsequent moves continue
    from where the last pan left off, not from origin).
  - handleTouchMove: when isDragging + zoom > 1 + one finger, update
    dragOffset from touch position.
  - handleTouchEnd: clear the isDragging flag (offset persists so the
    image stays where the user left it).

Also fix a latent bug surfaced while reading the pinch-zoom path:
when pinch-out drops zoom back to 1.0, dragOffset wasn't reset, so the
photo sat off-centre at natural zoom. Re-centre in handleTouchMove
when newZoom drops to <=1 with a non-zero offset.

Carousel swipe stays disabled when zoomed (existing behaviour). Mouse
path untouched. Pinch-to-zoom path untouched.

Refs: #532
2026-05-21 10:00:21 +02:00
Paul Nothaft b2bbf7efb5 feat(lightbox): save photo to Photos app on mobile via Web Share (#531)
@Jasper2213 reported non-technical clients struggle to get downloaded
photos into their Photos / Gallery app — current flow goes through the
Files folder, requires unzipping for the bulk download, and is hard to
explain over email. Browsers can't write directly to the OS Photos app
(it's a protected location), but navigator.share({ files: [...] }) opens
the native share sheet which on iOS includes "Save Image" and on Android
includes "Save to Photos" / "Save image" — exactly the affordance non-
technical users are looking for.

Plumbed through three layers:

1. galleryService — new savePhotoToDevice(slug, photoId, filename).
   Fetches the photo blob, probes navigator.canShare({ files: [file] })
   with a representative File (some browsers return true for empty
   files arrays even when they won't accept a non-empty one), and:
     - shares if supported,
     - falls back to the existing <a download> path otherwise.
   AbortError on share() means the user dismissed the sheet — that's
   a choice, not a failure, so no fallback. Any other error falls
   through to a regular download so the user still gets the file.
   Refactored the existing downloadPhoto to share the fetch + trigger
   helpers (no behaviour change for the other 3 callers; they keep
   the regular download path).

2. useGallery — new useSavePhotoToDevice() hook next to the existing
   useDownloadPhoto(). Onsuccess toast omitted because the share-sheet
   path doesn't finish from this code's perspective — the OS UI takes
   over and the user picks the destination, so "Photo downloaded" is
   misleading. Fallback path stays silent to keep the two flows
   symmetrical (the file appearing in Downloads is its own signal).

3. PhotoLightbox — swap the existing useDownloadPhoto call site to
   useSavePhotoToDevice. No UI change. Desktop unchanged. Other
   download buttons (PhotoGrid, PhotoGridWithLayouts, GalleryView
   bulk) still use useDownloadPhoto — scoping this PR to the
   lightbox download button per the discussion thread.

Browser support:
  - iOS Safari 15+:    Web Share Files → "Save Image" → Photos      ✓
  - Chrome Android:    Web Share Files → "Save to Photos" / "Save"  ✓
  - Desktop Chrome:    canShare returns false → regular download     ✓
  - Desktop Safari:    canShare returns false → regular download     ✓
  - Firefox (any):     no Web Share File support → regular download  ✓

No new tests — the flow is browser-API-driven; jsdom doesn't model
navigator.share or canShare, so a meaningful unit test would mostly
exercise the mock rather than the contract. Verified the build is
clean (tsc --noEmit + vite build both pass).

Refs: #531
2026-05-21 10:00:21 +02:00
Paul Nothaft 600c29db8a fix(lightbox): fill the heart icon when liked (#538 follow-up)
@Tietge86 spotted that both branches of the heart-icon className were
`text-white` — the conditional was a no-op, the `fill-current` class
that would actually fill the icon was missing entirely. The button
background was turning red on like, but the heart icon stayed as a
white outline against the red, making it nearly invisible.

Move text-white outside the conditional (always white against the
red/dark backgrounds the button uses), and add fill-current to the
liked branch so the heart fills in.

Same shape as bug 2 of the original report — the like state needed to
be visually unambiguous. PhotoLikes.tsx was already fixed in this PR;
this catches the equivalent latent bug in the inline lightbox toolbar
button.

Also: bug 4 of the original report (recovery flow) turned out to be
SMTP misconfig on the reporter's end (mailhog silently dropping
emails), not a PicPeak bug. Confirmed in this thread; no further
backend changes needed.

Refs: #538
2026-05-20 17:28:50 +02:00
Paul Nothaft 4d92fb4590 Merge pull request #540 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
chore(beta): release 3.54.2-beta.0
v3.54.2-beta.0
2026-05-20 16:49:45 +02:00