Two SSR-OG injection bugs reported by @alexvaltchev. Both made his link
previews fall back to the brand logo + site-wide tagline instead of the
event-specific name/photo, even though the bot UA was hitting our
already-existing OG handler. He compensated with a Cloudflare Worker as
SSR middleware — which then created bug 3 below (og:image at the
auth-gated /api/.../hero/ path, not the public /og/.../cover one), so
Instagram never rendered the image either.
## Bug A — slideshow URLs miss the OG handler entirely
`/gallery/<slug>/show/<token>` has 3 segments after `/gallery/`. The OG
route was wired only at `/gallery/:slug/:token?` (1-2 segments), so
slideshow links fell through to the SPA-catchall `/gallery/*` and never
invoked the OG handler at all. Added a second route handler for the
3-segment slideshow shape, sharing the same intercept middleware so a
recognised social crawler still gets the rich preview.
## Bug B — share-token-only URLs resolve to nothing
`/gallery/<32-char-share-token>` (the form produced when migration 525's
short-URLs option strips the event slug) routes to the OG handler with
`slug=<token>`. resolveSlug then queries `events.slug = <token>`, which
never matches because the token is in a separate `share_token` column.
Result: falls through to the "no event found" branch and serves the
generic site-wide OG.
Fix: when the slug shape matches a 32-char hex AND the slug lookup
missed AND no redirect rule applies, try `events.share_token = slug` as
a final fallback. Real slugs are kebab/dot/underscore mixes, never pure
32-hex, so the extra DB roundtrip is gated to only fire for the
token-shaped URL.
## Tests
3 new tests in galleryOgService.shareImage.test.js using non-entropy
32-hex fixtures (deliberately zero-padded to avoid tripping
GitGuardian's Generic High Entropy Secret detector while still
matching the route's /^[a-f0-9]{32}$/i shape check):
- share-token slug resolves via the share_token column (alex's case)
- malformed/expired 32-hex token returns the site-wide fallback (no leak)
- non-hex slugs skip the share_token query entirely (hot-path cost guarded)
All 14 tests in the file pass.
## Out of scope here (separate follow-up)
- Issue 2 (Instagram og:image) — alex-side CF Worker bug pointing
og:image at /api/gallery/<slug>/hero/<id>, which requires gallery
auth. PicPeak already has the right unauthenticated path
(/og/gallery/<slug>/cover) gated by events.og_image_share_enabled
per-event opt-in (#474). Documented in the issue reply.
- Issue 3 (URL shortener with custom names) — real feature request,
meaningfully different from the existing #525 short-URLs option that
just strips the slug. Designing separately.
- 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.
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).
Branch protection on `main` + `stable` lists `upgrade-from-bootstrap`
and `fresh-install` as REQUIRED checks. The producing workflows had
`paths:` filters in their `pull_request` triggers, so they correctly
skipped on PRs that didn't touch migrations / package.json. But a
skipped workflow doesn't satisfy a required check — it leaves the
status "missing", which blocks merge on every unrelated PR.
Concretely surfaced on PR #692 (security bumps): all 12 visible checks
were green, but the merge button was blocked because the two
path-filtered workflows skipped and their required-check names never
reported.
This PR drops the `paths:` filter from both workflows so they always
fire on PRs against `main` + `stable`. Costs:
- `schema-drift` (`upgrade-from-bootstrap`): ~75 s per PR (Postgres
service boot + migrate:safe run + schema assertion).
- `install-smoke` (`fresh-install`): ~2 min per PR (full Docker
Compose boot + login).
Both are buying unconditional safety nets on the install + migration
paths, which is what the required-check gate is supposed to model.
Also fixes the trigger branch list while in the file: `[main, beta]`
→ `[main, stable]`, completing the post-#669 rename for these two
workflows that were missed in PR #686.
## What this does NOT fix
`GitGuardian Security Checks` is the third required check that's
currently missing on PRs — but that's a separate problem. The
GitGuardian GitHub App was installed at the user-account level
(`the-luap`) before the org transfer and didn't move with the repo.
Re-installing it on the org via the GitHub Marketplace is a UI step
the maintainer needs to do; can't be done via API.
@Luca-Timo is on main's review-bypass list so he can self-merge small
bugfixes without waiting for a maintainer review. The bypass list alone
is binary (he can merge anything), so this adds a complementary required
status check that fails when a bypass user's PR exceeds a configured
line-count threshold — blocking merge for genuine features while leaving
small bugfixes flowing.
How it works:
- Trigger: pull_request_target (so the workflow runs in the base repo's
context with permissions to write a check status — script never
executes PR code, so fork-PR-attack-safe).
- For PRs authored by a bypass user (default: @Luca-Timo):
- linesChanged = additions + deletions
- If ≤ LINE_LIMIT (300): check = success → bypass works → self-merge OK
- If > LINE_LIMIT: check = failure → required-check gate blocks merge
regardless of bypass; needs a maintainer review.
- For everyone else: check = success ("not applicable"). They go through
the normal review path and are unaffected.
Both constants (LINE_LIMIT, BYPASS_USERS) are at the top of the workflow
for easy tuning.
After this lands on main, a separate API step adds 'bypass-size-gate' to
the main branch's required_status_checks list so the gate is actually
enforced. Until that's in place the check runs but doesn't block.
GitHub-flavored `> [!IMPORTANT]` callout right below the title, before
the badges/hero block, so it's the first thing a visitor or repo browser
sees in the rendered README. Mirrors the in-app banner (#687) so an
operator gets the same message whether they're browsing the repo or
logged into the admin dashboard.
Body covers:
- Image-path change with the literal new path
- Branch rename (beta → main, main → stable) with auto-redirect note
- Link to docs/migration-to-org.md for the exact compose-file edit
Remove (or downgrade to a regular note) after the migration window
settles, same lifecycle as the in-app banner constant.
One-time banner shown at the top of the admin layout to surface the org
rename + GHCR registry change for operators who haven't read the release
notes. Sits right below the existing maintenance banner — same pattern.
## What it looks like
Blue, dismissible banner with a short body:
> PicPeak's image registry has moved
> Update your docker-compose.yml to pull from
> ghcr.io/picpeak/picpeak/{backend,frontend} — the old path is no longer
> being updated. [See migration notes]
The link goes to `docs/migration-to-org.md` on the new org repo.
## Design choices
- **No backend feature flag.** A hard-coded `MIGRATION_BANNER_ENABLED`
constant in the component file (1 line) gates global display. After
~1 quarter, flip it to false (or drop the mount in `AdminLayout.tsx`)
in a small follow-up PR. A backend `app_settings` row + Settings UI
toggle would be overkill for a one-time migration event.
- **Per-admin dismissal via localStorage.** Key is `picpeak:migration-banner:v1`
(versioned so a future "we've moved AGAIN" banner can show without
inheriting earlier dismissal). Wrapped in try/catch so private-mode
browsers + storage-quota-exceeded errors don't crash the layout.
- **EN + DE strings** under a new top-level `migrationBanner` namespace.
Other locales (fr, nl, pt, ru) fall through to EN — `migrationBanner.*`
keys aren't translated there yet, deliberate (per #669 the ops
banner is operator-facing and admins reading EN/DE is the majority).
- **Reuses `common.dismiss`** for the close-button aria-label.
## Test plan
- [ ] Frontend `npm run build:check` passes (TS + build)
- [ ] Open admin dashboard in EN → banner shows at top, below header,
above main content
- [ ] Switch to DE → banner shows German strings
- [ ] Click dismiss → banner hides, doesn't re-appear on hard refresh
- [ ] Clear localStorage `picpeak:migration-banner:v1` → banner returns
- [ ] Flip `MIGRATION_BANNER_ENABLED` to false → banner doesn't render
for anyone, regardless of dismissal state
Refs #669.
After the org move + branch rename (#669):
beta → main (active development)
main → stable (curated release channel)
This PR rewires the workflows that referenced the old branch names so
release-please and the Docker build target the right channels.
## Workflow changes
### `.github/workflows/docker-build.yml`
- **Push triggers**: `[main, beta]` → `[main, stable]` (both `push.branches`
and `pull_request.branches`). `beta` no longer exists; `stable` is the
curated channel that should also produce builds.
- **`is_prerelease` detection**: pre-release context was decided by
`refs/heads/beta`; now decided by `refs/heads/main` (active dev →
prerelease, `-beta.N` version suffix unchanged).
- **`:latest` + `:stable` tagging**: were gated on `{{is_default_branch}}`
(which used to be `main` = stable channel). Default branch is now `main`
= active dev, so the implicit gate would have aliased `:latest` to dev.
Both tags now explicitly gate on `refs/heads/stable` OR a non-prerelease
release tag.
- **`:beta` tag**: REMOVED. Active-dev pulls are `:main` (auto-generated
by `type=ref,event=branch`). The pre-rename `:beta` tag remains frozen
at its last build under Option B / #669 — operators are expected to
update to `:main` or pin to a versioned tag.
### `.github/workflows/release-please.yml`
- `branches: [main]` → `branches: [stable]`. This is the **stable**
release-please workflow (uses `release-please-config.json`); after the
rename, the stable channel lives on the `stable` branch.
### `.github/workflows/release-please-beta.yml`
- `branches: [beta]` → `branches: [main]`.
- `target-branch: beta` → `target-branch: main`.
- This is the **pre-release** release-please workflow (uses
`release-please-config-beta.json`, `prerelease: true`); after the rename,
pre-releases are cut from the new `main` (active dev). The version-suffix
scheme stays `-beta.N` so existing operator pins keep working.
## RELEASING.md
Rewrote the TL;DR, "How a stable release is cut", and hotfix path to
reference the new branch names. Added a one-line "branch model background"
note pointing at #669 so future maintainers know why `main` means active
dev (the opposite of what some projects use). Filename conventions:
`release/X.Y.Z-merge-from-main` (was `…-from-beta`); promotion PR title
`promote main → stable as vX.Y.Z` (was `promote beta → main`).
## Why combined with PR A's content as a single PR
Originally planned as two PRs (B = workflow triggers, C = release-please
reconfigure). Splitting wasn't worth it: the configs are branch-agnostic
(`release-please-config.json` and `release-please-config-beta.json` don't
mention branch names internally), and not bundling them meant a window
where the stable release-please workflow would fire on pushes to the new
`main` (active dev) — exactly the wrong place. Single PR closes that gap.
## Versioning scheme — kept
No version-scheme decision needed. The `-beta.N` suffix on pre-release
versions is preserved (existing operator pins like `v3.71.3-beta.0` keep
working). If a `v4.0.0-pre.N`-style reset is desired later, that's a
separate PR with explicit operator-comms attached.
Operator + contributor docs for the post-org-move world. None of these
files reference the legacy branch names (`beta` / old `main` meaning) —
they describe the new shape (`main` = active dev, `stable` = curated
release channel), so they're correct from the moment the rename happens.
Three additions/edits:
1. `docs/migration-to-org.md` (new) — operator-facing one-pager that the
in-app migration banner + the OLD GHCR package URLs (now 404) can
point at. Walks through the single `docker-compose.yml` edit needed.
2. `CONTRIBUTING.md` — new "Branch model" section explaining which
branch to target (`main` for features + most fixes; `stable` only
for small, surgical bugfix backports). Updates the "fork from beta"
step to "fork from main". Updates the release-process paragraph to
describe the two-channel model instead of the old beta→main promote.
3. `.github/PULL_REQUEST_TEMPLATE.md` — adds a target-branch hint at
the top of the template (HTML comment so it shows during PR
composition but doesn't render in the merged PR body).
Repo transferred from the-luap/picpeak → PicPeak/picpeak. Docker images
publish to ghcr.io/picpeak/picpeak/{backend,frontend} (lowercase, per the
GHCR canonical form computed by docker-build.yml's `${GITHUB_REPOSITORY,,}`).
Sweep covers:
- docker-compose.production.yml + Dockerfiles → new image registry path
- README, CONTRIBUTING, SECURITY, SIMPLE_SETUP, scripts/picpeak-setup.sh
→ new GitHub URLs
- Update-check / release-notes services (updateCheckService,
environmentService, updateNotificationService, adminSystem,
UpdateNotification, githubReleaseUrl) → GitHub API + tag URLs use the
canonical PicPeak/picpeak path
- Issue templates + README-DOCKER + workflow README → updated package URLs
- One commit-context comment in migrations/090 + customerAccountsService
CHANGELOG.md is intentionally untouched (historical release entries are
immutable; GitHub auto-redirects the old URLs indefinitely).
CLAUDE.md keeps the bare `(the-luap)` reference — that's the maintainer's
personal handle, not a repo URL.
22 files, 48/48 line swaps (every change is a 1:1 URL replacement).
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".
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.
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").
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.
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").
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.
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.
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).
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).
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.
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).