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).
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).
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.
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.)
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.
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.
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).
A scheduled invoice's issue_date was stamped at creation, so a long-
scheduled invoice printed a stale date by the time it shipped — the
relative Skonto window ("pay within N working days") and the net-days
due date were then counted from the authoring day, not the send day.
sendInvoice now stamps issue_date = send date on the first send and
re-derives the due date from it, preserving a manual due-date override.
Adds resolveNetDaysForRow to read net days from the persisted snapshot.
Deselecting Skonto before the scheduled send already propagates (the
scheduler re-reads the row fresh and the render context honours
skonto_disabled); no change needed there.
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).
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.
The invoice-import endpoint stamped sent_at and paid_at with the moment
of import (new Date()) instead of the document's historical dates. The
CRM dashboard "Revenue · last 30 days" card keys on paid_at, so a
year-old paid invoice imported today wrongly counted toward the rolling
window. The dashboard windowing is correct (cash-basis "received in the
window") — the bug was the wrong paid_at on imported rows.
POST /admin/invoices/import now anchors sent_at to issue_date and
paid_at to issue_date (or an optional new paidAt param when the admin
knows the real payment date), never to import time.
Migration 111 backfills rows imported under the old behaviour: for every
invoice with imported_pdf_path set, sent_at/paid_at are reset to
issue_date. The old code never captured a real payment date, so
issue_date is the only sensible anchor. Idempotent and scoped strictly
to imported rows, so picpeak-issued invoices are untouched.
paid_at/sent_at are operational timestamps, not the invoice's immutable
legal content, so correcting the import-time error is safe under the
§14/§11 UStG immutability rule.
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.
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).
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.
The #592 fix added a devtools-detection probe, and the #592 follow-up
added a require_password probe + a branding-defaults whereIn().select().
Both shift the db() call indices the existing #550 test relied on, and
the branding probe needed `.select()` to resolve to an array (the mock
chain wasn't thenable, so `for..of` on the result threw → 500 on every
test that hit BASE_BODY).
Add `whereIn` + `selectResult` to buildChain so the branding probe
yields an iterable. Factor the three pre-slug app_settings chains into
a baseSettingsChains() helper and update each test's queued sequence
and toHaveBeenNthCalledWith / toHaveBeenCalledTimes expectations to
match the new shape. No behaviour change in v1/events.js — only the
test scaffolding moves.
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.
Same class of bug as the devtools-detection gap landed in 2304b25.
v1 POST /events was hardcoding require_password=true in the destructure
default and skipping getBrandingDefaults entirely, so:
- Admins who disabled "require password by default" globally still
got password-required galleries through the API.
- API-created events ignored the global branding_logo_display_hero
and branding_logo_size toggles, defaulting to visible/medium
regardless of the admin's preferred branding chrome.
Mirror the readBooleanSetting + getBrandingDefaults pattern from
adminEvents.js inline (helpers aren't exported, and pulling them out
is out-of-scope for this fix). Adds validators, fallback resolution,
and the three resolved values to the events insert. hero_logo_position
stays at 'top' since #357 / migration 084 explicitly disconnected it
from the header-bar branding_logo_position setting. OpenAPI updated.
Same class of bug as #550 part 2 (feedback default ignored on API
events): the events table column default for enable_devtools_protection
is true, so an admin who disabled detection globally still got it ON
for every API-created gallery.
Mirror the feedback fallback that landed in 1b521e7 — accept an
optional enable_devtools_protection body field, fall back to the
app_settings entry of the same name, and write the resolved value
explicitly on insert so the column default doesn't shadow it.
OpenAPI doc updated to match.
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.
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).
#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.
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.
#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-... .
Closes#570.
PR #555 shipped the CRM module with strong service-layer coverage
but no HTTP-layer tests. This adds Supertest-based route coverage
across the externally-reachable public routes (P0) and an auth-gate
sweep of every CRM admin route (P1+P2).
## What's covered
### P0 — Public routes (49% of new tests)
The three public routes are the security-sensitive surface — any IP
with the raw token from a leaked email can hit them. Tests pin the
publicTokenGuards.loadActionToken contract end-to-end:
- **publicQuotes** (8 tests) — GET load + POST respond: 404 unknown,
400 malformed, 410 expired, 200 valid w/ sanitised payload (no
customer_account_id / created_by_admin_id leakage), 429 after 20
bad attempts (IP lockout), 400 invalid action.
- **publicContracts** (10 tests) — GET load + POST sign + POST
upload-signed-pdf + GET pdf: same guard outcomes per endpoint,
plus the pre-multer token check (malformed token rejected before
multer reads the body — prevents the disk-spam attack the
preMulterTokenGuard was added for).
- **publicPaymentCheck** (6 tests) — different shape (no
loadActionToken; service does its own validation): validator gate
on token shape, all 4 canonical actions pass through the
validator, negative amountMinor rejected.
The NULL-expires_at defensive branch in loadActionToken is
documented but not tested here — current schema declares
quote/contract_action_tokens.expires_at NOT NULL, so the branch is
unreachable at the route level. Worth a direct unit test on
loadActionToken if anyone wants to cover it.
### P1 + P2 — Admin routes (51% of new tests, 25 cases)
One consolidated `adminCrmAuth.test.js` file rather than nine
per-route files — the auth-gate contract is identical for every CRM
admin route, so a parametrised `describe.each` is more efficient
and lands the same coverage:
Per route (adminQuotes, adminContracts, adminInvoices, adminCalendar,
adminDeals, adminTaxReport, adminBusinessProfile):
- 401 without Authorization header (adminAuth gate)
- 401 with invalid JWT signature (adminAuth signature check)
- 2xx with super-admin token + CRM feature flags on (permission +
feature-flag gates both pass)
Plus 4 tests for the CRM additions in adminCustomers
(hour-entries / bill / trigger-monthly-bill) — those endpoints
are mixed in with pre-existing customer routes, so they get
explicit coverage rather than bulk via the parametrised sweep.
## Harness extensions to integration/helpers/crmDb.js
Three new helpers (one place for any future route test to find):
- `mintAdminToken(adminId, opts)` — JWT signed with the test
JWT_SECRET, shape matches what adminAuth expects.
- `createPublicToken(db, tableName, opts)` — insert a row into
quote/contract_action_tokens with controllable expires_at /
used_at / token. Note: Date values are explicitly ISO-stringified
before insert — bare Date objects round-tripped inconsistently
through knex+SQLite, sometimes via .toString() → literal
`"[object Object]"` which parsed back to NaN and silently defeated
the expiry guard. Caught it in test bring-up.
- `buildRouteApp(mount, router)` — minimal Express app (json + cookies)
with a catch-all error handler that mirrors middleware/errorHandler
(uses err.statusCode, not err.status — getting that wrong silently
maps every 4xx to 500 in tests).
- `assignAdminRole(db, adminId, roleName)` — promotes a seedMinimal
admin into super_admin (or any seeded role) for happy-path tests.
## Out of scope (follow-up)
Deeper integration tests for the document mint/send paths
(adminQuotes.send → PDF persisted + token minted + email queued;
adminInvoices.Storno → new row with shared deal_uuid + original
cancelled; adminContracts.countersign → integrity_hash computed)
are deferred. The service-layer behind those is already covered by
the existing __tests__/services/ suites — this PR pins the
HTTP-layer contract, which is what #570 actually asked for.
## Counts
- 4 new test files, 49 tests total
- ~860 LOC of test code + ~85 LOC of new harness in crmDb.js
- All tests pass in <2.5s (no real network, no real disk except the
per-test tmpdir, no email sending)
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.
Resolves a conflict with the CRM merge (#555) that landed on beta
between when this branch was cut and now.
Two conflict regions in backend/src/routes/adminCustomers.js:
1. **Require block** — both branches added new requires after
customerAccountsService. Kept both: this branch's
emailNormalization import AND beta's customerHoursService +
invoiceService imports (the CRM merge added the hours-billing +
invoice-creation paths to this router).
2. **Edit-customer validators** — both branches changed the same set
of body() validators in the PUT /:id handler. This branch added
the IDENTITY_PRESERVING_NORMALIZE_EMAIL options arg to
normalizeEmail; beta changed every body() to optional({ nullable:
true }) so passive-customer records that store nulls for missing
profile fields don't reject on save. Kept both: the nullable
pattern from beta + the email-normalization options from this
branch. Preserved beta's explanatory comment about the nullable
choice.
Also patched one NEW normalizeEmail site the CRM merge introduced:
- backend/src/routes/adminCustomers.js:231 — POST /admin/customers
now exists (CRM-era customer-create endpoint). Same options arg
applied.
backend/src/routes/adminBusinessProfile.js has an isEmail() WITHOUT
normalizeEmail() on the issuer email — intentional (no normalization
means no risk of the Gmail dot-strip bug for that field), no change
needed.
All 18 normalizeEmail sites now pass IDENTITY_PRESERVING_NORMALIZE_EMAIL.
7/7 regression tests still pass. Lint clean on the merged file.
When PR #555 (CRM module) added pdfkit/swissqrbill/pdf-lib/qrcode to
backend/package.json, every dev with an already-built dev image hit
a MODULE_NOT_FOUND restart loop on the next pull. Root cause: the dev
compose bakes node_modules into the image while live-mounting src/
from disk — a dep added on disk isn't visible to the running container
until the image is rebuilt.
The symptom doesn't point at the cause, so this adds a short rebuild
note to the Local Development section of CONTRIBUTING.md. A
self-healing entrypoint (compare node_modules/.package-lock.json
vs /app/package-lock.json on boot, npm ci if they differ) would fix
this at the runtime layer too; tracked as a follow-up.
PR #555 shipped the CRM module on beta. The README's "Beta Features
(Use at your own risk)" table is the right place to signal that the
feature exists, is opt-in, and carries non-trivial legal / financial
caveats — readers landing on the README should not first discover the
CRM by enabling its feature flags and bumping into the seeded
example contract bodies without warning.
Adds one row to the Beta Features table linking to
docs.picpeak.app/features/crm where the full disclaimers,
sub-feature pages, and admin-settings reference live.
CRM is intentionally NOT added to the top-of-README "Key Features"
list — those are stable, production-ready features. Mixing the beta
CRM in there would undermine the clear stable/beta distinction.
Closes#574.
Reporter (@blazmaric) identified the root cause cleanly:
express-validator's `.normalizeEmail()` applies provider-specific
canonicalization by default — Gmail dot-stripping, +tag stripping,
googlemail → gmail folding, etc. That's wrong for identity: PicPeak
uses email as a login identifier, so `john.doe@gmail.com` getting
silently stored as `johndoe@gmail.com` means the user can't log in
with the address they were invited with.
The bug existed at 17 call sites across the codebase (auth, admin user
create/update, customer create/update, event create/update on three
different routes, customer login, feedback submission). All of them
are identity-bearing — none had a legitimate reason to strip dots
for deduplication.
Fix: introduce one shared options object in `utils/emailNormalization`
disabling every provider-specific normalization
(gmail_remove_dots, gmail_remove_subaddress,
gmail_convert_googlemaildotcom, outlookdotcom_remove_subaddress,
yahoo_remove_subaddress, icloud_remove_subaddress). The only default
left enabled is `all_lowercase`, which is safe — local-parts are
case-insensitive in practice on every major provider, and lowercasing
keeps login lookup consistent.
Every call site updated to pass the shared options. 7 unit tests pin
the preserved-dots, preserved-subaddress, preserved-googlemail-domain,
and still-lowercase behaviours so a future refactor can't silently
regress.
## Migration note
Existing accounts whose emails were already stripped before this fix
remain with the stripped form in the DB. The fix takes effect for new
invitations going forward. If an admin re-invites an existing user
with the un-stripped address, that would create a duplicate account —
out of scope here; if it becomes a real problem we can add a
backward-compat login fallback (try lookup with dot-stripped form too)
as a separate change.