Commit Graph
658 Commits
Author SHA1 Message Date
Luca 333379b321 Merge branch 'beta' of https://github.com/the-luap/picpeak into feat/crm-improvements 2026-06-02 14:17:46 +02:00
Luca a5c88f2d9f i18n(crm): fill missing DE/EN keys on CRM admin surfaces
The t() calls on the Features tab, CRM settings tab, contract editor,
and settings nav carried English fallbacks but the keys were absent from
both locale files, so DE rendered English. Add ~67 keys to en.json +
de.json (DE native): the full contracts.editor.* subtree (whole page was
English), settings.features.{contracts,crmDevelopment,hoursLogging}.*,
crmSettings contracts/dashboard-overview/ToS labels, and two settings
nav titles. Also drop a dead duplicate bills.field.sourceQuote key in de.
No code changes — additive translations only.
2026-06-02 14:11:44 +02:00
Luca 603ba1e504 feat(email): read-only "Sent emails" tab over email_queue
Add a paginated, filterable view of the email_queue (recipient, type,
status, queued/sent timestamps, error, event link) as a third tab in
Email config, beside SMTP + Templates. Filters: status, recipient/type
search, created-at range. email_data is never exposed. Pairs with the
"Send queued emails now" flush — flush, then watch what sent/failed.
2026-06-02 13:58:54 +02:00
Luca 8d14441df4 feat(quotes): admin decline-on-behalf with optional reason
Add a "Decline on behalf" action mirroring accept-on-behalf, for when a
customer says no by phone/email. Flips a draft/sent/expired quote to
declined, stamps declined_at, closes the public response window, and
invalidates outstanding accept/decline tokens so the emailed link can't
toggle it back. Optional free-text reason persisted to a new
quotes.decline_reason column (migration 115) and shown on the quote
detail page. Hard-delete intentionally not included.
2026-06-02 13:47:31 +02:00
Luca 621ce942b5 feat(email): per-weekday business hours + manual queue flush
Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
2026-06-02 13:00:13 +02:00
Luca 93956db0ca feat(hours): aggregate open-hours landing view on /admin/clients/hours
When no customer is selected, list every customer with unbilled hour
entries — entry count, total hours, and open amount (resolved via the
override → customer-rate → install-default chain). Rows with no
resolvable rate are flagged "Rate not set" rather than undercounted.
Click a row to drill into the per-customer logging section.

Backend: getUnbilledSummaryByCustomer() + GET
/api/admin/customers/hour-entries/unbilled-summary (customers.view).
2026-06-02 11:43:42 +02:00
Luca ab6bad17c9 feat(hours): install-wide default rate + inline missing-rate CTA
Hour-entry saves hard-failed with an English-only error when a customer
had no rate, and the standalone hours page showed a disabled rate field
that looked set. Add a global business_profile default_hourly_rate_minor
(migration 113) as the last link in the rate chain
(entry override → customer → install default), so saves succeed with the
global rate. When no rate resolves anywhere, replace the save-time error
with a read-only resolved-rate display + a CTA to set a customer or
install-wide rate, disable Add-entry until a rate/override exists, and
translate the backend HOURLY_RATE_REQUIRED toast (en+de).
2026-06-02 11:33:45 +02:00
Luca d9251c0850 feat(crm): Finder-style sortable column headers, default sort by issue date
Replace the sort <select> dropdowns on the invoice, quote and contract
list pages with clickable column headers that toggle asc/desc and show a
chevron indicator. Adds a shared SortableHeader component + useColumnSort
hook that maps clickable columns onto the server-side sort enum.
Make issue date (newest first) the standard sort on all three lists,
set at the frontend, route and service layers. Adds issue_asc/issue_desc
to invoices and an "Issued" column to the bills table so the default is
visible and toggleable. Extends sort coverage so every clickable column
has both directions (+customer_desc on all; +issue_asc/desc on
quotes/contracts). Storno rows remain listed.
2026-06-02 10:59:36 +02:00
Luca 095edfe06d fix(bills): localize the event_date on the invoice detail card
The Anlass field rendered inv.eventDate verbatim (raw ISO from pg
date-as-Date serialization) while every other date on the card went
through useLocalizedDate. Route it through fmtDate so it honors the
general_date_format setting. (The event_id → /admin/events linkify was
already in place.)
2026-06-02 10:04:23 +02:00
Luca db7d11dc7f feat(bills): capture event name/date when importing historical invoices
The historical-invoice import form had no event field, so imported rows
landed with event_name = NULL even when the admin knew the occasion. Add
free-text Event name + Event date inputs to the import modal, thread them
through billsService.importHistorical and the POST /admin/invoices/import
validator, and store them in the event_name/event_date snapshot columns
(migration 107). event_id stays NULL — no FK, since the event may predate
picpeak. Autocomplete-to-event_id linking deferred as a future bonus.
2026-06-02 09:50:51 +02:00
Luca c2d3f663bc fix(settings): use CountrySelect for business-profile country (no more FL)
The business-profile country field — the seed source for the customer-create
country default — was still a free-text input placeholdered "FL", which could
reintroduce the non-ISO "FL" code that migration 110 normalized to "LI" and
re-open the create/edit CH-vs-FL default mismatch. Swap it for the shared
CountrySelect so every surface stores ISO alpha-2. The free-text countryName
verbatim-PDF override (migration 107) is unchanged.
2026-06-02 09:37:33 +02:00
Luca 06b4522dec feat(billing): manual cadence + fix admin date inputs ignoring date-format setting
Manual billing cadence
  Adds a "Manual (trigger only)" cadence alongside monthly/quarterly. It reuses
  the monthly draft accumulator — invoices and billed hours pile onto one running
  draft — but stores NULL monthly_period_start/end so the scheduler's auto-flush
  never matches. The draft ships only when an admin clicks "Trigger invoice now".
  No migration: billing_cadence is a free-form string column gated by validators.
  - adminCustomers.js: allow 'manual' in billing_cadence validator
  - invoiceService.js: route manual through accumulator; NULL periods + placeholder
    issue/due date in getOrCreateMonthlyDraft
  - customerHoursService.js: manual auto-appends hours to running draft;
    billUnbilledEntries refuses manual (CADENCE_MISMATCH)
  - CustomerDetailPage.tsx: dropdown option, cycle-day hidden for manual,
    NULL-period-safe draft preview + trigger button, manual-specific copy
  - customerAdmin.service.ts: cadence union + nullable periodStart/periodEnd
  - en.json / de.json: manual, triggerConfirmManual, triggerHintManual,
    draftPreview.titleManual
Date-format fixes
  Replace raw <input type="date"> (browser-locale) with LocalizedDateInput so
  these admin surfaces honor the general_date_format setting:
  - ContractEditorPage.tsx (issue / valid-until / event dates)
  - QuoteEditorPage.tsx (event / valid-until dates)
  - EventDetailsPage.tsx (expiry date)
  - HoursSection.tsx (entry date)
2026-06-02 09:27:10 +02:00
github-actions[bot] 3361a8bb51 chore(beta): release 3.60.1-beta.0 2026-06-02 07:17:10 +00:00
github-actions[bot] 987911a03b chore(beta): release 3.60.0-beta.0 2026-06-02 07:11:30 +00:00
Paul Nothaft 9d424d0dbb Merge pull request #596 from Luca-Timo/bugfix/crm-backup
Backup & Restore hardening — close the silent files-only data-loss class
2026-06-02 09:10:57 +02:00
Luca d788a6cde5 feat(crm): per-customer Skonto opt-out
Adds customer_accounts.skonto_disabled (migration 112) so a customer
that negotiated "no early-payment discount" can be flagged once instead
of ticking the per-invoice toggle on every invoice. resolveSkontoPercent
ForInvoice and the PDF render context both honour it, extending the
resolution chain to customer → invoice → snapshot → quote → global.
Checkbox added to the customer detail Billing card (en + de).
2026-06-02 09:00:05 +02:00
Luca 7c39a30957 feat(bills): link event label to its event detail page
The Anlass / event name on the invoice detail page and the bills list
now links through to /admin/events/:id when the invoice references a
real event row. The list link stops propagation so it doesn't trigger
the row's invoice navigation. Falls back to plain text when the invoice
carries only a free-text event snapshot. Customer portal unchanged
(no admin route access).
2026-06-02 08:38:07 +02:00
Luca 522cf2aa4a fix(invoices): auto-track due date from send date + payment term
Due date now derives from (scheduled send date else issue date) plus the
selected Net-days template, both in the editor and on save. The bill
editor renders it read-only with an Override toggle for manual entry;
existing invoices preserve their stored due date. Backend adds a single
resolveNetDays resolver that honors the split payment-net-days template
(previously only the legacy FK was read) and the
crm_payment_default_net_days setting, used by createInvoice and the
installment-spawn path alike.
2026-06-02 01:52:45 +02:00
Luca db2c482ae9 feat(crm): country dropdown + name guard for customer create/edit
Replace the free-text 2-char country code field on the inline customer
create form and the customer detail page with a dropdown that shows
localized country names (Intl.DisplayNames, no hardcoded map) while
still storing the ISO 3166-1 alpha-2 code. The create form now seeds the
default country from the business profile instead of leaving it blank or
guessing CH/FL. The free-text countryName override is kept for the rare
case where an operator wants a custom display string.

Standardize Liechtenstein on the ISO code LI instead of the colloquial
plate code FL so it matches the PDF renderer's locale-aware lookup and
the new dropdown. Migration 110 normalizes existing FL rows to LI on
customer_accounts and business_profile (idempotent, case-insensitive).

Require at least one human-readable identifier (company name or a
contact name) at create time so the form can't produce a nameless row
that's impossible to recognise in lists later. Enforced on both the
frontend (isValid + toast) and the backend POST /admin/customers
validator so the API can't be bypassed.

i18n: en + de updated; other locales fall back to inline English
defaults and should get a native review before release.
2026-06-02 01:28:00 +02:00
Luca 840df52581 fix(crm): respect general_date_format on all admin date inputs
Admin date inputs were inconsistent: raw <input type="date"> on event
creation and the bill editor rendered in the browser locale (en-US users
saw MM/DD/YYYY regardless of Settings -> General), while the historical-
invoice import modal used a private LocalizedDateField that displayed the
configured format but showed a text box plus a tiny native date stub
side-by-side ("two date fields, looks corrupted").

Extract a single shared LocalizedDateInput that displays/parses in the
configured general_date_format on every browser and opens the native
picker via a calendar icon button (showPicker on a visually-hidden native
input), so there is one date field, not two. Wire it into event creation,
the bill editor (event/issue/due dates), the import modal, and the tax-
report range filters (dropping the Chromium-only lang={dateInputLang}
workaround there).
2026-06-02 01:15:40 +02:00
Luca 09f6a1af6a fix(backup-ui): respect general_date_format + general_time_format
The four backup admin panes (BackupHistory, BackupDashboard,
BackupCoverageCard, BackupIntegrityCard) used raw date-fns
`format()` with hard-coded tokens like 'p' (12-hour AM/PM), 'PP',
'PPP', 'PPp', and 'yyyy-MM-dd HH:mm:ss' — ignoring the admin's
configured `general_date_format` and `general_time_format`
settings.

Net effect on a 24h-configured install: backup History row showed
"11:25 PM" instead of "23:25", and the Coverage tab's "Last dump"
+ "Coverage generated" timestamps were stuck on
yyyy-MM-dd HH:mm:ss regardless of the admin's date-format choice.

All four panes now route through `useLocalizedDate()` which honors
both settings + the active i18n locale (per the existing
[[feedback_respect_general_format_settings]] pattern).

Tokens replaced:
  format(date, 'p')              → formatTime(date)
  format(date, 'PP')             → format(date)
  format(date, 'PPP')            → format(date)
  format(date, 'PPp')            → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm:ss') → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm')    → formatDateTime(date)

No backend changes — settings already shipped via /admin/settings;
this just makes the consumers actually read them.
2026-05-31 23:33:50 +02:00
github-actions[bot] b646dab493 chore(beta): release 3.59.1-beta.0 2026-05-31 21:10:15 +00:00
Paul Nothaft c246fd3cc8 fix(admin-header): hide wordmark on <sm when logo also shows (#523)
Rekoo-PS's v3.59.0-beta.0 screenshot showed a different shape than
the truncate fix in e7cf834 addressed. Their company name ("Arkan
Studio") isn't unusually long, but with logo_and_text display mode
on a phone-width viewport the wordmark wrapped to two lines and the
LanguageSelector button — sitting in the right action cluster —
landed visually on top of the wrapped second line.

Truncate alone left "Arkan Studio" rendered as "Ar..." after the
logo image. Functional but ugly, and on accounts where the wordmark
reaches the right cluster the visual overlap returns. Match what
LanguageSelector does for its language name in #527: hide the
wordmark on <sm when a logo is also showing (the logo carries the
identity), keep it on sm+. text_only mode is unchanged — wordmark
shows on every width, otherwise nothing would render.

Truncate stays in place as defensive depth for the text_only path.
2026-05-31 23:07:19 +02:00
Luca e7dffa656b feat(backup-stats): per-Stage-B-path counters in backup statistics
Closes the last gap from tonight's backup-hardening: backup_runs.
statistics now carries a `per_path` map keyed by backup_paths.path
(e.g. `events/active`, `business-docs`), with per-bucket count + size.

Backend (backupService.js):
  - new `computePerPathStats(backedUpFiles, allFiles)` helper that
    bucket-sorts each backed-up file into its owning backup_paths row
    by longest-prefix match. Reuses the same backup_paths source the
    walker reads, so toggling include_in_default off propagates
    correctly. Falls back to LEGACY_BACKUP_PATHS if the table is
    missing.
  - runBackupInternal calls it after the destination implementation
    reports back, includes the result in statistics under both
    snake_case (`per_path`) and camelCase (`perPath`) keys for the
    same alias treatment the existing fields get.

Frontend (BackupHistory.jsx):
  - Backup History detail pane now renders one row per per_path entry
    when present, with path label + count + formatted size.
  - Falls back to the legacy Photos / Archives / "Other" rendering
    when the field is absent (backups taken before this commit). No
    breaking change for stored history.

Tests: new backupService.perPathStats.test.js — 2 scenarios pinning
attribution behaviour (single-path, nested-paths-don't-collide).
Plus a NOTE comment about overlapping-path walker behaviour (out of
scope; canonical seed doesn't hit it).
2026-05-31 22:54:09 +02:00
Paul Nothaft 791e9974eb fix(gallery): preserve per-viewer is_liked across hard refresh (#590 follow-up)
The in-session toggle fix in d292b9f handles click 2 correctly, but
on a hard refresh likedPhotoIds was always initialized to an empty
Set — so previously-liked photos rendered un-filled until the user
opened the lightbox.

Backend: gallery.js GET /:slug/photos now mounts resolveGuest and
emits a per-viewer is_liked boolean per photo. Prefers req.guest.id
when a verified guest token is present (per-person identity), falls
back to the IP+UA hash that generateGuestIdentifier produces — same
identity model galleryFeedback.js uses for /my-feedback. Skipped
when feedback is hidden from guests.

Frontend: Photo type gains optional is_liked. Each of the 7 grid
layouts (Masonry / Grid / Justified / Timeline / Carousel / Mosaic /
Premium) seeds its lifted likedPhotoIds Set from photos.filter(is_liked)
on the first non-empty payload, gated by a seededRef so subsequent
React Query refetches don't clobber in-session optimistic toggles.
Mosaic uses photo.is_liked ?? false in its per-card useState initializer.

GalleryPremium also drops the buggy `|| like_count > 0` fallback at
line 521 that treated "anyone liked this" as "I liked it" — the
per-viewer seed is now the correct source.

GalleryStory had the same shape of bug in two places — same #590 fix:
- Seed switched from like_count > 0 (global) to is_liked (per-viewer),
  with the same mount-only seededRef guard.
- handleToggleFavorite now calls submitFeedback on EVERY click, not
  only when adding. The previous code skipped the unlike submit, so
  the UI removed the heart while the server kept the like row.
2026-05-31 22:47:16 +02:00
Luca 155aa63103 fix(backup-dashboard): show last SUCCESSFUL backup + last attempt separately
The dashboard widget used `lastBackup.created_at` for the "Last
successful backup: X ago" text — but lastBackup is the most recent
row of any status. So a crashed restore (status=running, never
updated) or a recent failure showed up labeled as the last
successful backup. Same "silent failure not surfaced" class the
restore wizard had.

Backend now returns:
  lastSuccessfulBackup — most recent backup_runs with status='completed'
  zombieRuns — running rows older than 30 min (likely crashed mid-flight)
  lastBackup — unchanged (most recent any status)

Frontend renders:
  - "Last successful backup: X ago"  — always from lastSuccessfulBackup
  - "Last attempt: Y ago · failed/running"  — when lastBackup differs
    from lastSuccessful. failed shows the first line of error_message
    in red; running stays neutral.
  - Zombie callout — "N backup(s) running >30min — may have crashed"
    in amber, so admin sees stuck rows at a glance.
  - Health score downgrades from "excellent" to "warning" if the
    latest attempt failed, even when older successes keep the age
    fresh — surfaces regressions without erasing the green history.
2026-05-31 22:44:22 +02:00
Luca 47ed6907d1 fix(restore-wizard): surface failure status, stop showing "completed at 0%"
The progress step used a binary `isRunning ? "in progress" : "completed"`
check. So when the backend rejected the restore (pre-flight validator
threw, path error, etc.) the wizard cheerfully rendered "Restore
completed" with 0% progress and no error context — the admin had to
SSH into the server and inspect `restore_runs.error_message` to find
out what happened.

Now reads the most recent row from `restoreStatus.history[0]` and
renders one of three states:
  - running    → blue text, progress bar updates
  - succeeded  → green tick + post-restore actions (existing behaviour)
  - failed     → red banner with the first line of error_message, and
                 a callout if was_rollback_attempted is true so the
                 admin knows the destination is safe to retry on top of.

Net: the wizard now tells the truth about what just happened.
2026-05-31 22:44:00 +02:00
Paul Nothaft c83e88348f fix(nginx): defensive large_client_header_buffers bump (#591)
Default nginx is 4 8k — too tight when an outer Cloudflare /
corp-proxy injects long Set-Cookie / X-Forwarded-* headers, or when
a power-user accumulates many per-gallery gallery_token_<slug>
cookies over the 24h maxAge in tokenUtils.js. Either way users hit
"400 Request Header Or Cookie Too Large" and clearing cookies is
the only workaround.

4×32k is cheap RAM, matches what most reverse proxies do upstream,
and means PicPeak doesn't fail the request before the upstream even
sees it.
2026-05-31 22:35:19 +02:00
Paul Nothaft d292b9fa10 fix(gallery): toggle (not add) the local liked set on click (#590)
The /feedback like endpoint is a server-side toggle — the same one
the lightbox uses. Every grid layout's optimistic-UI setter only
ever did next.add(photoId), so click 2 on a liked tile fired a
server unlike but kept the heart filled in the UI.

Switch each setter to toggle (delete if present, else add). Covers
Masonry (default), Grid, Justified, Timeline, Carousel, Mosaic, and
Premium layouts — including their identity-modal callback paths for
shape consistency. Lightbox toggle is unchanged (already correct).
2026-05-31 22:35:15 +02:00
Paul Nothaft e7cf834325 fix(admin-header): truncate long company names on narrow widths (#523 regression)
#527 hid the language *name* on <sm to free space for the title.
Since then the right cluster gained dark-mode toggle, notifications,
and the user avatar, and the brand block still had no truncation —
so a long branding_company_name would still push past the available
width into the action buttons on phones.

Defensive fix: min-w-0 on the brand-block wrapper, truncate on the
company-name span, flex-shrink-0 on the logo image. Long names now
ellipsis within the left cluster regardless of how many widgets
fill the right.
2026-05-31 22:35:07 +02:00
Paul Nothaft dcc629cad2 fix(csp): external bootstrap script to survive strict reverse-proxy CSP (#564)
demo.picpeak.app sits behind Caddy + Cloudflare; Caddy replaces the
nginx CSP entirely with one that omits 'unsafe-inline' / hash / nonce,
so the #358 inline theme-bootstrap was being blocked there — admin
loaded a black page, the SPA bundle 404'd, link buttons did nothing.

Move the bootstrap to /public/bootstrap.js served as 'self' so the
script runs under every reasonable CSP without further coordination.
Vite copies /public/* to the dist root at build time (same pipeline
as /favicon-32x32.png), and it remains in <head> without defer/async
so it still runs before <body> paints. The OS-preference @media CSS
above still handles the first-frame dark/light baseline.
2026-05-31 22:35:00 +02:00
Luca 7f7c8eee61 fix(backup-history): show Total + Other so per-row sums match the count
The "Content Backed Up" panel in Backup History only counted two
categories (Photos + Archives), so a 3-file backup that landed all
3 in business-docs (Ralf's case after the storage truncation +
restore tonight) showed:
  Photos (0 of 0)
  Archives (0)
  → total: 3 files
The discrepancy made admins wonder where the 3 files actually went.

Adds two rows:
  - "Business documents & other" = files_processed - photos - archives
  - "Total files" = files_processed
So the math adds up regardless of which Stage B path the files came
from. Properly per-path-category breakdown requires backend-side
per-path counters (separate follow-up); this commit closes the
visible-discrepancy gap without that schema change.

i18n: en + de added; other locales fall back to en until reviewed.
2026-05-30 21:22:27 +02:00
Luca ed7ab61b90 fix(admin-ui): backup download button hits API path, not SPA route
`BackupHistory.jsx` opened `/admin/backup/download/<id>` via window.open,
which goes to the React SPA's router — no matching route, so it
rendered the "Page Not Found" screen.

The actual download endpoint lives at `/api/admin/backup/download/:id`
on the backend (adminBackup.js:685). Cookie-based admin auth already
supports the implicit cookie sent by window.open, so the URL prefix
was the only thing missing.

Predates today's backup-hardening work — the bug has existed since
this download button shipped. Surfaced now because Ralf finally has a
completed backup to try downloading after the Stage A inline-dump
guard started working.
2026-05-30 03:51:02 +02:00
github-actions[bot] 714f7647fc chore(beta): release 3.59.0-beta.0 2026-05-29 20:54:16 +00:00
Paul Nothaft dfcebccee9 feat(admin/users): reactivate + delete actions for deactivated admin users
#574 follow-up — @blazmaric flagged that once an admin user is
deactivated, the UI loses every affordance to manage that record.
The deactivate button hides (rightly — they're already deactivated)
but nothing replaces it, leaving the row stranded in the list with
no path to either restore access or permanently remove it.

## Backend

New on `userManagementService`:

- **`activateAdminUser(id, activatedById)`** — symmetric to
  `deactivateAdminUser`. Flips `is_active` back to true, logs
  `admin_user_activated` activity. Idempotent: already-active target
  short-circuits without bumping `updated_at`. No "can't activate
  yourself" guard needed (actor is by definition already active).
- **`deleteAdminUser(id, deletedById)`** — hard-deletes the row.
  Same self-action and last-super-admin guards as deactivate.
  Last-super-admin guard counts ACTIVE super admins excluding the
  target — so an already-deactivated super_admin can still be
  deleted when an active super_admin remains. FK ON DELETE rules
  in core migrations handle the cascade: SET NULL on
  `created_by_admin_id` everywhere (events, photos, quotes,
  invoices, contracts, customer_accounts, …); CASCADE on the
  user's own `api_tokens` + their pending admin / customer
  invitations.

New routes on `adminUsers.js`:

- `POST /api/admin/users/:id/activate` — `users.delete` permission
  (same tier as deactivate; reverting deactivation is the same
  scope of action as performing it).
- `DELETE /api/admin/users/:id` — `users.delete`.

## Frontend

`UserManagementPage.tsx`:

- New mutation hooks: `activateUserMutation`, `deleteUserMutation`.
- The row's action cell now branches on `user.isActive`: active
  users see Edit + Deactivate (unchanged); deactivated users see
  Edit + Reactivate (`UserCheck` icon, green hover) + Delete
  (`Trash2` icon, red hover).
- The shared `ConfirmDialog` handles all four action types
  (deactivate / activate / delete / cancelInvitation) via per-type
  title / message / confirmText / variant lookup.

`userManagement.service.ts`:

- New `activateUser(id)` and `deleteUser(id)` methods mirroring the
  existing `deactivateUser` shape.

i18n keys are added with English fallbacks via `t(key, fallback)`
so the page works on every locale without a missing-translation
warning. Native translations can be filled in via a follow-up.

## Test plan

- [x] 8 new service tests pin: activate happy-path, idempotency on
  already-active, NotFoundError on missing target, activity log
  emitted, delete self-refusal, last-super-admin guard for both
  active and already-deactivated super_admin targets, hard-delete
  success, delete activity log.
- [x] Frontend type-check clean.
- [x] Frontend lint clean for the changed files.
- [x] Backend lint clean.
- [ ] Manual: deactivate a user → row now shows Reactivate + Delete
  → reactivate → user can log in again. Then deactivate again →
  delete → row vanishes, pending tokens for that user invalidated.

Closes the UX gap blazmaric called out in
https://github.com/the-luap/picpeak/pull/579#issuecomment-... .
2026-05-29 22:49:01 +02:00
Luca 03e6617f38 feat(backup): coverage diagnostic — what will the next backup miss?
Stage C of the three-stage backup-hardening plan (Stage A: inline
DB dump + fail-loud landed in 7fdf01a; Stage B: config-driven walker
in 302fc6b). Answers the "what would I lose if I clicked Run Backup
Now right now?" question that Stage B made possible to answer.
Backend:
  - new backupCoverageService.js: per-path coverage classification,
    drift detection (top-level subdirs not in backup_paths and not
    in the backups/tmp allow-list), DB-dump mode + staleness block
  - new GET /api/admin/system-health/backup-coverage route, same
    auth + settings.view permission as /backup-integrity
  - 7 integration scenarios pinning the classifier behaviour
Frontend:
  - new BackupCoverageCard with auto-fetch (cheap; no recursion)
  - new Coverage tab on BackupManagement next to Integrity
  - en + de i18n; other locales fall back to en keys until a native
    speaker reviews
Verification:
  - 26/26 backup integration tests pass (Stage A 5 + Stage B 7 +
    Stage C 7 + adminBackupIntegrity 4 + businessDocs 3)
  - frontend build clean
  - 4 pre-existing integration failures confirmed unrelated
2026-05-29 22:20:32 +02:00
github-actions[bot] d50edcc427 chore(beta): release 3.58.0-beta.0 2026-05-29 20:05:46 +00:00
Paul Nothaft 433af15146 Merge pull request #582 from the-luap/feat/slovenian-locale-580
feat(i18n): add Slovenian (sl) language support
2026-05-29 22:05:32 +02:00
github-actions[bot] 4989c468a8 chore(beta): release 3.57.2-beta.0 2026-05-29 20:04:53 +00:00
github-actions[bot] 7be484eb8e chore(beta): release 3.57.1-beta.0 2026-05-29 20:03:02 +00:00
Paul Nothaft 37cc3631d8 feat(i18n): add Slovenian (sl) language support
Closes #580.

Slovenian community contribution from @blazmaric (filed as an issue
with attached files rather than as a PR — files inlined here unchanged
except for the migration number).

## Changes

- **`frontend/src/i18n/locales/sl.json`** — full Slovenian UI
  translations. Covers every top-level key present in `en.json` as
  of pre-CRM beta. The new CRM-module keys (`bills`,
  `businessProfile`, `calendar`, `contracts`, `crm`, `crmDev`,
  `crmSettings`, `dealLineage`, `eventReminderOverride`,
  `hoursLogging`) are not yet translated and will fall back to
  English — same posture as FR / NL / PT / RU / ES currently have
  for the CRM module (see PR #555 description).
- **`frontend/src/components/common/LanguageSelector.tsx`** — adds
  `SLFlag` SVG component + registers `{ code: 'sl', name:
  'Slovenščina', Flag: SLFlag }` in `SUPPORTED_LANGUAGES`. Frontend
  i18n auto-discovers locale files via `import.meta.glob` so no
  separate config registration is needed.
- **`backend/migrations/core/108_seed_sl_email_template_translations.js`** —
  contribution-author's `107_*` filename renumbered to `108_` to
  avoid collision with `107_crm_consolidated.js` that landed on beta
  in the meantime. Idempotent insert via (template_id, language)
  uniqueness check — re-runnable, never overwrites admin edits.
  Covers 17 templates: admin invitation / password reset, archive
  complete, backup completed / failed, customer gallery assigned,
  customer invitation / password reset, database backup completed /
  failed, expiration warning, gallery created / expired, restore
  completed / failed, version update available / test.
- **`backend/src/services/emailProcessor.js`** — adds `.si → sl` to
  the email-domain → language inference map, matching the pattern
  for every other supported locale. A customer with `@example.si`
  now gets Slovenian emails automatically without needing to set
  their preferred_language explicitly.

## Out of scope (consistent with existing locales)

- CRM email templates (quote_sent, invoice_sent, contract_sent, etc.,
  seeded at boot by `crmEmailTemplates.ensureCrmEmailTemplatesSeeded`)
  will fall back to English for Slovenian customers — those seeders
  only emit EN + DE rows today across every locale.
- CRM UI strings under the missing top-level keys listed above will
  fall back to English.

Both gaps mirror the existing FR / NL / PT / RU / ES situation.
2026-05-29 21:54:55 +02:00
github-actions[bot] 463ef4d0fd chore(beta): release 3.57.0-beta.0 2026-05-29 14:25:27 +00:00
Luca 7e2feca12f feat(backup): UI for backup-integrity verifier — tab + post-restore CTA
Frontend half of the diagnostic shipped in 4812fcd. Adds:
  - BackupIntegrityCard component — runs the check on demand, surfaces
    the five summary counters (total / verifiedOk / existsButNoHash /
    missing / hashMismatches), and expands collapsible result tables
    for missing files + hash mismatches. existsButNoHash is exposed as
    a separate amber-toned bucket so admins can distinguish hash-
    verified evidence from existence-only at a glance — the latter is
    explicitly weaker in a legal dispute and the UI says so.
  - "Integrity" tab on BackupManagement, alongside the existing
    Dashboard / Configuration / History / Restore tabs. Card is
    portable — when the System Health page (backlog item) lands it
    can lift the component without changes.
  - Post-restore CTA on the RestoreWizard success card (D2 follow-
    through): "Verify document integrity now" button that switches
    the parent tab to Integrity. The audit trail captured at sign /
    issue time is worth nothing if the documents it refers to are
    missing from the restored copy — verifier surfaces that drift
    in one click before the admin trusts the restored state.
i18n strings added in EN + DE (per user_languages — only those two
are native; other locales fall back to the English defaults and
should be flagged for native-speaker review per
feedback_translation_flagging if anyone picks them up).
2026-05-29 13:11:43 +02:00
Paul Nothaft 48cf1121e5 Merge pull request #575 from the-luap/feat/clickable-version-links-566
feat(admin): clickable version links + update-available modal with changelog & upgrade command
2026-05-29 11:35:06 +02:00
github-actions[bot] 84486c65f1 chore(beta): release 3.56.0-beta.0 2026-05-29 09:27:47 +00:00
Paul Nothaft 5f0fcc225c Merge pull request #555 from Luca-Timo/feat/crm-pr
feat: CRM module — quotes, contracts, invoices, hours, calendar, tax
2026-05-29 11:27:23 +02:00
Paul Nothaft 832f7bad45 feat(admin): update-available modal with aggregated changelog + upgrade command (#567)
Closes #567.

The sidebar already had a "vX.Y.Z available" indicator (#566 made it a
link to that release's page) but there was no way to read the actual
changelog inline or to grab a copy-paste upgrade command. This adds
the modal the issue spec'd, layered on top of the existing
updateCheckService / environmentService backend infrastructure that
already shipped.

## Backend

- `updateCheckService.fetchAvailableVersions` now returns full release
  objects (tag, name, body, publishedAt, htmlUrl) instead of just
  version strings — body data is what the changelog modal renders.
  `checkForUpdates` extracts the version strings for its existing
  consumers; no API change visible to callers.
- New `getReleasesSince(currentVersion, channel)` returns the list of
  releases strictly newer than current, filtered to the user's
  channel. Reuses the same 1-hour cache as `checkForUpdates` so the
  modal opening doesn't trigger an extra GitHub round-trip.
- New `GET /admin/system/updates/changelog` route in `adminSystem.js`,
  same auth + UPDATE_CHECK_ENABLED gating as the existing
  /updates and /updates/instructions endpoints.
- 4 unit tests (axios mocked) pin: strictly-newer filtering,
  channel-scoped, empty array on GitHub fetch failure, empty array
  when already on latest.

## Frontend

- New `UpdateAvailableModal.tsx` — opens from the sidebar chip. Two
  sections:
    1. **How to upgrade** — fetches /updates/instructions for the
       environment-detected copy-paste command (Docker compose / git /
       standalone). Copy-to-clipboard button per step.
    2. **Release notes** — fetches /updates/changelog for every
       version between current and latest in the user's channel.
       Latest is auto-expanded; older releases are collapsed by
       default (click to expand). Each release also has a "View on
       GitHub" link to the canonical release page.
  - Renders release body markdown through the existing safe
    MarkdownContent component (marked + DOMPurify allowlist).
- New `updateDismissal.ts` helper — single localStorage key holds the
  last-dismissed version. Chip stays hidden until a STRICTLY newer
  version appears, using the same compare semantics as the backend
  (stable > beta, higher beta > lower beta, semantic numeric on
  major.minor.patch). 9 unit tests pin the rules.
- `VersionInfo.tsx` — chip is now a button that opens the modal
  instead of an external link (the #566 link-to-release behaviour is
  preserved on the modal's per-release "View on GitHub" affordance).
  Dismissal triggers an immediate re-render so the chip disappears
  without waiting for the next route change.

No new dependencies — uses `marked` + `DOMPurify` that were already
present in the bundle for the contract block renderer.
2026-05-29 11:26:24 +02:00
Paul Nothaft d231623c59 feat(admin): link version numbers in sidebar to GitHub release notes (#566)
Closes #566.

The admin sidebar showed the running frontend + backend versions as
plain text. Wraps each version (and the "update available" indicator)
in an anchor pointing at the corresponding GitHub release tag, opening
in a new tab so the admin session isn't disrupted.

A small githubReleaseUrl helper (extracted to its own module for
testability) does the version → URL mapping. Because release-please
tags every release as `vX.Y.Z[-beta.N]`, the version string already
carries the channel suffix and a pure template covers both stable and
beta without branching.

Three unit tests pin the URL template — stable, beta-with-suffix, and
a defensive check that the leading `v` isn't double-prefixed if a
caller accidentally passes a tag-shaped value.
2026-05-29 10:25:12 +02:00
Luca 5ce0b6edc3 fix(quote-response): compute minutes-remaining for the DE changeWithin string
Previous fix (45f0606) papered over the bug by changing the German
wording from "innerhalb von {{minutes}} Minuten" to "bis {{at}}" —
that worked but changed the UX intent. The original German wording
("you have N minutes left") was deliberate and clearer than an
absolute clock time; the actual bug was that no caller ever computed
`minutes` from `responseLockedAt`.

Revert the DE translation to its original wording, then build a
{ at, minutes } object at the call site so EN ("until {{at}}") and
DE ("innerhalb von {{minutes}} Minuten") each pick up the variable
they need. `minutes` rounds UP so a 14m 32s remainder displays as
"15 Minuten" rather than promising 14 the customer can't actually
hit.
2026-05-27 16:06:35 +02:00
github-actions[bot] fd8ee5d52c chore(beta): release 3.55.0-beta.0 2026-05-27 13:53:11 +00:00