Commit Graph
100 Commits
Author SHA1 Message Date
Luca d0bdcb1a6a Merge pull request #355 from Luca-Timo/feat/messages-email-client
feat(messages): Outlook-style Messages email client (3 phases, flag-gated)
2026-07-07 10:48:46 +02:00
Luca 768e84711f feat(messages): Phase 3 — editable-template composer, reply + create actions
The CRM action buttons and Reply now open a send-composer, not a silent
templated send.

- New send-composer (MessageComposer): loads the rendered template (via
  previewTemplate) or a reply stub into a fully-editable body — the admin can
  rewrite it or drop a note anywhere before sending. On send it goes out as-is
  (server-sanitized), no template re-render.
- Backend: emailProcessor.sendRawEmail() sends admin-edited HTML via the
  configured SMTP identity; POST /admin/email/send sanitizes + sends + records
  the message in email_queue as a 'manual' send.
- Migration 155: email_queue.origin ('system' default | 'manual'). The Sent
  stream now splits by origin — Automated ▸ Sent = system, Customers ▸ Sent =
  the human/edited messages (which finally populates that folder). /queue gains
  an origin filter + returns origin.
- Toolbar wired: Reply enabled on inbound customer mail (prefilled + quoted);
  Create Quote/Contract/Invoice open the composer with that template loaded;
  Gallery opens a blank compose. Accounting/Forward/Archive/Delete stay disabled
  (later phases). After send, jumps to Customers ▸ Sent.

Deferred to a later phase: two-way IMAP write-back; per-identity SMTP (manual
sends currently use the global from address). Frontend build + migration boot
verified.
2026-07-07 10:42:54 +02:00
Luca ee46cf2125 feat(messages): Phase 2 — customer (hello@) mailbox + inbound body capture
Second inbound mailbox and real message bodies for the Messages viewer.

Backend:
- Migration 154: mail_accounts table (additional inbound mailboxes beyond the
  primary accounting IMAP) + received_emails.{account_key,to_address,body_html,
  body_text}. Additive/guarded.
- emailIntakeService now polls the accounting mailbox AND every enabled
  mail_accounts row. Extracted pollAccountOnce(cfg, {accountKey, routeToExpenses});
  accounting keeps its exact attachment->expenses behavior, customer mail is
  logged with its body and NOT routed to accounting. Inbound HTML is sanitized
  server-side (sanitize-html) on ingest.
- adminEmail: /received gains an account filter + returns account_key/to_address
  (bodies excluded from the list); new GET /received/:id returns the body;
  GET/POST /accounts + /accounts/test manage the extra mailboxes.

Frontend:
- Customers inbox now pulls the hello@ mailbox; reading pane renders the
  sanitized body in a strict (script-less, no same-origin) sandboxed iframe.
  Accounting inbox shows bodies too. Toolbar context keys off the mailbox.
- CustomerMailboxCard in Settings -> Email (behind the messaging flag) to
  configure + test the hello@ IMAP box.

No behavior change to the existing accounting inbound flow. Frontend build +
migration boot verified.
2026-07-07 10:11:58 +02:00
Luca 26eeb76197 feat(messages): Phase 1 read-only Messages viewer (email client shell)
New admin "Messages" page — a three-pane mail viewer over the mail picpeak
already stores, feature-flagged behind `messaging` (default off):

- Sidebar account tree: All mail / Customers (hello@) / Accounting (rechnungen@)
  / Automated (no-reply@), matching the agreed IA.
- Automated + All Sent = email_queue (listQueue); Accounting + All Inbox =
  received_emails (listReceived). Customers folders show an explanatory empty
  state pending the hello@ mailbox (Phase 2).
- Reading pane renders the sent body from rendered_html (migration 119) in a
  sandboxed iframe; new GET /admin/email/queue/:id returns body + cc +
  attachment filenames (disk paths never exposed).
- Received supplier invoices: envelope + rasterized PDF viewer reusing the
  accounting inbound blob endpoint, plus "Open in Accounting inbox".
- Context toolbar (Reply/Forward/Create Quote-Contract-Gallery-Invoice /
  Book-as-expense-Re-bill) present but disabled — wired in later phases.

Reuses email.service, accounting inbound blob endpoint, RequireFeature +
PermissionGate (email.view), Tailwind dark: theming. No schema change.
2026-07-07 03:15:41 +02:00
Luca ea86871b81 Merge pull request #354 from Luca-Timo/fix/email-language-and-payment-confirm
fix(email,ui): billing emails follow customer language + readable pay…
2026-07-06 19:33:47 +02:00
Luca fcc3e9195d fix(email,ui): billing emails follow customer language + readable payment-check confirmation
- Billing/dunning emails no longer render in the gallery event's language.
  emailProcessor now honors an explicit `__language` in the email data
  (else falls back to the event-first recipient resolution), and the invoice
  reminder passes the customer/invoice locale (customer.preferred_language
  || invoice.language || 'de'). Fixes German customers getting English
  dunning notices. (#760)
- Payment-check confirmation card ("Action recorded") is now theme-adaptive
  (green tint + readable text on both light and dark surfaces) instead of a
  hardcoded light-green mix + dark-green title that vanished in dark mode. (#759)
- The per-customer "Preferred language" field already exists
  (CustomerDetailPage) plus the business-profile default; updated the helper
  text to note billing emails now honor it too. (#761)
2026-07-06 19:27:57 +02:00
Luca 9a763337b6 Merge pull request #752 from Luca-Timo/fix/dunning-payment-check-template-key
fix(invoices): correct payment-check email template key so dunning email sends
2026-07-05 19:24:37 +02:00
Luca 3682de195b fix(invoices): correct payment-check email template key so dunning email sends
queuePaymentCheckEmail queued the admin payment-check email with template
key 'invoice_payment_check_admin', but no such template exists — the only
one is 'invoice_payment_check' (crmEmailTemplates.js:217, seeded by
migration 116), which IS the admin "Paid / Partial / Not paid" email. The
processor does an exact template_key lookup and throws "template not
found", so every dunning admin payment-check email failed, retried to the
cap, and got stuck pending.

One-word fix: queue 'invoice_payment_check'. Unbreaks the built-in
invoice-dunning flow's email step. (Rebased onto the post-decompose
invoiceService refactor — the line now lives in invoice/payments.js.)
2026-07-04 23:51:22 +02:00
Luca fa7665c5b1 fix(backup): address .picpeak review — table filter, superuser guard, tests
From the-luap's review:

- Import no longer trusts manifest.tables blindly. It now intersects the
  manifest's table list with the real data tables of THIS database
  (listDataTables(), which already excludes knex_migrations/_lock) and
  drops anything else. A crafted/corrupted .picpeak listing knex_migrations
  or a non-existent table can no longer wipe it; skipped tables are logged.
- The Postgres session_replication_role='replica' SET (needs superuser) is
  now wrapped: on a managed-PG non-superuser it fails BEFORE any rows are
  deleted (transaction rolls back) and surfaces a clear, actionable 400
  instead of a cryptic permission error.
- Export: on an archiver error, the temp out dir (a partial plaintext-secret
  archive) is now removed instead of orphaned.

Tests (+4, now 26): engine-mismatch rejection, forward-only newer-refused,
non-picpeak rejection, and files/ restored + filesRestored asserted.
2026-07-03 01:39:13 +02:00
Luca 07b450a954 feat(setup): per-feature config step after feature selection
When the chosen features need config the wizard can collect, 'Finish' on
the usage step now advances to a lean config step instead of jumping to
the dashboard:
- Invoicing (if Invoices): company/legal name, address, VAT-ID or tax
  number, IBAN, currency → saved to business-profile + a default bank
  account. Carries the bank/VAT legal disclaimer.
- Email (if reminders/incoming-mail/whatsapp/invoices): SMTP host/port/
  user/pass/from → saved to email_configs.
Each section persists only if started, and 'Skip for now' is always
available — soft settings keep their seeded defaults. en + de strings.
2026-07-02 22:10:54 +02:00
Luca a95ee473ae feat(setup): add restore-from-backup branch to the first-run wizard
The usage step now offers 'Migrating from another PicPeak?' → a restore
step that uploads a .picpeak (reusing PicpeakRestoreCard) to clone another
instance onto this fresh one, preserving the account just created. en + de
strings added.
2026-07-02 21:57:50 +02:00
Luca 86324e7da7 feat(backup): fold .picpeak restore into the Restore wizard's Upload source
Removes the redundant standalone .picpeak card. The wizard's 'Upload
Backup' source now splits into two kinds: '.picpeak backup' (the working
portable restore — renders the upload + destructive-confirm flow inline)
and 'Manifest + files' (legacy, still 'Manifest Upload functionality
coming soon'). en + de strings added.
2026-07-02 21:38:48 +02:00
Luca d4b143f313 fix(setup): keep the first-run wizard light regardless of dark mode
The setup page background used var(--color-background), which flips to
#0a0a0a under the .dark class while the wizard card stays hardcoded light
— giving a dark page + light card mismatch in dark mode. Pin the first-run
screen to its intended light branded look (fixed #fafafa bg / #171717 text)
so all three steps render consistently.
2026-07-02 21:29:47 +02:00
Luca cc79b3d9ec refactor(backup): move .picpeak download to the Dashboard tab
Downloading a portable backup is a "make a backup" action, so it belongs
next to "Run Backup Now" on the Dashboard, not under Restore. Split the
combined card into PicpeakExportCard (Dashboard) and PicpeakRestoreCard
(Restore). The manifest stays bundled inside the .picpeak, so there is no
separate manifest-only download for the portable format.
2026-07-02 20:37:35 +02:00
Luca f57462f798 fix(backup): make .picpeak roundtrip work on Postgres
Two Postgres-only bugs found by a live docker-pg roundtrip (SQLite tests
passed because neither reproduces on SQLite):

- Export: knex `.stream()` pulls in the optional `pg-query-stream` module
  (not bundled) and throws on pg. Switched to a plain per-table `select`
  — works on both engines, no new dependency. Rows are DB metadata
  (blobs live under files/), so holding a table in memory is fine.
- Import: the pg driver returns json/jsonb columns as parsed JS values,
  so re-inserting a scalar like the string "PicPeak" sent it unquoted and
  pg rejected it ("invalid input syntax for type json"). Now introspects
  each table's json/jsonb columns and re-serialises those values before
  insert (pg only; SQLite stores json as TEXT and round-trips as-is).

Verified end-to-end on docker Postgres: export 85 tables, full-override
import, current account preserved, post-backup data removed.
2026-07-02 20:13:18 +02:00
Luca 66d61c87ca feat(backup): .picpeak download + upload-restore UI in Backup Manager
Adds a self-contained "Portable backup (.picpeak)" card to the Restore
tab, completing the GUI-only roundtrip:
- Download: optional "include original photos" toggle + a prominent
  plaintext-secrets warning, streams the file via a blob download.
- Restore: file picker → destructive confirmation modal ("replaces ALL
  data except your current account, cannot be undone") → multipart upload
  to /admin/backup/picpeak/import → success summary. If the backup uses
  external media, shows a banner to reconfigure the mount, with a docs link.
Kept separate from the legacy RestoreWizard (different format/flow). en+de
strings added; dark-mode variants throughout.
2026-07-02 19:45:16 +02:00
Luca 2b66f6d889 feat(backup): upload + restore endpoint for .picpeak
POST /admin/backup/picpeak/import — multipart upload of a .picpeak,
streamed to a temp file (after auth, so unauthenticated requests can't
push a large file to disk), then restored via picpeakImportService with
currentAdminId = the logged-in operator (preserved across the override).
Gated on backup.restore. Returns usesExternalMedia so the UI can prompt to
reconfigure the external-media mount. Temp upload is always unlinked.

Completes the backend half of the GUI-only roundtrip (export download +
import upload). Multipart is already allowed by the CSRF content-type guard.
2026-07-02 19:37:17 +02:00
Luca 2920d82186 feat(backup): .picpeak import/restore (full override, keeps current account)
Receiving half of the roundtrip. picpeakImportService.importFromPicpeak():
- Validates the manifest: rejects non-picpeak files, a newer format, an
  engine mismatch (pg↔pg / sqlite↔sqlite only), and a backup from a NEWER
  schema than this instance (forward-only). knex_migrations absence is
  tolerated (test harnesses).
- Snapshots the current logged-in admin, then wipes + reloads every table
  from the backup NDJSON in one transaction with FK enforcement suspended
  (pg: session_replication_role=replica reset before commit; sqlite:
  defer_foreign_keys). knex_migrations is never touched, so the target's
  schema/migration state is preserved.
- Re-injects the current account so the operator is never locked out; a
  backup admin colliding on email is overwritten with the current creds.
- Restores files/ into storage and detects external-media references so the
  caller can prompt to reconfigure the mount.

Roundtrip integration test proves: backup data restored, current account
survives a full override (different email → added), and the email-collision
case keeps the operator's password.
2026-07-02 19:35:48 +02:00
Luca 422dfe1cc8 feat(setup): add "How will you use PicPeak?" feature-selection step
After the admin account is created (and we're logged in), the wizard now
shows an opt-in feature step instead of jumping straight to the dashboard.
Grouped ticks (Client management / Accounting / Automation) map to the
existing feature flags; galleries/analytics/userManagement stay always-on
and are noted, not listed.

- Selection is saved via the existing authenticated PUT /admin/feature-flags,
  whose server-side applyDependencyRules resolves dependencies (e.g. Invoices
  pulls in Accounting) — the wizard only sends raw ticks.
- Labels/descriptions reuse settings.features.<key>.title/description so
  translations stay in sync (en + de verified for all 14 features).
- Saving is best-effort: on failure the admin still enters the app and can
  set features later in Settings.
- New en/de strings for the usage step.

Option A (lean wizard): this is the feature-selection foundation; per-feature
hard-required config steps + the restore-from-backup branch come next.
2026-07-02 19:31:10 +02:00
Luca 38b3aef63d feat(backup): .picpeak portable export (engine-neutral logical snapshot)
First half of the GUI-only backup roundtrip. Adds a self-describing
".picpeak" archive that can be downloaded from one instance and (later)
re-uploaded to another via the web UI only.

- picpeakExportService.createPicpeak(): dumps every table as NDJSON
  (tables introspected at runtime — no hardcoded list, won't rot), plus
  a manifest (format version, app version, DB engine, latest migration,
  per-table row counts + checksums, includePhotos, contains_secrets),
  plus files/ (business-docs + uploads always; original gallery photos
  only when includePhotos). NDJSON is engine-neutral so the target
  rebuilds schema via migrations then loads rows — enabling pg↔pg /
  sqlite↔sqlite and forward-only auto-migrate.
- GET /admin/backup/picpeak/export?includePhotos= streams the file and
  sets X-Picpeak-Contains-Secrets (the file holds plaintext SMTP pass,
  admin hashes, API keys — the UI must warn).
- Purely additive: no existing backup/restore path is touched.

Integration test proves the archive shape, knex-table exclusion, and
row-count/NDJSON consistency (85 tables on the seed schema).
2026-07-02 19:13:58 +02:00
Luca 5b535f8658 fix(settings): remove duplicate Mail import that broke the dev server
SettingsPage.tsx imported `Mail` from lucide-react twice — in the main
icon block (line 20) and again in a later import (line 58). The
@vitejs/plugin-react babel transform rejects the duplicate with
"Identifier 'Mail' has already been declared", so `npm run dev` crashed
when the module loaded. The production `vite build` (esbuild) silently
dedupes it, which is why CI/Docker builds passed and it went unnoticed.

The two imports overlap only on `Mail`; drop it from line 58, keeping
that line's six unique icons (Briefcase, Receipt, ScrollText, Landmark,
Smartphone, MonitorPlay). Verified: single Mail import remains, prod
build passes, and the vite dev transform of SettingsPage now returns 200
with no "already been declared" error.
2026-07-02 17:37:51 +02:00
Luca b0912c7427 feat(setup): validate setup token at step 1 before advancing
Previously "Continue" on the token step only checked the field was
non-empty; a wrong token wasn't caught until the final submit, after the
user had filled in email + password. Add a non-burning verify:

- backend: POST /setup/verify-token constant-time compares the token
  without consuming it (createInitialAdmin still claims it atomically on
  submit), gated on no-admin-exists and rate-limited like /setup/admin.
- frontend: step-1 "Continue" calls verifyToken and only advances on a
  valid token; a wrong token shows the invalidToken error on the field,
  429 -> too-many-attempts, 409 -> redirect to login.

Adds integration tests for accept-without-burn / reject / closed-once-set.
2026-07-02 15:18:22 +02:00
Luca 3e69c5df3f fix(setup): match first-run logo size to the login page default
The header logo used a hardcoded 64px frame; the login page renders a
medium (200x150) frame via resolveLoginLogoClasses. Reuse that helper
with the default size so /setup and /admin/login read identically.
2026-07-02 15:03:56 +02:00
Luca d9b0eb7232 feat(setup): brand first-run screen and split into two-step wizard
Address post-merge UI feedback on the first-run setup screen — the first
screen any new admin sees:

- Use the bundled PicPeak logo (same asset the login page falls back to)
  on the cream brand plate instead of the generic lucide Sparkles icon.
- Split the flow into two steps: step 1 takes only the one-time setup
  token, with the `docker compose logs backend | grep -i "setup token"`
  recovery command shown prominently (with a copy button) directly under
  the field, plus a docs link for when the logs have rotated away; step 2
  collects email + password. A rejected token bounces back to step 1.

en/de strings added; other locales fall back to en.
2026-07-02 14:56:14 +02:00
Luca 286975dc52 fix(setup): address PR #714 review — password UX, script token, race, nits
Blockers:
- SetupPage now mirrors the server password rule (>=8 with upper/lower/digit) so
  a green client isn't bounced by the server; server errors carry a `field`
  (routes/setup.js) that the client maps to a translated key instead of
  rendering raw English. New i18n: setup.invalidToken, setup.passwordRequirements.
- picpeak-setup.sh: the ADMIN_CREDENTIALS.txt block no longer dead-ends on the
  wizard path — when no legacy admin was seeded it prints the one-time setup
  token (from data/SETUP_TOKEN / docker compose logs) and points at /setup.

Concern:
- createInitialAdmin creates the admin + burns the token in ONE transaction,
  atomically claiming the token (null-if-present, expect 1 row) so a
  double-submit can't create two super_admins. Cross-DB (whereNotNull, trx-only
  writes). Added a concurrency test.

Nits:
- SetupPage redirects to /login when /setup/status errors (no form flash on a
  configured instance).
- Dropped the unused DATABASE_URL from docker-compose.yml.
- Documented why secrets are chmod 644 (three different reader users).
2026-07-02 13:21:55 +02:00
Luca 415bffa04c feat: zero-config first run — in-browser admin bootstrap + auto-generated secrets
Fresh installs need nothing in .env. See PR description for the full feature.
2026-07-01 14:49:18 +02:00
Luca 5582644dc4 fix(whatsnew): decode HTML entities and trim em-dash detail in fallback bullets
The Features-fallback showed raw changelog text, so a commit subject like
'branded URL shortener — /s/<slug> with OG injection' surfaced two problems
in the admin banner:
- release-please escapes <slug> to &lt;slug&gt;; React renders the literal
  entity, so the banner read '/s/&lt;slug&gt;'. Decode the entities
  (&lt; &gt; &amp; &quot; &#39;), &amp; last to avoid double-decoding.
- the technical tail leaked into a user-facing highlight. Drop a trailing
  '— detail' clause (em dash only, so 'mark-paid' is untouched) so the bullet
  reads as the headline 'branded URL shortener'.

Only affects the deterministic fallback; curated <!-- whatsnew --> blocks are
unchanged.
2026-06-30 18:39:21 +02:00
Luca d25178d2e5 ci(whatsnew): let the Models step fail soft so the fallback runs without Models
If GitHub Models is disabled for the org the ai-inference step errors;
without continue-on-error the job would go red and skip the inject+fallback.
Mark it continue-on-error so an unavailable Models cleanly degrades to the
deterministic bullets — the feature now works with Models off, not just on.
2026-06-30 17:28:31 +02:00
Luca 5aeb6905ac ci(whatsnew): generate release highlights via GitHub Models
Activate the What's New highlights step that condenses each release's
Features into <=8 short bullets and injects a <!-- whatsnew --> block the
app reads (utils/whatsNew.parseWhatsNew), with a deterministic fallback.

Runs as a needs: job inside the release-please workflows rather than on a
standalone release: published trigger, because release-please creates the
release with GITHUB_TOKEN and GitHub never starts new workflow runs from
token-generated events -- a standalone trigger would never fire. Shared as
a reusable workflow_call so the stable and beta channels stay in sync.

Best-effort: continue-on-error + fallback mean it can never break a release.
Requires GitHub Models enabled for the org; until then the fallback is used.
2026-06-30 16:43:54 +02:00
Luca de789faec5 Merge pull request #698 from Luca-Timo/docs/comparison-pixieset 2026-06-30 12:35:29 +02:00
Luca 3528f6b8b7 Merge pull request #696 from Luca-Timo/docs/contributor-luap
docs(readme): credit @the-luap as creator/lead maintainer

Docs only no change in Codebase
2026-06-30 11:23:02 +02:00
Luca b0439638bd docs(readme): clarify comparison footnotes — $0 cost caveat + Pixieset video cap
- you still bring your own server (own hardware or VPS) and optional domain.
- Pixieset "unlimited" storage is photos only — video is capped per plan (~0–10 h depending on tier).
- Renumber the PicPeak storage footnote (* → **) so the three markers don't collide.
2026-06-30 11:20:37 +02:00
Luca 721f440fa6 docs(readme): add Pixieset to comparison + customer-accounts/CRM/accounting rows 2026-06-30 11:07:58 +02:00
Luca 748238e8ca docs(readme): credit @the-luap as creator/lead maintainer 2026-06-30 11:06:04 +02:00
Luca 500cf8522e feat(updates): "What's New" highlights after update + pre-update teaser
Surfaces release highlights to admins, sourced from the GitHub release notes
(no AI at runtime). Bullets are written once per release in CI via GitHub Models
(see docs/ci/whatsnew-highlights.yml) into a <!-- whatsnew --> block; the app
reads that block and falls back to the changelog's "### Features" for releases
without it — so it works against today's releases immediately.

- backend utils/whatsNew.parseWhatsNew(body): curated block else Features
  section, strips scope/PR-links, de-dups, caps at 8 (tested).
- GET  /admin/system/updates/whatsnew: highlights for every version moved
  through since the per-instance marker (whatsnew_last_seen_version); fresh
  installs self-anchor silently. Best-effort, never errors.
- POST /admin/system/updates/whatsnew/seen: advance the marker (per-instance).
- /admin/system/updates also returns latestHighlights for the teaser.
- Frontend: WhatsNewBanner (green bar -> modal with "Full changelog" link) on
  the dashboard via adminService; UpdateNotification shows a "New features
  include:" teaser. i18n de/en. No migration (uses app_settings).
2026-06-30 02:31:13 +02:00
Luca e4367e028a fix(invoices): badge held (unsent, no send date) invoices as "Draft"
The earlier change only relabeled is_monthly_draft rows. But a per-event
invoice created from hours is status 'scheduled' with scheduled_send_at = NULL
and is_monthly_draft = false — it never auto-ships (the scheduler only picks
rows with scheduled_send_at <= now), yet it still read "Scheduled" on the
customer panel + lists.

Add a shared isDraftInvoice() helper (scheduled && no send date, or a
monthly/manual accumulator) and use it for the badge in the Bills list, the
invoice detail header, and the customer profile's invoice panel. A scheduled
invoice WITH a future send date keeps "Scheduled".
2026-06-29 19:39:40 +02:00
Luca d1c9e02bcf feat(dashboard): revenue "year" tile toggles 365 days ↔ calendar YTD
Per request, keep the dashboard to four tiles rather than adding a fifth: the
"Revenue · last 365 days" tile is now clickable and toggles in place between
the trailing-365-day window and calendar year-to-date (since Jan 1).

- adminDashboard: new calendar-year cutoff + revenue.calendarYearMinor (same
  cash-basis paid_at window logic as the existing trio).
- StatCard gains an optional onClick (renders as a button); the year tile uses
  it, with a "Tap to switch window" hint for discoverability.
- bills.service CrmOverviewStats.revenue gains calendarYearMinor.
2026-06-29 19:39:40 +02:00
Luca e96ef4c5a3 fix(invoices): add bank transfer to the mark-paid method list
The mark-paid dialog offered Cash / Card / PayPal / TWINT but not bank
transfer — the default method for the QR-bill / IBAN invoices picpeak issues
(createInvoice even falls back to 'bank_transfer'). Added it as the first
option. Backend already accepts paymentMethod as a free string, so no API
change; i18n bills.payment.methods.bankTransfer (de "Überweisung").
2026-06-29 19:39:40 +02:00
Luca ca0944293f fix(invoices): show "Draft" on the invoice detail page for accumulator drafts
Follow-up to the Bills-list change: the invoice detail header still printed
"Scheduled" for a running monthly/manual draft (is_monthly_draft). It already
had a separate monthly-draft badge, but the status pill itself now reads
"Draft" too, matching the list and the Billed-chip link target.
2026-06-29 19:39:40 +02:00
Luca e457656b9d feat(invoices): surface monthly/manual accumulator drafts in the Bills list
Manual/monthly-cadence customers accumulate logged hours into one running
draft invoice (is_monthly_draft, migration 128). That draft gets a real
invoice number and stamps the hours ("Billed: R-2026-0026"), but listInvoices
hid is_monthly_draft rows from the main list — so the invoice looked lost even
though it existed on the customer's monthly-queue card. It also carried status
'scheduled' despite never auto-sending on manual cadence, reading misleadingly
as "Scheduled".

- Bills list now opts into drafts via a new `includeDrafts` query param
  (GET /admin/invoices → listInvoices includeMonthlyDrafts). Pickers/sub-lists
  that reuse billsService.list leave it off, so they're unaffected.
- Draft rows render a distinct "Draft" badge instead of "Scheduled"
  (transformInvoice already exposes isMonthlyDraft).
- The hours "Billed: R-…" chip now links straight to its invoice.
- i18n: bills.status.draft (de "Entwurf", en "Draft").
2026-06-29 19:39:40 +02:00
Luca b9d91385b4 fix(reminders): wrap is_active/is_archived wheres in formatBoolean
eventReminderService used bare boolean literals in its knex .where() calls
(events.is_active/is_archived/event_reminder_disabled and the assigned-
customer c.is_active), instead of the codebase's formatBoolean() convention
(utils/dbCompat). On SQLite, booleans are stored as 0/1, so a bare `true`
relies on knex's coercion rather than the explicit helper every other service
uses — the maintainer flagged this twice (#674, #679). Wrap all four.
2026-06-29 19:39:40 +02:00
Luca c2bc2b098e fix(invoices): show sub-cent Rundung in the editor totals preview
The live totals panel in the quote/invoice editor (LineItemsTable) summed the
per-line rounded totals and showed that as Total — so with crm_invoice_round_total
on, a 4 × (2.5h @ 32.25) invoice previewed CHF 322.52 while the saved invoice +
PDF correctly show 322.50 with a Rundung row. The preview now mirrors the backend.

- LineItemsTable gains a `roundTotal` prop. When set, it computes the clean net
  (full-precision sum rounded once — same rule as backend
  utils/invoiceRounding.cleanNetMinor, including the migration-119 priced
  sub-item override), shows a "Rundung" row for the drift, and folds it into the
  VAT base + Total. Off ⇒ unchanged (no row).
- Bill + Quote editors pass roundTotal from appSettings.crm_invoice_round_total.
- i18n: crm.lineItems.rounding (de "Rundung", en "Rounding").

The saved-invoice detail view already shows the stored clean total, so no change
there.
2026-06-28 17:28:21 +02:00
Luca 4670292139 feat(invoices): optional sub-cent rounding reconciliation ("Rundung" row)
Per-line totals are each rounded to the cent before the net is summed, so
a long time-based invoice can drift a few Rappen from qty × rate — e.g.
68 h × 32.25 = 2193.00, but the 21 rounded line totals sum to 2193.02. This
is the standard "sum of rounded lines" convention (Stripe/QuickBooks/Xero
do the same) and it foots, but some issuers want the total to match the
customer's arithmetic.

New per-issuer setting `crm_invoice_round_total` (default OFF, no migration —
read via getAppSetting with a false default). When on, the create paths store
the full-precision net rounded ONCE (cleanNetMinor), and the drift is shown to
the reader as an explicit "Rundung" row:

    Betrag Netto    2'193.02   (= Σ visible line totals, still foots)
    Rundung            -0.02
    Gesamtbetrag    2'193.00

- New util src/utils/invoiceRounding.js (cleanNetMinor) mirrors the
  migration-119 hierarchy (priced sub-items override their parent) but sums
  at full precision; rate-agnostic, so mixed hourly rates reconcile to one
  clean net. Single document-level VAT rate ⇒ one Rundung row.
- computeTotals (quotes) + createInvoice + payload-preview gain the toggle.
- Render contexts derive the row as storedNet − Σ(line totals); legacy/off
  documents have equal values ⇒ adjustment 0 ⇒ byte-identical output.
  Suppressed on Storno/Mahnung (negated net + sign-flipped lines).
- Storno/tax-report stay correct: both use the stored net scalar, which is
  the clean value (createStorno negates net_amount_minor; it never re-sums).
- pdf-i18n: totals_rounding in all 6 locales (de/en/fr confident; nl/pt/ru
  machine-translated — flag for native review).
- Frontend: toggle on Settings → CRM (Invoices), default off.

Tests: backend/__tests__/utils/invoiceRounding.test.js (real 68h invoice,
mixed rates, discounts, sub-item hierarchy, no-op case).
2026-06-28 15:53:44 +02:00
Luca 2205b0bd68 fix(pdf): correct multi-page invoice/quote layout + drop IBAN dup under Swiss QR
Before drawing the line-items table, the renderer inflated page 1's bottom
margin to reserve room for the bottom-pinned totals block, but the `finally`
restored it on whichever page the table *ended* on — leaving page 1
permanently short on any multi-page document. On long invoices and quotes
this caused:

  - the table to break far too early (only ~6 items on page 1, large blank
    gap beneath)
  - the page-number stamp to land below page 1's phantom bottom margin,
    auto-paginating a stray blank trailing page and desyncing the
    "Seite X von Y" labels (page 1 unnumbered, the blank page labelled
    "Seite 1 von N")

Let the table paginate with the document's normal margins so each page fills
to the bottom; the existing desiredTotalsY check already advances to a fresh
page when the last item row would collide with the pinned totals block.

Also suppress the IBAN block under the totals when a Swiss QR-bill slip is
appended: the slip already prints the account/IBAN in human-readable form,
so it was pure duplication. The EPC QR path keeps the block (its QR lives on
a trailing page, so on-page bank details still help).
2026-06-28 14:49:50 +02:00
Luca 6e20d58487 fix(workflows): make the dashboard pending-approvals card items clickable too
The dedicated Approvals page rows open the underlying document on click, but the
identical card on the admin dashboard didn't — so 'clickable approvals' only half
worked depending on where you looked. Apply the same treatment: the info area is
now a button that navigates to the run entity's detail page (quote -> /admin/quotes/:id,
invoice -> /admin/bills/:id, etc.), reusing the workflows.approvals.openEntity
tooltip. Confirm/Deny stay separate; items with no mappable entity render as plain
text.
2026-06-28 14:34:36 +02:00
Luca 539a83711d fix(workflows): defer quote.accepted/declined emit until the 15-min response window locks
The customer's accept/decline can be toggled for crm_quotes_accept_window_minutes
(default 15) before it locks, and the public page promises exactly that. But the
booking workflow fired on the FIRST accept click and immediately converted the
quote (status -> 'converted'), so a decline within the window was rejected
('Quote cannot be responded to in status converted') — the grace period was dead
on arrival.

recordResponse / adminAcceptQuote now DEFER the workflow emit while the toggle
window is open; the new scheduler sweep finalizeQuoteResponses fires the FINAL
status once response_locked_at passes (idempotent via the new
quotes.workflow_response_emitted_at column, migration 149). A response recorded
with the window already closed (0-min window, or admin decline which locks
immediately) still emits inline. So toggling accept->decline->accept inside the
window converts at most once, for the final state, after the customer's grace
period — and a plain decline never converts.

Trade-off: with the hourly CRM scheduler, the booking flow now starts up to ~1h
after the window locks instead of instantly. Acceptable — the flow gates on admin
review anyway, and the alternative (graph-level wait) wouldn't reach already-
enabled built-ins (admin_toggled_at blocks re-seed).

Adds a finalize sweep test (deferred while open, fires + stamps once locked,
idempotent).
2026-06-28 14:30:26 +02:00
Luca 882cfc0661 fix(workflows): held booking invoices are 'scheduled', not 'pending_delivery' — so send_document can issue them
A quote with no explicit payment timing falls back to a single after_delivery
installment. spawnInstallmentInvoices marked those 'pending_delivery' even in
hold mode, so the booking flow's send_document -> sendInvoice threw 'Cannot send
invoice with status pending_delivery', the run failed, and no invoice email went
out (the symptom: approve the quote->invoice flow, receive nothing).

In hold mode the flow's review gate + explicit send_document IS the delivery
release, so a held invoice is always 'scheduled' (editable + sendable) regardless
of trigger; scheduled_send_at stays null so the scheduler never auto-sends it.
Non-hold after_delivery invoices keep 'pending_delivery' as before.

Adds a regression test (default after_delivery term -> draft -> scheduled+null).
2026-06-27 12:36:35 +02:00
Luca 7727b6714b feat(workflows): make approval rows clickable to open the underlying document
Each approval asks the admin to confirm/deny, but they couldn't see what they
were approving. The row's prompt/meta area is now a clickable button that
navigates to the run entity's detail page (quote -> /admin/quotes/:id, invoice
-> /admin/bills/:id, event/contract/customer likewise) so the admin can review
before deciding. Confirm/Deny stay as separate buttons; rows whose entity has no
detail route (or no entity) render as plain, non-clickable text. Adds the
approvals.openEntity tooltip string (en + de).
2026-06-27 01:03:31 +02:00
Luca 9414b42b7f feat(workflows): implement remaining stub actions (prepare_quote, prepare_gallery, reserve_date)
These were the last guard-stubbed actions — offered in the builder palette but
refused on enable. Now all three are real, backed by existing converters:

- prepare_gallery: alias of prepare_event (a gallery IS an event in picpeak).
- reserve_date: convertToEvent({ skipInvoices: true }) — a pure draft date hold
  with no money documents (new skipInvoices option on convertToEvent).
- prepare_quote: createQuote (customer entity) or duplicateQuote (quote entity),
  producing a status='draft' quote; idempotent via ctx.vars.preparedQuoteId.

With no stubs left, the enable-guard switches from a hardcoded DOCUMENT_ACTIONS
list to a registry lookup: an action node whose config.action has no registered
handler is unimplementable. This can't drift from what the engine can run and
also catches typo'd/future actions. (Fixes the enable-route node mapping to
carry node.type so the action-node filter matches.)

Extends the single-connection SQLite in-trx deadlock fixes to the quote-create
path (prepare_quote runs unattended): nextQuoteNumber reads getAppSetting
through trx, createQuote logs via trx and hoists its hasColumnCached schema-drift
checks before the transaction.

Adds tests for reserve_date (no invoices), prepare_quote (draft, no deadlock),
and registry coverage; retargets the enable-guard refusal test at a genuinely
unregistered action. Full backend suite: 985 passed, 1 skipped.
2026-06-27 00:49:35 +02:00
Luca 4faf5a344a feat(workflows): implement prepare_event so booking_full/booking_simple are enableable
The booking_full / booking_simple flows go prepare_event -> prepare_invoice,
but prepare_event was still a guard-stub, so enabling either flow returned
409 'uses actions that aren't implemented: prepare_event'.

prepare_event now calls convertToEvent({ hold: true }): convertToEvent already
creates the event as is_draft=true AND schedules its invoices, so this creates
those invoices on HOLD (scheduled_send_at NULL) and stashes their ids in
ctx.vars.preparedInvoiceIds. The downstream prepare_invoice already short-
circuits on a populated preparedInvoiceIds, so it ADOPTS the event's held
invoices instead of calling convertToInvoiceOnly again (which would both
double-create and throw ALREADY_CONVERTED_TO_EVENT). The review gate, the
wait-until-event-date, and send_document then issue those same invoices.
send_document(event)=publish is intentionally left a graceful skip — the
gallery is published manually after photos are uploaded, not auto-published
on an empty draft.

convertToEvent gains the same single-connection SQLite deadlock fixes as
convertToInvoiceOnly (getAppSetting reads through trx; logActivity moved after
commit) since prepare_event runs unattended, returns invoiceIds (incl. the
idempotent already-converted re-entry, which recovers them by event_id), and
removes prepare_event from the enable-guard list.

Adds a convertToEvent hold-mode test (draft event + held invoices + quote
linkage) and updates the enable-guard test to a still-stub action
(prepare_gallery). Full backend suite: 982 passed, 1 skipped.
2026-06-27 00:08:05 +02:00
Luca cf424efb4a feat(workflows): wire booking document actions (prepare_invoice/contract + send_document)
Implements the draft-seam booking cutover so the booking_invoice_only flow
becomes enableable. The booking flows trigger on quote.accepted, so the run
entity is the quote:

- prepare_invoice: convertToInvoiceOnly({draft:true}) creates the invoice(s)
  on HOLD (scheduled_send_at NULL, status stays 'scheduled') so the scheduler
  never auto-sends before the review gate; crash-recovery recovers drafts by
  the quote's deal_uuid. Stores ids in ctx.vars.preparedInvoiceIds.
- prepare_contract: createFromQuote (idempotent via converted_contract_id).
- send_document: dispatches the prepared draft (invoice -> sendInvoice each id,
  contract -> sendContract).
- resolveActor: quote creator -> workflow creator -> first admin.
- prepare_contract/prepare_invoice/send_document removed from the enable-guard
  list; prepare_event/prepare_quote/prepare_gallery/reserve_date still guarded,
  so booking_full/booking_simple stay blocked until the event-path increment.

Fixes a latent single-connection SQLite deadlock these unattended paths would
hit: getAppSetting/logActivity/adminActor read or write the global db, which
deadlocks when issued inside an open knex transaction. Thread the active trx
through getAppSetting, logActivity, nextInvoiceNumber, nextContractNumber, the
spawnInstallmentInvoices audit log, and hoist adminActor before createFromQuote's
transaction. convertToInvoiceOnly now logs after commit and returns invoiceIds.

Adds bookingCutover integration test (hold-mode null send-at, normal scheduled
contrast, contract path no-deadlock) and a route test that the now-implemented
booking invoice actions can be enabled.
2026-06-26 23:48:12 +02:00
Luca f54ec66d05 Merge pull request #337 from Luca-Timo/feat/workflow-engine
Feat/workflow engine
2026-06-26 17:02:05 +02:00
Luca 3356b22688 Merge remote-tracking branch 'origin/beta' into feat/workflow-engine
# Conflicts:
#	frontend/src/components/admin/PublishGalleryDialog.tsx
2026-06-26 17:01:16 +02:00
Luca aa3471efe1 fix(gallery): publish dialog stuck for password-protected galleries with no inline email
handleSubmit required a password whenever requirePassword was true, but the
password field only renders on the inline-email path (requirePassword &&
customerEmail). For a password-protected gallery with no inline email the field
was hidden, so submit blocked on the missing password and the dialog never
closed. Gate password collection + validation on a single `needsPassword`
(requirePassword && customerEmail); the no-inline-email path publishes without
re-entering the password (existing hash kept, customer reaches it via portal).
2026-06-26 15:28:58 +02:00
Luca c657892bc8 feat(gallery): publish notifies assigned customer accounts via the account email
Publishing a gallery with no inline customer_email but assigned customer
account(s) previously sent nothing (the dialog said "no notification"). Now the
publish route falls back to the existing customer_gallery_assigned "your
galleries" email (sent per assigned active account in their preferred language)
so registered customers learn the gallery is available. Inline-email path
(gallery_created) is unchanged.

The publish dialog now reflects this: with an inline email it notifies that
address; with only assigned accounts it says the account(s) will be notified;
with neither, the button is just "Publish" (no false notify promise). Exports
notifyCustomerOfNewAssignments; EN/DE strings added.
2026-06-26 15:26:28 +02:00
Luca 3ccaed06a2 feat(crm): pre-event reminder falls back to the assigned customer account
When an event has no inline customer_email/host_email but has customer
account(s) assigned (event_customer_assignments), the pre-event reminder now
sends to those registered customers instead of skipping with no_recipient.
Recipients sent to an assigned account are queued WITHOUT eventId so the
language resolver uses the customer's preferred_language (vs the event's
language for inline-email sends). Applies to both the flow path
(sendReminderForEvent) and the legacy pass. The gallery-ready mail deliberately
does NOT fall back to accounts — only the reminder does. Test covers the
no-inline-email + assigned-customer case.
2026-06-25 20:01:40 +02:00
Luca 10559fd68e fix(email): resolve recipient language from the queue row's event_id, not just email_data
Language priority is event.language → customer preferred_language → app default
→ … → en, but it was keyed on email_data.eventId, which only queueEmail injects.
Direct email_queue inserts (e.g. the gallery-publish "notify customer" path) set
the event_id COLUMN but not email_data.eventId, so those mails skipped
event.language and fell through to the default — e.g. a gallery-ready mail in EN
while the same event's pre-event reminder (sent via queueEmail) was DE.

The processor now backfills emailData.eventId from the authoritative event_id
column before rendering, so every send path resolves language from the event
consistently.
2026-06-25 19:50:08 +02:00
Luca 250b240337 fix(crm): pre-event reminder passes raw event_date (fixes "Invalid Date" in the email)
composePayload pre-formatted event_date to DD.MM.YYYY, but emailProcessor runs
date variables through formatDate(value, language) — new Date("25.06.2026")
can't parse → the email rendered "Invalid Date". Pass the raw event_date and let
the processor localise it, matching the expiry mailer's contract. Pre-existing
in the migration-143 composePayload (dormant while the legacy pass was gated
off); surfaced once the pre_event_email flow ran.
2026-06-25 19:22:04 +02:00
Luca 675e41a2f7 feat(workflows): route webhook node through the delivery pipeline (full Option 1)
Replaces the one-shot guarded POST with the maintainer's intended end-state: the
webhook node now references a CONFIGURED webhook subscription (Settings →
Webhooks) and enqueues a real webhook_deliveries row via
webhookService.enqueueForWebhook. Delivery then rides the existing worker
pipeline, inheriting — not reimplementing — per-delivery SSRF re-validation
(validateExternalUrl / GHSA-wmjx-pc37-272r), HMAC signing with the
subscription's secret, retry/backoff, and the deliveries audit log.

- webhookService.enqueueForWebhook(webhookId, eventType, data): enqueue for one
  active subscription, bypassing fire()'s event-type matching. No schema change.
- webhook action: config.webhookId; unset/missing/inactive → observable skip;
  dry-run does not enqueue. event_type = workflow.<trigger>.
- Editor: webhook node config is now a subscription dropdown (was a raw URL),
  fed by the admin webhooks list, with a hint pointing to Settings → Webhooks.
- EN/DE strings; test asserts enqueue + dry-run no-op + inactive skip.
2026-06-25 18:44:27 +02:00
Luca af7eea8b43 fix(workflows): wire a real, SSRF-guarded webhook action (was a silent no-op)
Second-review loose end: the `webhook` node type passed validation but had no
registered handler → engine dispatched to registry.getAction('webhook') →
undefined → every run silently skipped. An enabled webhook flow no-op'd.

Register a real `webhook` action (covers both the webhook node type and the
"Call a webhook" action). It POSTs the run context to config.url, guarded by
validateExternalUrl — the same NAT64/private-range SSRF protection the webhook
delivery worker uses (GHSA-wmjx-pc37-272r) — with no redirects and a timeout,
unless WEBHOOK_ALLOW_PRIVATE_URLS=true (local-dev opt-out). Missing URL /
rejected URL / network error record an observable skipped step, not a crash.
So the action is now implemented → it passes the enable guard legitimately.

Test covers dry-run, missing-url, and metadata-IP (169.254.169.254) rejection.
2026-06-25 18:00:12 +02:00
Luca 415c93a512 fix(event-types): renaming a type's slug cascades to events, quotes + reminder template
Renaming an event type's slug_prefix is editable in the UI but previously
orphaned everything keyed on the old slug: existing events/quotes (their
event_type) detached, and the authored per-type pre-event reminder template
(event_reminder_<slug>) was left behind → reminders fell back to default.

updateEventType now cascades atomically when the slug changes: re-points
events.event_type + quotes.event_type old→new and renames the
event_reminder_<old> template to <new> (guarded so it never clobbers an existing
target). So a photographer can rename a type to e.g. "concert" and the edited
subject/body follow. Column check resolved before the transaction (avoids the
SQLite global-read-in-trx deadlock). Tests cover the cascade + no-clobber.
2026-06-24 13:52:42 +02:00
Luca 10d091b55e feat(workflows): pre-event reminder picks the template GROUP on the block, type stays automatic
The reminder template family (prefix) is now chosen on the notify_pre_event
block via config.templateGroup (default 'event_reminder'); within that group the
exact template is still auto-resolved per event type:
  <group>_<eventType> if authored  →  else  <group>_default
So an admin can point a flow at a different reminder family, while wedding/
birthday/… routing and the catch-all fallback stay automatic. resolveTemplateKey
now takes (eventType, group) and tolerates a trailing "_" on the group.

Editor: notify_pre_event (+ the gallery notify actions) added to the action
dropdown, with a "Reminder template group" field and hint. Seed sets
templateGroup='event_reminder' on the built-in (v4). EN/DE strings. Tests cover
the per-type / group-default resolution.
2026-06-24 11:26:02 +02:00
Luca 5fbe514db6 fix(crm): pre-event reminder resolves recipient from the event row, not a non-existent column
The reminder query joined customer_accounts on events.customer_account_id — a
column the events table doesn't have (events store the recipient inline as
customer_email/host_email, like the gallery emails). So the query threw, the run
failed, and no pre-event email went out for an event that has an email but no
CRM customer account. Latent in the legacy pass (gated off by default); surfaced
the moment the pre_event_email flow ran notify_pre_event.

Both runEventReminderPass and sendReminderForEvent now read the recipient from
the event's own columns (customer_email || host_email, name from
customer_name || host_name) via SELECT events.* — no join, safe on installs
predating the customer_email column. Regression test covers an event with a
direct email and no customer account.
2026-06-24 01:30:35 +02:00
Luca dee8d40bb3 fix(workflows): matchFilter strict equality + accurate comment
Concern #2: switch eq/neq to ===/!== (drop the eslint-disable); a filter
{value:0} no longer matches false/''/null. Comment corrected — no implicit
type normalisation; filter authors match the payload type.
2026-06-23 23:36:56 +02:00
Luca 440b2b379f test(workflows): cover review fixes + update for disabled-by-default posture
- gate decision with no matching edge → run fails (not silent done)
- enabled-based mutex: legacy reminder pass stands down only when the flow is on
- built-ins now seeded disabled (v6/v2/v3); re-seed flips never-touched defaults
  but preserves an admin_toggled_at-owned flow
- route: rejects unknown node type; refuses enabling a flow with unimplemented actions
2026-06-23 23:36:17 +02:00
Luca c5f131cec3 feat(workflows): warn when disabling a built-in (reverts to legacy, not off)
Confirm dialog on the list page when toggling a built-in OFF, clarifying it
reverts to the previous built-in/legacy behaviour rather than turning the
automation off (review concern #4). The enable-refusal for unimplemented flows
surfaces via the existing toggle error toast (backend 409). EN + DE strings.
2026-06-23 23:36:17 +02:00
Luca d927464778 fix(workflows): harden graph validation + refuse enabling unimplemented flows
Review concerns #1/#2/#3/#5:
- validateGraph whitelists node types (rejects a typo'd 'actoin' that would
  no-op every cycle).
- Caps graph size: max 200 nodes / 500 edges / 16KB per-node config — a
  workflows.manage user can't DoS the DB with a giant graph.
- Refuses to enable (create/update/PATCH) a flow whose graph references
  unimplemented stub actions (the booking prepare_*/send_document), with a
  clear 409, so an admin can't enable a flow that silently drops the work.
- Stamps admin_toggled_at on admin enable/disable/edit (sentinel for the seeder).

matchFilter strict-equality fix lives in the engine commit.
2026-06-23 23:36:05 +02:00
Luca 5893ecb27a fix(workflows): ship built-ins disabled for first beta + enabled-based mutex + admin sentinel
Per review: the four cutover built-ins (dunning, gallery_expiring,
gallery_expired, pre_event_email) now ship enabled:false. The mutual-exclusion
guards revert to ENABLED-based (isBuiltinFlowActive, not existence) so the
legacy paths keep running until the admin enables a built-in — enabling cuts
over, disabling reverts to legacy (fixes concern #4's "disable = silent dark"
foot-gun; no automation goes dark on upgrade).

admin_toggled_at sentinel (migration 148) marks admin ownership; the boot
re-seeder applies a shipped default-flip (enabled→disabled) only to
never-touched built-ins and never overwrites an admin's enable/disable/edit
(nit #1). SEED_VERSIONs bumped so the disabled default propagates.

Nit: applyReminder unlinks the just-rendered Mahnung PDF if queueEmail throws
(no orphan file).
2026-06-23 23:35:56 +02:00
Luca 98ab717043 fix(workflows): close review blockers — prefetch-safe approvals + loud gate-edge failure
Blocker #1: GET /workflow-approvals/:token/:action no longer mutates. Email
clients + security scanners (Outlook Safe Links, Gmail, Proofpoint, AV
link-checkers) GET links before the human clicks, which previously advanced a
payment-confirm gate silently. GET now renders a confirm/deny interstitial via
a new read-only peekApproval(); only POST calls actByToken.

Blocker #2: a gate decision with no matching edge now failRun()s instead of
finishRun(). resumeRun matches the decision handle EXACTLY (no fall-back to
outEdge's sole-edge heuristic), so a 'deny' with only a 'confirm' edge fails
loudly in run history instead of taking the confirm path / a green 'done'.
2026-06-23 23:35:45 +02:00
Luca d14f1d850c feat(workflows): per-quote booking-workflow picker + quote→invoice (no gallery) built-in
A quote can now choose which flow runs on acceptance instead of every enabled
quote.accepted flow firing. Migration 147 adds quotes.booking_workflow_id; the
editor shows a "Booking workflow (on acceptance)" dropdown listing the
quote.accepted flows (workflow-engine flag only); emitQuoteEvent passes it as
the new emitWorkflowEvent targetWorkflowId so ONLY the picked flow runs (still
gated on enabled + trigger match → a disabled/None selection runs nothing).

Adds the booking_invoice_only built-in (quote.accepted → prepare invoice →
review gate → send; no event/gallery, no wait), the variant requested for
shoots billed without an online gallery. Disabled stub like the other booking
flows until the prepare_*/send_document cutover.

Tests: targetWorkflowId runs only the selected flow; invoice-only built-in has
no wait/prepare_event.
2026-06-23 23:15:31 +02:00
Luca eec262b0a7 copy(crm): late-fee toggle reads "every reminder after the first"
The fee accumulates on every fee-bearing reminder (2nd onward), not just the
2nd — relabel the toggle (EN + DE + code fallback) to match the behaviour.
2026-06-23 18:22:18 +02:00
Luca 23098127a8 fix(crm): quote→event fallback resolves an ACTIVE event type, never hardcoded 'wedding'
The last-resort fallback hardcoded 'wedding', which breaks when the admin has
disabled that type. resolveDefaultEventType now prefers the generic 'other'
catch-all when active, else the first active type by display order, and only
uses a literal as a final guard if the catalog is empty/unreadable. The chosen
quote type and the crm_default_event_type setting still take precedence.
2026-06-23 18:02:11 +02:00
Luca f78671fc6c feat(crm): event-type dropdown on quotes; quote→event uses it (no more hardcoded 'wedding')
Quotes now carry an event type (migration 146: quotes.event_type, the
event_types.slug_prefix), chosen from the active event-types catalog in the
quote editor's Event section. convertToEvent reads it instead of the
unconditional hardcoded 'wedding': quote.event_type → crm_default_event_type
setting → 'wedding' as last-resort seeded fallback. When the booking flow's
prepare_event is wired, it reads the same field.

Backend: createQuote/updateQuote persist event_type (hasColumn-guarded);
adminQuotes route accepts + returns eventType. Frontend: FormState + payload +
load + a catalog-sourced dropdown ("— Use default —"); EN/DE strings.
2026-06-23 17:52:50 +02:00
Luca 182e655fcf feat(workflows): invoice prepared+approved early, dispatch waits; daysBefore in editor; dashboard approvals
- Booking built-ins reordered: prepare the invoice EARLY (admin adjusts line
  items), admin approves at the review gate whenever, then the wait holds
  dispatch until the event date and it sends itself. prepInvoice → reviewInvoice
  → waitEvent → sendInvoice (both booking_full and booking_simple; v3).
- Flow editor now reads/edits/saves trigger_config; the pre-event "days before
  event" lead time is editable in the canvas toolbar (was only in settings,
  which the cutover removed — closing that gap).
- Dashboard: pending-approvals card under "Events Expiring Soon" (workflows flag
  + non-empty only), with inline Confirm/Deny.

Confirms the design: a gate's confirm edge can feed a wait, so an admin OK
before the event parks the run at the wait and the scheduler dispatches on the
date. New test covers confirm-early-then-wait-dispatches.
2026-06-23 16:11:18 +02:00
Luca 0b6c33e59a feat(workflows): hard cutover of gallery-expiry + dunning + pre-event to flows
Seed gallery_expiring / gallery_expired built-ins and make the live automations
flow-owned, with zero feature loss:

- New delegating actions (notify_gallery_expiring / notify_gallery_expired /
  notify_pre_event) call the EXISTING send functions, so the engine path is
  byte-identical to the legacy hourly checker/pass (same templates, recipients,
  variables, dedup, per-event overrides, sent_at idempotency).
- Cutover built-ins (invoice_dunning, gallery_expiring, gallery_expired,
  pre_event_email) now ship ENABLED; booking flows stay disabled (stubs).
- The legacy paths stand down via existence-based isBuiltinFlowPresent guards:
  once a built-in is seeded (flag on) the engine is the single switch — flow
  enabled = it sends, flow disabled = off — so no double-send and reminders/
  expiry emails can still be fully turned off.
- emitDueEventReminders now honours the per-event reminder controls
  (disabled / offset / sent_at) so pre-event timing is faithful; fixed a
  Number(null)===0 offset bug.

Settings UI cutover is gated on the `workflows` flag (default off): when the
engine is live, the dunning reminder schedule (CRM settings) and the pre-event
global toggle (Reminder emails) are replaced with a "now in Workflows" callout;
when it's off, the legacy controls stay so flag-off installs lose nothing. The
late-fee math and installment-trigger defaults stay (fee math / scheduler-owned).

Split/installment invoices intentionally remain scheduler-driven (no flow).
2026-06-23 15:58:19 +02:00
Luca fa7b1bae95 feat(workflows): admin review gates before sends + migrate lifecycle/time triggers
Booking built-ins now gate every outbound document on an explicit admin OK:
prepare_* drafts the doc, the admin adjusts line items/terms, confirms the
"Review … before sending" gate, and only then does send_document fire. Added to
booking_full (contract + invoice) and booking_simple (invoice); seed versions
bumped so the disabled built-ins self-heal.

Migrated the remaining time- and event-driven triggers into the engine, all
additive / best-effort / fail-closed (no behaviour change when the flag is off):
- gallery.published (event creation)
- gallery.expiring + gallery.expired (expiration checker, alongside the email)
- quote.sent (was queued but never emitted — gap closed)
- contract.sent + contract.signed (sent, fully-signed via counter-sign or wet upload)
- customer.created (direct add + invitation accept)
- invoice.overdue (status→overdue flip, deduped per invoice)

Editor trigger list extended to match. Tests assert the review gates wire
confirm→send on both booking flows.
2026-06-23 14:58:48 +02:00
Luca b38c216f22 test(workflows): raise beforeAll timeout for migration-heavy CRM suites
The workflow/dunning suites boot the full core-migration set in beforeAll via
bootCrmDb. In isolation that's ~1.3s, but under full-suite parallel load on a
small CI runner it can exceed Jest's 5s default, timing out beforeAll and
failing every test in the file (the CI flake). Match the existing pattern used
by the other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill)
and set jest.setTimeout(30000) on workflowEngine, workflowRoutes and
invoiceDunning.
2026-06-23 14:40:07 +02:00
Luca 62ba905464 feat(workflows): seed booking + pre-event built-ins, wire event.date_approaching
Three more editable built-in flows, seeded disabled like the dunning ladder:
- booking_full: quote.accepted → prepare/send contract → admin "signed?" gate
  → create event → wait to event date → prepare/send invoice
- booking_simple: the no-contract path (quote.accepted → event → invoice)
- pre_event_email: customer reminder + admin heads-up, fired daysBefore the
  event date

The booking document actions stay stubs (observable skipped steps) until the
booking cutover. pre_event_email uses the already-wired send_email action, so
it is functional once enabled — backed by a new scheduler emitter
(emitDueEventReminders) that fires event.date_approaching for events entering a
flow's lead window, deduped per event. Refactors the boot seeder to a built-in
registry so each flow self-heals on its own SEED_VERSION.
2026-06-23 14:11:11 +02:00
Luca e70ddd36b8 feat(workflows): test-fire — safe dry-run of any flow on demand
Engine testRun() walks the whole graph immediately: waits pass through,
gates auto-confirm, side-effecting actions short-circuit to {dryRun, would}
so no real emails go out. POST /admin/workflows/:id/test-run returns the
run status + per-node step log. Admin list gets a flask button that opens
a result modal with an optional entity id (e.g. invoice) for conditions.
2026-06-23 13:57:02 +02:00
Luca 192d2cbc06 feat(workflows): crash recovery — resume runs orphaned mid-flow
Closes the crash-safety gap: a run left in running/pending by a crash had
nothing to resume it (the scheduler only wakes 'waiting'). Adds a heartbeat
(workflow_runs.updated_at, stamped on every node advance + start/resume) and a
recoverStaleRuns() sweep that re-enters runs whose heartbeat has gone stale
(>10 min) from their persisted node. Runs on the scheduler tick AND the boot
tick, so a restart catches anything stranded during downtime.

Re-entry is at-least-once (the current node may re-execute) — loop counters +
the late-fee math are idempotent, so the only residual risk is a duplicate
reminder email. An attempts counter (migration 145, cap 5) marks a run failed
instead of recovering a node that reliably crashes the process (crash-loop
backstop). Flag-gated. Tests: orphan-resume + crash-loop cap.
2026-06-23 13:39:17 +02:00
Luca 83dc95a62b test(crm): dunning fee math, VAT-toggle gating + invoice immutability
Covers the tax-sensitive bits the dunning rework added (previously untested):
flat vs percent fee, the VAT toggle applying the org rate AND no-op'ing when
the org has no VAT rate, per-reminder accumulation (2nd=1x / 3rd=2x), the
invoice total staying immutable while the fee is tracked, and the 3-reminder
cap. Exports the fee resolvers + applyReminder for testing; PDF render stubbed
(flaky in CI, verified manually). 6/6 pass.
2026-06-23 13:30:48 +02:00
Luca 5ed2fec2fe feat(crm): Mahngebühr on a separate Mahnung document; invoice stays immutable
Corrected dunning model (Mara): a Mahnung is a reminder LETTER showing the new
total (original + Mahngebühr), NOT a separate invoice and NOT a mutation of the
issued invoice.

- The invoice PDF no longer shows the fee (buildInvoiceRenderContext reports
  lateFeeAmountMinor 0) and is NEVER re-rendered by a reminder — it stays
  immutable (§14/§11).
- applyReminder now: tracks the fee as dunning state on the row (gross
  late_fee_amount_minor + new late_fee_vat_minor for the VAT portion, migration
  144), renders a separate MAHNUNG PDF (pdfService 'mahnung' kind — reuses the
  invoice layout: same lines + Mahngebühr row + new total, 'Mahnung' title, no
  QR), stored under storage/business-docs/mahnung/, and attaches BOTH the
  unchanged original invoice + the Mahnung to the reminder email.
- Fee resolvers split into net + VAT-rate (toggle + org-rate gated); a gross
  wrapper feeds the payment-check preview. en + de PDF title.

Outstanding/collections still read late_fee_amount_minor (now dunning state).
P3 (tax-report/Banana booking of the Mahngebühr VAT) stays Treuhänder-gated.
Syntax + 17/17 workflow/invoice tests green.

NOTE: the Mahnung PDF render path isn't unit-tested (PDF rendering is flaky in
the test env) — eyeball on the dev box: fire a level-2 reminder, confirm the
Mahnung PDF shows the new total and the original invoice PDF is unchanged.
2026-06-23 13:08:19 +02:00
Luca eaceb7e71c feat(crm): toggle for VAT on late fees (jurisdiction-dependent)
Mahngebühr VAT differs by country (CH: liable; DE/AT: not), so it's now a
toggle (crm_invoices_late_fee_vat_enabled, seeded into migration 143 in place
since it isn't deployed yet — no compensation migration). When on, VAT is
added on top of the net fee at the org's default rate
(business_profile.vat_rate_default). Gated so it's a NO-OP when the org doesn't
charge VAT (default rate 0/unset) — i.e. enabling the toggle on a non-VAT org
adds nothing, as required. Settings UI: a self-documenting checkbox.

The fee is treated as net + VAT-on-top; the tax-report VAT breakdown for the
fee is part of the deferred dunning-document rework. tsc 0, build green,
9/9 workflow tests.
2026-06-23 12:41:44 +02:00
Luca b78bd979dc feat(workflows): collections-handoff block after dunning exhausts
New escalate_to_collections action: when the 3-reminder loop ends still
unpaid, consolidate ONE email to the admin — customer data, outstanding
(invoice + late fees − paid), and the invoice PDF attached — ready to forward
to an Inkasso agency / for Betreibung. Internal mail, sent immediately; does
NOT touch the invoice. New invoice_collections_handoff email template (en +
native de, seeded by the boot self-heal). Wired into the built-in dunning
flow: loop 'exit' → collections → end (seed v4, re-seeds the disabled
built-in). Selectable + labelled in the canvas editor. Tests 9/9, tsc 0,
build green.
2026-06-23 12:37:50 +02:00
Luca dcdbeb9cc5 feat(crm): 3-reminder dunning + flat/percent Mahngebühr on 2nd & 3rd + AGB notice
- Late fee can now be a FLAT amount OR a PERCENTAGE of the invoice gross
  (crm_invoices_late_fee_type/_percent, migration 143; defaults preserve the
  current flat behaviour).
- Fee is charged from the 2nd reminder onward and accumulates per fee-bearing
  reminder (2nd = 1×, 3rd = 2×), computed from the level so re-applying a level
  never stacks. New resolvePerReminderFeeMinor() shared by applyReminder + the
  payment-check fee preview.
- Reminder ladder extended to 3 levels (caps raised in sendReminder +
  recordPaymentCheckAction); the built-in dunning flow now loops 3× (seed v3,
  re-seeds the disabled built-in on boot).
- Settings UI: flat/percent toggle + percent field, and a prominent AGB
  callout — a late fee is only enforceable if the concrete amount is stated in
  the terms (Mara's wording), 'verify with your Treuhänder'. en + native de.

The fee math is examples-only / Treuhänder-verify; issued invoices stay
immutable (the fee is tracked in late_fee_amount_minor, not folded into the
original total). Tests 17/17, tsc 0, build green.
2026-06-23 12:26:32 +02:00
Luca 289568fd52 feat(workflows): advanced text mode — export/import the flow as JSON
A 'Text' toggle in the editor toolbar swaps the canvas for the whole flow as
pretty JSON ({name, trigger_type, enabled, nodes, edges}). Copy it to share or
hand to an LLM, or paste a flow and 'Load into editor' (validates parse + one
trigger; backend re-validates on Save). Imported nodes land at 0,0 — one
'Clean up layout' click arranges them. en + native de.
2026-06-23 12:09:18 +02:00
Luca 79607c597a feat(workflows): 'Clean up layout' auto-arrange button (dagre)
Adds a one-click tidy that re-lays the graph top-to-bottom with dagre
(@dagrejs/dagre) and fits the view — handles the loop-back cycle by breaking
it internally. en + de string.
2026-06-23 12:03:36 +02:00
Luca 1734aba39c fix(workflows): dark-mode canvas + readable nodes + structured config
Addresses editor UX feedback:
- Dark mode: pass React Flow's colorMode (admin isDark) so the zoom/lock
  controls, minimap and selection render dark instead of white-on-black.
- Readable nodes: show a human label derived from type+config (e.g. 'Invoice
  paid?', 'Send payment-check email', 'Repeat ≤ 2×', 'Wait until due date')
  instead of the raw node_key, and label each output handle on the node
  (yes/no, confirm/deny, loop/exit) so branching is self-explanatory.
- Structured config: replace the raw-JSON textarea with a per-node form
  (NodeConfigPanel) — dropdowns for action/condition/recipient/operator,
  typed wait/loop/gate fields, live-applied; an 'Advanced (JSON)' expander
  remains for anything the form doesn't cover.
- en + native de strings for all of it.

tsc 0 errors, build green.
2026-06-23 11:49:32 +02:00
Luca cede885b04 fix(workflows): Postgres-safe id capture on workflow inserts
On Postgres, knex .insert() without .returning() resolves to [], so ins[0]
was undefined → the child workflow_nodes inserts hit a NOT NULL violation and
the whole transaction rolled back. Result on PG: migration + tables present
but zero rows — the seeded dunning flow never persisted, and the 'New
workflow' button would 500. SQLite returns the row id, so the test harness
masked it.

Add .returning('id') and normalise the {id} (pg) vs bare-id (sqlite) shapes
(same pattern as the crmDb harness) in both the built-in seed and the admin
create route. Tests stay green on SQLite (17).
2026-06-23 11:33:14 +02:00
Luca 5259ee9705 feat(workflows): migrate the dunning ladder onto the engine (cutover)
Makes the built-in dunning flow a faithful replacement for the hardcoded
reminder ladder instead of a disabled representation:

- queue_payment_check action delegates to invoiceService.queuePaymentCheckEmail,
  so the proven confirm + reminder_level + Mahngebühr state machine
  (recordPaymentCheckAction) stays the single source of truth — the workflow
  only decides WHEN the payment-check email (the gate) fires.
- runScheduledTasks now SKIPS the hardcoded reminder batches when workflows is
  on AND the invoice_dunning built-in is enabled, so the two never double-send.
- The built-in graph is re-authored to the delegation model (wait→due, grace,
  loop: check-paid → payment-check → wait-gap), dropping the redundant gate +
  generic reminder emails. A SEED_VERSION re-seeds the disabled, never-activated
  built-in on boot but never touches an enabled/edited one.

Tests: delegation graph shape, re-seed-when-stale, enabled-protection (9 engine
+ 8 route = 17 passing).
2026-06-23 11:12:19 +02:00
Luca 88881d0428 i18n(workflows): native DE + EN strings for the workflow pages
Adds the workflows.* block (list, approvals inbox, canvas editor) to en.json
and de.json so the Workflows UI no longer renders English inline fallbacks
under a German UI. DE authored natively.
2026-06-23 11:12:19 +02:00
Luca 5c0396d0c1 feat(workflows): React Flow canvas editor + list + approvals UI
Adds the admin Workflows surface (top-level nav, gated by the workflows
flag + workflows.view): a list page (enable toggle, delete, new), a
pending-approvals inbox (confirm/deny), and a React Flow (@xyflow/react)
canvas editor — palette to add nodes, drag handle→handle to connect
(branch/gate/loop expose yes-no / confirm-deny / loop-exit handles), a
side-panel JSON config editor, and save (writes a new version). Routes +
sidebar entry + workflows.service. Build + tsc clean.

NOTE: the workflow page strings render via inline English fallbacks; DE
translations for the workflows.* block are still pending native review.
2026-06-23 02:51:40 +02:00
Luca 9b557efbf3 feat(workflows): seed invoice-dunning ladder as an editable built-in flow
Boot self-heal seeds the corrected gate-in-loop dunning graph (wait→due,
grace wait, invoice_paid check, confirm-no-payment gate, bounded reminder
loop with re-check, final notice) keyed on builtin_key='invoice_dunning',
sized from the reminder_first/second_days settings. Seeded DISABLED and
is_builtin: live reminder behaviour is UNCHANGED (the hardcoded scheduler
ladder still runs) — enabling it pre-cutover would double-send, so the
engine cutover is a deliberate follow-up. Idempotent (preserves admin edits).
Built-ins refuse delete (enforced in the CRUD route). Test covers seed shape
+ idempotency.
2026-06-23 02:44:05 +02:00
Luca 1a0d6de04d feat(workflows): admin CRUD + run-history + approvals-inbox API
GET/POST/PUT/PATCH/DELETE /api/admin/workflows with graph read/write (PUT
writes a fresh node/edge set under version+1 and bumps workflows.version so
in-flight runs keep their pinned version). Run-history (/:id/runs,
/runs/:runId/steps) and the pending-approval inbox (GET /approvals,
POST /approvals/:id/:action → actById) round it out. Gated by the workflows
flag + RBAC (view for reads, manage for writes); built-in flows refuse
delete; graph validated (exactly one trigger, unique keys, edges reference
known nodes). Route tests cover CRUD, validation, version bump, toggle,
inbox, and the 403 permission gate.
2026-06-23 02:40:29 +02:00
Luca b48d8c2eb8 feat(workflows): approval gates — email confirm/deny + token resume
gate_setup action creates a workflow_approvals row (single-use token stored
as SHA-256 hash) and emails the admin confirm/deny links immediately
(internal mail, no business-hours floor). actByToken / actById finalize the
approval and resume the run down the matching confirm/deny edge; both are
idempotent (a second click → 'already recorded') and respect expiry. Public
GET /api/public/workflow-approvals/:token/:action returns a small HTML
confirmation page (clickable from email, single-use so prefetch can't
double-act). listPending backs the webview inbox (wired in the CRUD phase).
Test covers gate→approval→email→token-confirm→resume + idempotency.
2026-06-23 02:37:20 +02:00
Luca cc0ba5347d feat(workflows): emit lifecycle events from invoice + quote services
Wires the workflow event bus into the hot paths, AFTER each commit:
- invoiceService.sendInvoice → invoice.sent (idempotent per invoice id, so
  overdue re-sends don't double-fire)
- invoiceService.markPaid → invoice.paid, only on the transition into paid
  (transaction result captured so the emit runs post-commit, never rolling
  back a recorded payment)
- quoteService.recordResponse / adminAcceptQuote / adminDeclineQuote →
  quote.accepted / quote.declined via a shared emitQuoteEvent helper that
  resolves the customer email for downstream send_email actions

All emits are best-effort and fail closed when the workflows flag is off.
Existing invoice/quote integration tests still green.
2026-06-23 02:33:14 +02:00
Luca 96fb44045e feat(workflows): data-touching action + condition handlers
Adds send_email (INTERNAL/admin = immediate, EXTERNAL/customer = business-
hours floor via queueEmail's respectBusinessHours) and the invoice_paid
condition (paid_at / status / cumulative paid_amount). Registers the
prepare_quote/contract/event/gallery/invoice + send_document + reserve_date
document actions as recognized-but-not-yet-wired (record an observable
skipped step rather than crashing a flow). index.js side-effect-imports the
handlers. Tests cover the customer-mail routing + the invoice_paid logic.
2026-06-23 02:28:02 +02:00
Luca 610a3dfd73 feat(workflows): scheduler resumes elapsed wait nodes
Adds engine.runDueWaits() — polls waiting runs whose wake_at has passed and
resumes the ones parked on a wait node (gate timeouts handled later by the
approvals layer). Flag-gated (fails closed when workflows is off). Wired into
the existing hourly invoiceScheduler tick in its own try/catch so a workflow
failure never suppresses the invoice/reminder jobs. Test covers not-due vs
elapsed resume.
2026-06-23 02:09:57 +02:00
Luca 1eaef67c36 feat(workflows): execution engine core + registry + tests
Graph executor that walks nodes/edges per run: trigger, condition/branch
(registered conditions → yes/no edge), bounded loop (counter in context +
maxIterations cap), wait (status=waiting + wake_at for the scheduler), gate
(status=waiting; resumed via confirm/deny edge), action/webhook (registered
handlers). emitWorkflowEvent creates one idempotent run per matching enabled
workflow (unique dedup_key) and fails CLOSED if the flag system is
unavailable; never throws into callers (safe to call after commit). Every
node records a workflow_run_steps row. Registry seeds primitive
conditions (always/never/expr) + actions (noop/log/set_context). Integration
test covers loop+wait resume, gate confirm, and dedup.
2026-06-23 02:05:34 +02:00
Luca c818c25cf2 feat(workflows): schema + permissions (migration 142)
Adds the workflow engine's graph data model — workflows, workflow_nodes,
workflow_edges (versioned so in-flight runs keep their version),
workflow_runs (status/current_node/context, wake_at for the scheduler,
unique dedup_key for idempotency), workflow_run_steps (per-node audit),
and workflow_approvals (hashed email confirm/deny token + webview inbox).
Seeds workflows.view / workflows.manage and grants them to super_admin +
admin. Loose-FK integers per the whatsapp_queue/expenses convention;
idempotent hasTable guards + reversible down().
2026-06-23 01:57:06 +02:00