Commit Graph

1624 Commits

Author SHA1 Message Date
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
Paul Nothaft 06f4c109bc Merge pull request #680 from Luca-Timo/fix/invoice-pdf-multipage
Fix/invoice pdf multipage
2026-06-28 22:17:07 +02:00
Paul Nothaft 23d1f5d609 Merge pull request #681 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.72.0-beta.0
v3.72.0-beta.0
2026-06-28 21:57:58 +02:00
github-actions[bot] c4ed88b085 chore(beta): release 3.72.0-beta.0 2026-06-28 19:53:32 +00:00
Paul Nothaft ec33ec7670 Merge pull request #679 from Luca-Timo/feat/booking-cutover
feat(workflows): booking cutover — wire booking actions + hold documents behind approval gates
2026-06-28 21:53:09 +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
Paul Nothaft e6f29b6345 Merge pull request #678 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.71.3-beta.0
v3.71.3-beta.0
2026-06-27 21:56:11 +02:00
github-actions[bot] 74d53b22b1 chore(beta): release 3.71.3-beta.0 2026-06-27 19:50:21 +00:00
Paul Nothaft f01754247c Merge pull request #676 from the-luap/fix/whatsapp-public-events-route-647
fix(events): wire customer notifications into both public API entry points (#647)
2026-06-27 21:50:00 +02:00
Paul Nothaft 54c52d3cf7 Merge pull request #677 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.71.2-beta.0
v3.71.2-beta.0
2026-06-27 21:49:30 +02:00
github-actions[bot] 5c04137a6b chore(beta): release 3.71.2-beta.0 2026-06-27 19:45:27 +00:00
Paul Nothaft c8714ca42f Merge pull request #674 from Luca-Timo/feat/workflow-engine
fix: event-reminder, email-language & gallery-publish bugs surfaced during workflow testing
2026-06-27 21:45:07 +02:00
Paul Nothaft 511d647eec fix(events): wire customer notifications into both public API entry points (#647)
Two entry points for event creation were missing customer notifications, both
discovered while triaging @Rekoo-PS's report that "API created events" don't
send WhatsApp after #649/#650 landed.

POST /api/v1/events (the OpenAPI-spec'd bearer-token API at v1/events.js):
- gallery_created email was NEVER queued — only the webhook fired.
- WhatsApp was NEVER queued either.

POST /api/events (legacy admin-auth route at routes/events.js):
- gallery_created email was queued, but WhatsApp was not.
- customer_phone wasn't read from the body at all.

Both routes now mirror the adminEvents.js create-and-publish path: best-effort
queues that never block the API response, gated on customer_email / customer_phone
presence and the global event_phone_field_enabled toggle for the phone field.

The webhook subject from POST /api/events now also includes customer_phone, so
downstream integrations get the same shape as the v1 API.

No schema change. No migration. customer_phone column already exists on events
(migration 080). WhatsApp config + template_language + template_params resolve
through the existing queue processor.
2026-06-27 21:28:56 +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
Paul Nothaft 4212062c12 Merge pull request #673 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.71.1-beta.0
v3.71.1-beta.0
2026-06-26 17:03:18 +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
github-actions[bot] 218b0dfd94 chore(beta): release 3.71.1-beta.0 2026-06-26 11:05:06 +00:00
Paul Nothaft 748af98f3d Merge pull request #671 from the-luap/fix/publish-dialog-cta-overflow-670
fix(admin): stack publish-gallery dialog CTAs so the German label fits (#670)
2026-06-26 13:04:45 +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
Paul Nothaft dddf932f13 Merge pull request #672 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.71.0-beta.0
v3.71.0-beta.0
2026-06-25 19:26:31 +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
github-actions[bot] ef5247d013 chore(beta): release 3.71.0-beta.0 2026-06-25 17:17:34 +00:00
Paul Nothaft 15be3b8d32 Merge pull request #667 from Luca-Timo/feat/workflow-engine
feat: admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome)
2026-06-25 19:17:03 +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
Paul Nothaft ea2852dcb0 fix(admin): stack publish-gallery dialog CTAs so the German label fits (#670)
Reporter @the-luap hit the German `Veröffentlichen & Kunden benachrichtigen`
button overflowing the modal footer in the publish dialog. Two failure modes
chained:

1. The footer was a `flex` row with two `flex-1` buttons inside a
   `max-w-md` (448 px) modal. Default `min-width: auto` on flex children
   meant the primary button kept its content width (~340 px including the
   paper-airplane icon + padding) and pushed the row past the modal frame.

2. Adding `min-w-0 whitespace-normal` doesn't help — the base `.btn` class
   has `@apply ... whitespace-nowrap` (`index.css:149`) which wins over a
   utility className via the Tailwind CSS cascade order. So the text won't
   wrap, the button silently extends past the modal frame, no overflow
   indicator. Verified with `getComputedStyle().whiteSpace = 'normal'` and
   the button still rendering as one ~340 px wide line at ~224 px allocated
   space.

Fix: stack both buttons vertically (`flex flex-col-reverse gap-3`). Primary
appears on top visually (col-reverse), cancel below — standard
confirmation-dialog pattern (Material, Headless UI, Radix all do this for
single-action dialogs). Works in every locale and viewport regardless of
label length. No side-by-side row to overflow.

Tried two prior shapes that didn't hold:
- `flex-col-reverse sm:flex-row` with `sm:flex-1 min-w-0 whitespace-normal`
  on the primary: still overflowed silently because of the
  whitespace-nowrap cascade above.
- `flex-col-reverse sm:flex-row sm:justify-end` with content-width buttons:
  `justify-end` doesn't constrain a row whose content sum exceeds the
  container; row just pushes left of the modal.

Bumping the modal to `max-w-lg` (or wider) was also considered and rejected:
matching modal width is asymmetric (every other admin dialog stays at
`max-w-md`), and any locale longer than German would re-hit the wall.
Stack-always is the only shape that handles every locale + every viewport
without per-language tuning.

Verified end-to-end against a dockerised dev backend:
- DE + EN × desktop (1280px) + mobile (375px) — all four show primary on
  top, cancel below, both inside the modal frame, no overflow.

Lint + tsc + full vitest suite (84/84) clean.

Closes #670.
2026-06-25 18:06:08 +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