Changelog
Development history and test coverage. For feature overview, see features.md.
2026-06-11 — Subscription presentation resolver + UI polish
- Shared presentation resolver
lib/subscription/subscription-view.ts—resolveSubscriptionState(status, nowMs)trả discriminated union (freeAccess | none | active | trialing | trialEnding | trialEnded | pastDue | canceled | expired). Toàn bộ precedence (free-access thắng expired, ngưỡng trial-ending 7d…) nằm 1 chỗ có test riêng (9 cases).SubscriptionBanner.resolveBanner+OwnerSubscriptionContentgiờ chỉ map state → surface, không tự derive — đóng class bug "thêm billingExempt phải nhớ sửa nhiều nơi". Banner enforcement (isTenantBookingBlocked, BE) không đổi. - FreeAccess wording neutral: "Your salon…" → "Your business…" (đa industry-ready, không hardcode salon).
- Ẩn 2 payment provider "Coming soon" (Vipps MobilePay + Worldline Direct) khỏi
/admin/settings→PAYMENT_PROVIDER_ORDER = ['BAMBORA'](chỉ provider đã ship). Comment + test giữ để khôi phục dễ. - Gỡ cảnh báo "card authorizations only hold 7 days" trong Booking policy editor — hệ thống giờ charge thẳng (instant capture), không hold auth nữa → cảnh báo lỗi thời. Xóa render + biến
showDepositLeadTimeWarning/maxBookingDays+ i18n key chết (en/nb). - Web lint + build + vitest (resolver 9 + banner 7 + provider 4 = 20) green.
2026-06-11 — Subscription UI: unified banner + free-access (billingExempt) honored everywhere
Polish + đóng lỗ hổng billingExempt cho luồng subscription owner-facing.
- Banner restyle:
SubscriptionBannertừ dải đỏ full-width → card bo góc mềm (tint theo tone: error=rose, warning=amber, info=brand), icon + title đậm + body + giữ nút action. Mirror styleAlertcủa trang Subscription. Thêm i18nsubscription.banner.title.*(5 state × en/nb). - Dashboard đồng bộ: gỡ
SubscriptionStatusCard(xóa file, dead code) khỏi dashboard, bỏ/adminkhỏiSUPPRESS_ON→ dùng chínhSubscriptionBanner(qua admin layout) cho mọi trang trừ/admin/subscription. Một component, một look. - Free access (billingExempt) honored ([[feedback_no_fallback_settings]]-style gap fix): trước đây
GET /subscriptionchỉ đọc Subscription row (status=EXPIRED) → super-admin cấp Free access (Gift toggle) nhưng banner + trang Subscription vẫn báo expired. Fix:SubscriptionStatusView+ DTO thêmbillingExempt(getStatus đọc thêmTenant.billingExemptqua prisma); banner ẩn khi exempt; trang Subscription hiệnFreeAccessCard(success, "Free access — no subscription needed") thay PlanPicker/expiry. OpenAPI + types regen. - Audit "free access bỏ qua mọi rào cản": xác nhận MỌI gate subscription (admin
BillingGuardwrite + public booking create +acceptingBookings+ marketplacelistableTenantWhere) đều route quaisTenantBookingBlocked(checkbillingExemptđầu tiên) → exempt tenant không bị chặn ở bất kỳ đâu, vẫn listed trên marketplace. Không có gate nào khác. - Tests: manage service spec (+1 billingExempt-over-EXPIRED, getStatus shape +billingExempt). Subscription suite 272 pass; api + web lint/build green.
2026-06-11 — Public booking gated by tenant subscription (lapsed salon = neutral "not accepting bookings")
Khi subscription của salon hết hạn (EXPIRED / trial-ended), trang booking công khai phải phản ánh đúng — KHÔNG để khách đặt lịch vào salon không trả phí. Research industry (Square: hủy sub → "online booking website disabled"; Fresha: payment fail → access paused, hủy → unlist khỏi marketplace) → chốt: message trung tính cho khách (không lộ lý do billing), unlist khỏi marketplace nhưng vẫn vào được qua direct link.
- Predicate dùng chung
shared/billing/is-tenant-booking-blocked.ts:isTenantBookingBlocked({subscriptionStatus, trialEndsAt, billingExempt})— block khi EXPIRED hoặc ACTIVE-mà-trial-đã-hết, trừbillingExempt; PAST_DUE/CANCELED vẫn mở (soft).BillingGuardrefactor để dùng đúng predicate này → admin write-guard + public booking một nguồn sự thật, không drift. KèmlistableTenantWhere()(Prisma where, phần bù) cho marketplace. - Backend enforce 3 điểm:
GET /public/tenants/:slugthêm field trung tínhacceptingBookings(KHÔNG expose raw status);POST .../bookingschặn → 409TENANT_NOT_ACCEPTING_BOOKINGS(last line of defense, không chỉ dựa UI);listTenantssearch +FeaturedTenantService.listPublicFeaturedlọc qualistableTenantWhere()→ unlist salon lapsed khỏi featured + search (direct link vẫn resolve). - Frontend:
BookingsUnavailableBanner(client, trung tính, amber, "Online booking unavailable / contact directly") dùng ở cả landing/b/[slug]lẫn stepper. Landing:bookingDisabled = closure || !acceptingBookings→ disable Book + tooltip; banner ưu tiên closure (có ngày mở lại) rồi tới subscription. StepperBookingFlowV2: banner trên cùng +StepperCtadisable Continue/Confirm/Pay khi!tenant.acceptingBookings. i18n en/nb (publicBooking.bookingsUnavailable.*+ errorTENANT_NOT_ACCEPTING_BOOKINGS). - Tests (+12): predicate spec (7 branch + listableWhere shape = 8) + public-booking controller (+4: getTenant acceptingBookings true/false, createBooking block EXPIRED / pass billingExempt). BillingGuard spec vẫn xanh (refactor behavior-preserving). Full api suite 2326 pass. gitnexus impact
canActivate= LOW.
2026-06-11 — Subscription re-subscribe after full expiry (reactivation = new row)
Đóng nốt gap cuối của Epic 12: tenant đã EXPIRED hoàn toàn (trial hết hạn không trả, hoặc paid sub hết hạn) muốn quay lại đóng tiền dùng tiếp. Trước đó kẹt ở 2 chỗ — UI luôn hiện current-sub card (Manage/Cancel), không bao giờ về PlanPicker; và webhook subscription.created đập vào row EXPIRED (terminal) → InvalidSubscriptionStateTransitionError/SubscriptionAlreadyExistsError.
- Backend (
SyncFromWebhookHandler.resolveExisting): khi row mới nhất theo tenant là EXPIRED (terminal) và event làsubscription.created→ coi như miss →executematerialise row Subscription mới (createdFromCheckout, UUID mới) thay vì mutate row terminal. Đúng invariant domain "reactivation = append-only new row, never mutate EXPIRED" (đã ghi sẵn trongsubscription-status-transitions.ts+ comment aggregate). Schema cho phép vì@@unique([provider, providerSubId])(KHÔNG unique trên tenantId) vàfindByTenantIdđãorderBy createdAt desc(latest wins). Nhánh đặt trước guardSubscriptionAlreadyExistsErrorđể row paid-expired (providerSubId cũ ≠ mới) cũng reactivate được. Row mới sync Tenant read-model về ACTIVE → mở khóaBillingGuard. Replay idempotent: created lặp lại với providerSubId mới khớp row mới quafindByProviderSubId(self-loop ACTIVE→ACTIVE), không tạo trùng. - Frontend (
OwnerSubscriptionContent): gate đổi từhasSubscriptionsanghasSubscription && status !== 'EXPIRED'→ EXPIRED rơi vàoPlanPicker(cùng surface với tenant chưa từng subscribe). ThêmexpiredNoticeAlert (warning) phía trên grid giải thích lapse + i18n en/nb. Checkout controller đã@AllowWithoutSubscription()nên EXPIRED gọi/subscription/checkoutkhông bị 402. - KHÔNG trial lần 2: tenant đã dùng hết 14d trial rồi EXPIRED → reactivate charge ngay, không cấp trial mới. Backend
remainingTrialDays()đọc row mới nhất (EXPIRED,trialEndsAtquá khứ) → 0 → Polar charge ngay; thêmorderBy createdAt descchofindFirst(khớp "latest wins", tránh pick row cũ sau reactivation). FrontendPlanCardnhậnnoTrial(= expired) → ẩn "14-day free trial included" + CTA đổi "Start 14-day free trial" → "Subscribe". +2 checkout tests (no-second-trial + orderBy assert). - Tests (+4):
sync-from-webhook.handler.spec(+2) — created trên EXPIRED-trial row (providerSubId null) → row mới; created trên EXPIRED-paid row (providerSubId cũ) → row mới, KHÔNG throw AlreadyExists.subscription-checkout.service.spec(+2) — re-subscribe sau expiry → trialDays 0; findFirst dùngorderBy createdAt desc. 271 subscription tests pass; web lint + build green. gitnexus impactresolveExisting= LOW (chỉ callerexecute).
2026-06-11 — Subscription trial-deferral + cancel-trial + honest errors + auth/UX fixes (live-verified P4)
Sau khi P4 live-verified (real Polar sandbox checkout owner5 / test card 4242 → webhook subscription.created → Subscription tạo đúng, current_period_end = Polar's truth), một loạt fix hoàn thiện flow subscribe khi đang trial + sửa lỗi auth chặn đường Back-from-Polar.
Backend (booking-api):
- Trial-deferral — subscribe giữa kỳ trial = không charge ngay, charge lúc trial end.
polar.adapter.createCheckoutSessiontruyềnallowTrial + trialInterval:'day' + trialIntervalCount = số ngày trial còn lại(override product);SubscriptionCheckoutService.remainingTrialDays()=ceil((trialEndsAt - now)/day), 0 nếu không phải pure-trial; thêmCreateCheckoutParams.trialDays?. Verified live: Polar hiện "N days free, charge", không double-trial. DEFER[polar-trial-conversion]: event-mapper còn hardcodetrialEndsAt:null→ sub đã convert hiện "active/renews" thay vì "trialing" (charge vẫn đúng theo Polar truth, chỉ sai label). - cancel-trial —
Subscription.cancelTrial(now)setcancelAtPeriodEnd=truenhưng giữ status ACTIVE (expiry-sweep vẫn expire đúng tạitrialEndsAt, không lệch read-model);applyCreatednay clearcancelAtPeriodEnd/canceledAt(mở đường re-subscribe).SubscriptionManageService.cancel()rẽ nhánh: trial (không cóproviderSubId→cancelTrial+ save) vs paid (adapter.cancelSubscription). - Honest errors —
requireProviderSub→SubscriptionNotFoundError(404) nếu chưa có sub,SubscriptionNotManageableError(409, code mớiSUBSCRIPTION_NOT_MANAGEABLE) nếu đang trial (chưa cóproviderSubId) — trước đó ném nhầmPlatformBillingProviderNotAvailableError. Subscription filter map NOT_MANAGEABLE → 409. - Global-filter gap fix ([[feedback_domain_error_global_fallback]]) —
HttpExceptionFilterGlobalthiếu nhánhSubscriptionDomainError→ mọi subscription domain error rơi vào đây = 500. Thêm nhánh +SUBSCRIPTION_DOMAIN_STATUSmap (mirror subscription filter). Đây là lý do "Manage billing" lúc trial bị 500.
Auth fixes (security-relevant, cần re-login + clean restart):
- sameSite
strict→lax(auth/auth-cookies.ts) — đồng bộ với web proxy (lax). Cookiestrictbị giữ lại khi cross-site Back-from-Polar → proxy không thấy token → bounce về/admin/signin. → user phải logout + login lại để lấy cookielaxmới. - bfcache infinite-spinner on Back-from-Polar (confirmed bfcache: chạy được khi mở DevTools hoặc incognito, fail bình thường). Fix:
next.config.ts headers()ép/admin/:path*→Cache-Control: no-store+proxy.tsno-store trên/admin+AuthContextpageshow.persisted→ reload fallback. Yêu cầurm -rf booking-web/.next+ restart web (proxy/next.config không HMR).
Frontend (booking-web):
- "Subscribe with {provider}" (extensible per-gateway):
SubscriptionStatusDto.provider(OpenAPI + types regen),PROVIDER_LABELmap (POLAR → "Polar.sh"). Single active provider → 1 button. - subscribe-during-trial UX — đang trial hiện "Subscribe now / Subscribe with Polar.sh" (checkout cùng planKey) thay vì chỉ Manage/Cancel.
- optimistic cancel (
useCancelSubscription) — setcancelAtPeriodEnd=truengay + delayed (3s) invalidate (provider cancel chỉ flip local qua webhook, lag 1–2s → invalidate ngay đọc stale ACTIVE). - Dashboard
SubscriptionStatusCard(mới, OWNER, tone-tinted, hiện trial days/renew) + banner dedup (SubscriptionBannerẩn trên/admin+/admin/subscription— đã có inline status); CTA card dùngButton variant=primary size=sm(đồng bộ "Manage billing").AppLogoonError→ fallback tile.OwnerSubscriptionContent"Trial" badge khiisTrialing, buttonssize="sm", ConfirmDialog dùng label ngắn ("Yes, cancel"/"Keep").
2026-06-11 — Subscription reconciliation sweep (S8 drift safety net)
Lưới an toàn cho pipeline webhook: nếu một delivery bị miss (provider outage / downtime / bug 500 inbox), local read-model lệch Polar — ca nguy hiểm là Polar đã canceled/expired mà local vẫn ACTIVE → BillingGuard cho ghi miễn phí. Thiết kế đầy đủ: docs/architecture/subscription-architecture.md §13a.
Cron subscription-reconcile (BullMQ repeatable, mỗi 6h, batch 100) poll provider cho các sub đã link provider, chưa terminal (providerSubId IS NOT NULL, status ∈ ACTIVE/PAST_DUE/CANCELED; EXPIRED terminal + trial thuần do subscription-expiry lo).
ReconcileSubscriptionsService.reconcile():repo.findReconcilable(100)→ mỗi sub gọiadapter.reconcileSubscription?(providerSubId, {tenantId, planKey})→ Polarsubscriptions.get→ normalize status → buildDomainSubscriptionEvent(updated/canceled/payment_failed/expired). Drift check rẻ (status + cancelAtPeriodEnd khớp → skip, không write/event); chỉ lệch mới apply qua đúngSyncFromWebhookHandler(một code path với webhook thật). Per-row isolation.reconcileSubscription= method OPTIONAL trênBillingProviderPort— adapter không poll được (LemonSqueezy) thì bỏ qua → sweep skip; không bắt LS implement.planKeylấy từ local row (reconcile không đổi plan, chỉ status/cancel).- Infra (mirror
subscription-expiry):reconcile.constants.ts+ReconcileSubscriptionsQueue(self-heal nếu Redis down lúc boot,triggerReconcileNow()cho ops/test) +ReconcileSubscriptionsProcessor. RepofindReconcilable(order updatedAt asc). Đăng ký queue + BullBoard trongSubscriptionModule. - Tests (+11):
reconcile-subscriptions.service.spec(drift apply / in-sync skip / cancel-flag drift / no-adapter skip / unmappable skip / error isolation) +polar.adapter.spec(active→updated, cancel→canceled, ended→expired, past_due→payment_failed, incomplete→null). booking-api 267 subscription tests pass, lint clean.
2026-06-10 — Subscription Phase D: trial-on-signup + enforcement (D1–D7)
Gating subscription 100% từ hệ thống mình, Polar chỉ là payment rail. Plan đầy đủ: docs/architecture/subscription-enforcement-plan.md. Quyết định chốt lúc impl: block status 402, TRIAL_DAYS=14 hardcode, cron sweep build (hourly), D6 = grandfather (không migration data).
D1 — Trial-on-signup (booking-api): auth.service.registerOwner ghi outbox event tenant.registered (auth/events/tenant-registered.event.ts) trong cùng tx tạo Tenant → OnTenantRegisteredListener (EventBus) → SubscriptionTrialService.startTrial: tạo Subscription.startTrial (status ACTIVE, trialEndsAt=+14d, providerSubId=null, provider = active PlatformBillingConfig, planKey = DEFAULT_TRIAL_PLAN_KEY) + sync Tenant read-model. Idempotent (skip nếu đã có sub); fail-open grandfather nếu chưa cấu hình provider. AuthModule import OutboxModule.
D2 — BillingGuard (core/subscription/interface/guards/billing.guard.ts): global APP_GUARD (sau JwtAuthGuard) secure-by-default, chỉ chặn WRITE (POST/PUT/PATCH/DELETE). Đọc Tenant read-model (subscriptionStatus/trialEndsAt/billingExempt) — không join. Allow: GET, @Public()/@AllowWithoutSubscription() (shared/billing/), unauthenticated, ADMIN, impersonation, billingExempt, ACTIVE+paid/trialing, PAST_DUE, CANCELED. Block (402 SUBSCRIPTION_REQUIRED): ACTIVE+trial-lapsed, EXPIRED. Trial-lapse tính on-the-fly. Decorator gắn cho SubscriptionCheckoutController + AuthController.
D3 — billingExempt (super-admin grant free): Tenant.billingExempt + migration 20260610000000_add_tenant_billing_exempt. PATCH /superadmin/tenants/:id/billing-exempt (ADMIN, audit-log). Web: toggle Gift-icon + ConfirmDialog + badge "Free access" trên SuperadminTenantsContent; hook useSetBillingExempt.
D4 — status + UX: GET /subscription thêm isTrialing. Web SubscriptionBanner (admin layout, OWNER): trialEnding (≤7d countdown) / trialEnded / expired / pastDue / canceled + CTA. errors.SUBSCRIPTION_REQUIRED i18n (en+nb) cho toast khi mutation bị chặn.
D5 — cron sweep: ExpireTrialsService + BullMQ repeatable queue subscription-expiry (hourly) flip ACTIVE-trial-lapsed → EXPIRED + sync read-model. Repo findLapsedTrials, aggregate Subscription.expireTrial(now) (event subscription.trial_expired).
D6 — grandfather: tenant cũ (ACTIVE + trialEndsAt=null) → guard coi là paid, không bao giờ chặn. Không migration data.
D7 — tests: +unit cho SubscriptionTrialService, BillingGuard (mọi nhánh matrix + bypass), OnTenantRegisteredListener, ExpireTrialsService, Subscription.expireTrial, SuperadminService.setBillingExempt + controller, auth.service outbox emit, web resolveBanner (vitest). booking-api 2291 unit pass (4 fail còn lại = soft-delete.extension.spec.ts cần DB thật, không liên quan). Web lint + build (React Compiler) pass. OpenAPI + types regen.
Plan catalog align + bỏ boot-seed plans (cùng ngày): thêm premium_1_month (flat NOK 95, mirror DB) vào plan-catalog.ts hardcoded + đổi DEFAULT_TRIAL_PLAN_KEY solo_monthly→premium_1_month (trial + sync read-model + webhook sync premium giờ resolve đúng, hết warn findPlan=null). Gỡ hẳn PlansSeederService + plans.constants.ts (boot không còn seed 4 plan cũ Solo/Pro/Enterprise + 26 feature) — plans giờ tạo thủ công qua Plans CMS admin (Polar MVP = 1 plan premium_1_month tạo tay). PlansModule chỉ còn CRUD + GET /public/plans. Tránh prod tự sinh plan thừa. Pricing view tương lai = static HTML.
2026-06-10 — Subscription owner flow: checkout + portal + cancel (Phase A + B)
Owner-facing self-serve billing — trước đây createCheckoutSession của adapter không có caller, PricingPage CTA là "coming soon". Giờ owner subscribe / quản lý / huỷ được end-to-end.
Phase A — Checkout (booking-api): POST /subscription/checkout {planKey} → {checkoutUrl} (thin — chính webhook mới tạo Subscription qua Subscription.createdFromCheckout). SubscriptionCheckoutService resolve active provider → adapter, fetch owner email qua prisma, lấy origin từ request host (redirect đúng cho custom-domain). DTO + controller (interface/owner/), gắn @AllowWithoutSubscription(). Web: nav riêng "Subscription" (Sparkles icon, OWNER) → /admin/subscription, list public self-serve plans + nút "Start 14-day free trial" → checkout → redirect Polar; hooks usePublicPlans + useCreateSubscriptionCheckout; i18n en/nb.
Phase B — Status + Portal + Cancel (booking-api): SubscriptionManageService (findByTenantId read-model; portal/cancel chọn adapter theo sub.provider; cancel = adapter.cancelSubscription(providerSubId, true), local cancelAtPeriodEnd flip qua webhook — không double-write) + 3 endpoint trên cùng subscription controller: GET /subscription (status view), GET /subscription/portal (returnUrl từ request host), POST /subscription/cancel (204). DTO SubscriptionStatusDto / CustomerPortalResultDto. Web: OwnerSubscriptionContent hiện current-sub card (badge ACTIVE/PAST_DUE/CANCELED/EXPIRED + renews/cancels/trial + "Manage billing" → portal + "Cancel" → ConfirmDialog) khi đã subscribe, plan picker khi chưa; hooks useSubscriptionStatus / useCustomerPortal / useCancelSubscription. 224 api unit tests, lint + tsc clean.
2026-06-10 — UI: ModalHeader tái dùng + button-size sweep
Sửa 2 bug UI lặp lại (X-button overlap subtitle + button to bất thường) phát hiện khi build các modal billing/plan.
ModalHeader (@/components/ui/modal/ModalHeader): title + subtitle trái, X phải (filled gray circle bg-gray-100 h-9 w-9, khớp TenantEditModal), border-b. LUÔN đi kèm showCloseButton={false} trên <Modal> (X mặc định absolute right-6 top-6 đè lên full-width subtitle). Refactor: PlanEditModal, BillingSection (CreateConfig / EditConfig / PlanMappings).
Button size sweep: list-page "Add/New" + modal footer = size="sm" (mặc định md quá cao). Fix New plan, Add script, footer PlanEditModal. docs/rules/ui-conventions.md §9 (gỡ fake lg, thêm rule) + §5 (ModalHeader chính thức) đồng bộ.
2026-06-08 — Billing: ẩn LemonSqueezy + nền tảng Polar.sh (P1 + P2)
Chuyển hướng platform billing từ LemonSqueezy (khó verify) sang Polar.sh. MVP scope rút gọn còn 1 plan "Premium" · monthly · flat/salon. Plan đầy đủ: docs/architecture/polar-integration-plan.md.
Ẩn LemonSqueezy (booking-web/PlatformSettingsContent.tsx): gỡ tab Billing (chỗ duy nhất cấu hình LS, hardcode LEMONSQUEEZY) khỏi nav desktop+mobile + chặn ?tab=billing (fallback branding). Giữ nguyên BillingSection + toàn bộ backend LS (unreachable từ UI). Khôi phục = thêm lại 'billing' vào TABS/VALID_TABS + re-import CreditCard.
Polar P1 — schema (booking-api): thêm POLAR vào enum BillingProvider + migration 20260608000000_add_polar_billing_provider (ALTER TYPE … ADD VALUE). parseProvider sẵn nhận mọi enum value → /webhooks/subscription/polar tự hoạt động.
Polar P2 — adapter (booking-api/.../infrastructure/providers/polar/): polar.config.ts, polar.signature.ts (Standard Webhooks qua standardwebhooks), polar.event-mapper.ts (map subscription.* → 7 domain event, mirror LS), polar.adapter.ts (đủ 6 method BillingProviderPort dùng @polar-sh/sdk@0.48.0, seat-based ready). Đăng ký PolarAdapter trong subscription.module.ts cạnh LS — active config row quyết định provider nào live. Build + lint (0 errors) pass. gitnexus impact LOW.
Polar MCP (sandbox) đã thêm vào Claude Code (~/.claude.json, cần auth). P3–P6 (config wiring, sandbox webhook verify, FE un-hide + provider POLAR, tests) pending.
2026-06-08 — Hover preview ảnh service ở stepper + show-more description ở salon landing
Booking stepper (ServiceCard.tsx): hover vào thumbnail service có ảnh → hiện popover phóng to, tái dùng đúng ImageHoverPreview của trang landing (portal ra body, ưu tiên phải/fallback trái, delay 250ms, clamp viewport, pointer-events:none nên click vẫn toggle chọn service). ImageHoverPreview thêm prop optional className (merge cn()) để wrapper span giữ shrink-0/mt-1 khi thành flex item — non-breaking, impact LOW.
Salon landing (ServiceList.tsx): description đồng bộ với stepper — clamp 3 dòng + nút Show more/less (tái dùng ExpandableText, key i18n có sẵn publicBooking.stepper.showMore/showLess). Row đổi items-center → items-start để thumbnail + nút Book căn trên khi description dài.
Frontend-only. gitnexus detect_changes MEDIUM (chạm đúng file feature). Lint + build pass.
2026-06-08 — Service description: expand/collapse ở booking stepper + textarea giới hạn 200 từ ở admin
Booking stepper (ServiceCard.tsx): description trước cắt cứng 1 dòng (line-clamp-1) → giờ clamp 3 dòng, nếu tràn hiện nút Show more / Show less (i18n stepper.showMore/showLess). Tách component tái dùng ExpandableText (components/ui/) đo overflow thật qua useLayoutEffect + ResizeObserver (chỉ hiện nút khi text thực sự vượt clamp, đúng cả khi đổi viewport). Card đổi items-center → items-start và thumbnail thêm mt-1 để ảnh + check căn trên đầu khi card cao nhiều dòng (ServiceThumb/ServiceThumbFallback nhận prop className qua cn()).
Admin service form (ServiceFormModal.tsx): field Description từ <input> 1 dòng → textarea (TextAreaField mới, components/form/, mirror FormField: useController + dịch lỗi namespace validation, render <textarea rows=4>). Giới hạn 200 từ với bộ đếm trực tiếp n/200 words (đỏ khi vượt) + Zod .refine(countWords <= 200, 'maxWords') chặn submit trước khi gọi API. Helper countWords() thêm vào lib/utils.ts. i18n: validation.maxWords, common.words.
Frontend-only. gitnexus impact LOW (ServiceCard 0 caller) / detect_changes MEDIUM (chạm đúng file feature). Lint + build pass.
2026-06-04 — Custom domain footer: thêm dòng copyright
Footer custom-domain (CustomDomainFooter) trước chỉ hiện "Powered by Novago JSC"; theo yêu cầu thêm dòng copyright © {year} {brandName}. All rights reserved. phía trên (giống block trái footer platform). Lấy brandName qua getPlatformSettingsServer() + i18n footer.rights. Bên phải vẫn locale/theme switcher, vẫn bỏ cột nav platform. Lint + build pass.
2026-06-04 — Custom domain: header logo salon + footer riêng
Trên custom domain (mysalon.com) salon LÀ site nên chrome platform (logo GLAMVOO + cột nav) không hợp.
Header (CustomerHeader.tsx): thêm prop salon (name/logoKey/primaryColor). Khi có → render SalonAvatar (logo salon, fallback chữ cái đầu) + tên salon thay cho AppLogo (GLAMVOO). Platform host / chưa resolve được branding → vẫn fallback AppLogo.
Footer (CustomDomainFooter.tsx mới): footer tối giản chỉ "Powered by Novago JSC" + language/theme switcher — bỏ cột nav (Product/Company/Legal), logo, dòng copyright (thuộc glamvoo.com).
Layout (layout.tsx): trên custom domain resolve slug (x-tenant-slug, fallback resolveTenantSlug(host) cho trang platform-global) → getTenant() lấy branding → truyền salon vào header; isCustomDomain ? <CustomDomainFooter/> : <footer full>. Fetch lỗi → degrade về platform brand, không crash. gitnexus impact LOW. Lint + build pass.
2026-06-03 — Fix: bookings mới không hiện ở /account sau khi đặt (cache stale)
Bug: sau khi đặt booking xong, vào /account?tab=bookings phải F5 mới thấy booking mới. Nguyên nhân: global staleTime: 30s (QueryContext) — quay lại tab trong vòng 30s thì query ["customer","bookings"] chưa stale nên React Query phục vụ cache cũ, không refetch (flow tạo booking ở public không invalidate key này).
Fix (BookingsSection.tsx): thêm refetchOnMount: "always" cho query bookings — "my bookings" là history view mở ngay sau khi đặt nên luôn fetch tươi khi mount, bất kể staleTime. Robust cho mọi đường tạo booking (guest→login, retry, cancel nơi khác). Lint+build pass.
2026-06-03 — Fix: 2 header trên custom domain sau khi login (OAuth redirect)
Bug: trên custom domain, sau khi login (server-side Google OAuth) hiện 2 header (platform GLAMVOO chrome chồng lên salon chrome ở trang /book), phải F5 mới hết. Nguyên nhân: proxy chỉ gắn x-custom-domain cho salon-scoped path (/, /book, /bookings); trang trung gian /account/auth/callback (platform-global) không được gắn → server render với isCustomDomain=false, rồi router.replace('/book') (client-nav, không reload) giữ nguyên false → CustomerHeader dùng pattern /b/<slug>/book (không khớp /book) nên không ẩn.
Fix (proxy.ts): gắn x-custom-domain cho mọi request trên custom domain, kể cả platform-global (x-custom-domain là thuộc tính của host, không phải path). nextWithPathname thêm optional param customDomainHost. Không cần F5 nữa. Không đổi giao diện trang /account (pattern header không khớp). Lint+build pass.
2026-06-03 — Salon landing: tab "All" cho danh sách services
Thêm tab "All" (nb "Alle") đầu tiên + default trong ServiceList ở /b/[slug] — gộp services của mọi category vào 1 list để khách duyệt nhanh không cần lọc. Hành vi "See all" theo hybrid: tab All giữ cap 5 + nút "See all" (list gộp dễ dài); vào từng category thì hiện hết (list đã bounded, bỏ click thừa). i18n publicBooking.allCategory. Tab bar vẫn chỉ hiện khi salon có >1 category. Frontend-only, lint+build+vitest pass.
2026-06-03 — Google login: chuyển hẳn sang server-side redirect (bỏ broker popup)
Vấn đề: trên custom domain, /account/login render GIS inline → lỗi (origin không nằm trong Google authorized JS origins); trang book chạy nhờ broker popup nhưng 2 click + dễ bị popup blocker. Broker (P8, 2026-06-02) cũng phải wire thủ công từng nút → dễ sót.
Giải pháp: thay GIS/broker bằng OAuth 2.0 Authorization Code flow server-side (validate redirect_uri thay vì JS origin) → 1 click, không popup, chạy giống hệt ở platform lẫn mọi custom domain, chỉ 1 redirect URI trên Google Console.
Backend (booking-api):
- 3 endpoint mới trên
auth/customer:GET google/start(302 → Google,stateký HMAC{returnOrigin,next,nonce}TTL 5m),GET google/callback(verify state →getToken(code)→ verify id_token → find-or-create → mint ticket → 302 về<returnOrigin>/account/auth/callback),POST google/complete(ticket → Set-Cookie host-only). - Model
CustomerAuthTicket(single-use, hash +usedAtatomic claim + TTL 2m) — migration20260603090000_add_customer_auth_ticket. findOrCreateFromGoogletách dùng chung;POST google(native id_token, mobile) giữ nguyên.TenantDomainService.isAllowedAuthOriginvalidate returnOrigin = platform | ACTIVE domain (anti open-redirect).- Env mới:
GOOGLE_CLIENT_SECRET,GOOGLE_OAUTH_REDIRECT_URI,PLATFORM_ORIGIN. 10 e2e (start/callback/complete + replay + expired + tampered-state), all pass.
Frontend (booking-web):
lib/customer-google-auth.ts(startGoogleLogin/currentReturnPath) +components/auth/GoogleSignInButtondùng chung choCustomerLoginModal+/account/login.- Route mới
/account/auth/callback(exchange ticket → set context →router.replace(next)). CustomerAuthContext:loginWithGoogle(idToken)→completeGoogleLogin(ticket).- Xoá hẳn broker:
lib/google-oauth-broker.ts(+test),app/oauth/broker/, i18noauthBroker.*, dep@react-oauth/google, envNEXT_PUBLIC_GOOGLE_CLIENT_ID/NEXT_PUBLIC_PLATFORM_ORIGIN. Lint + build + 227 vitest pass.
Operator trước khi deploy: thêm redirect URI https://glamvoo.com/api/auth/customer/google/callback (+ dev) vào Google Console; set 3 env api; apply migration; redeploy api+web. Xem custom-domain.md §9.
2026-06-02 — Fix: custom domain 404 cho mọi trang ngoài salon subtree
Bug prod: trên custom domain, proxy rewrite mọi path thành /b/<slug>/..., nhưng dưới /b/[slug] chỉ có / (landing), /book, /bookings/*. Nên mọi trang platform-global (/account, /terms, /privacy, /help, /contact, /pricing, /about, /oauth/broker) → 404 (footer links + nút Login → /account đều dính).
Fix: thêm allowlist isSalonScopedPath() (lib/custom-domain.ts) — chỉ salon subtree (/, /book, /bookings/*) mới rewrite vào /b/<slug>; trang platform-global render thẳng trên custom host (same-origin, cookie host-only vẫn dùng được). proxy.ts handleCustomDomain thêm 1 nhánh pass-through. 16 unit test mới. Verify end-to-end qua Host-header: /account /terms /privacy /pricing 404 → 200; / + /book giữ nguyên.
Login button trên trang book: nút Login/avatar giờ xuất hiện ở StickyBookingHeader (góc phải) — khách đăng nhập giữa flow để prefill + xem loyalty. Tách logic login/avatar dropdown thành component dùng chung CustomerAuthControl (TenantTopBar landing + StickyBookingHeader book cùng dùng, bỏ ~90 dòng trùng).
2026-06-02 — V2 stepper: time picker inline + section titles
Step "Chọn thời gian" giờ xổ sẵn dạng grid (kiểu Fresha) thay vì dropdown phải click. TimeSlotGrid thêm prop inline (grid auto-fill minmax(5.5rem,1fr) ~7 cột, nút h-11 to/dễ bấm) + prop ariaLabel (caller tự render heading). Dropdown variant giữ nguyên cho V1 1-page (mỗi service 1 picker). Step3DateTimePicker thêm 2 section title "Choose a date" + "Choose a time" (text-lg semibold, padding thoáng) bọc trong <section>; BookingSummary heading nâng lên text-lg cho đồng bộ. Frontend-only, web build + 222 vitest pass.
2026-06-02 — Tenant setting: bật/tắt step chọn staff (allowStaffSelection)
Owner giờ có thể tắt step "Choose professional" trong booking flow. Khi tắt, V2 stepper bỏ hẳn step staff (còn 3 bước: services → time → confirm) và mọi dịch vụ mặc định "Any available" (unassigned). Dùng cho salon không cho khách chọn nhân viên — salon tự phân công.
Setting mới allowStaffSelection: boolean (mặc định true, giữ nguyên hành vi cũ). Chỉ hợp lệ cùng bookingMode: 'allow_unassigned' — tắt staff selection ép mọi booking thành unassigned, mâu thuẫn với assigned_only.
Backend (booking-api):
TenantSettingsinterface +BEAUTY_SETTINGS/BARBERSHOP_SETTINGSdefaults +seed.tsliteral.TenantSettingsDto.allowStaffSelection(@IsBoolean @IsOptional).- Strict parser
parseTenantSettings: thêm vàoREQUIRED_KEYS+ bool-check (no-fallback) → backfill migration20260602041500_backfill_tenant_allow_staff_selection(jsonbdefaults || existing, default true, idempotent — ship CÙNG commit theo rule validator-backfill-pair). validateSettingsCombination: rejectallowStaffSelection=false+bookingMode=assigned_only→TENANT_SETTINGS_STAFF_SELECTION_REQUIRES_UNASSIGNED.sanitizeSettings(public payload) expose flag.
Frontend (booking-web):
PublicTenantSettings.allowStaffSelection+ booking-policy zod schema (+superRefineguard) +DEFAULT_BOOKING_POLICY+policyFromTenantSettings.BookingPolicyEditor: SwitchField mới (khoá ON khiassigned_only; đổi mode sangassigned_onlytự bật lại để không tạo combo lỗi). i18n nb/en + error-code message.BookingFlowState.getStepOrder(settings)— step order động (bỏ 'staff' khi tắt); thread quafurthestReachableStep,BookingFlowV2(activeStep/index/goNext/goBack/goToStep + forceresourceId=null), contextstepOrder,StepperHeader(counter "N/3" + progress segments).
Tests: API 2199 unit pass (validation guard + parser + fixtures cập nhật cho key mới). Web 222 pass (getStepOrder + 3-step order). API e2e public-booking-conflict 22/22 (exercise field end-to-end). Đồng thời vá drift pre-existing: hoàn thiện settings fixtures thiếu key (maxBookingDaysInAdvance/deposit/temporaryClose/emailNotifications) + thêm ThrottlerStorage override cho public-booking/customer-auth e2e (đỏ sẵn trên main, không liên quan feature).
2026-06-02 — P8: Google OAuth broker trên custom domain
Customer giờ login Google được trên custom domain (salon.novagoo.com). Trước đó Google Identity Services từ chối render nút sign-in vì JavaScript origin của custom domain chưa nằm trong allow-list của client ID (không thể đăng ký mọi domain khách). Guest booking vẫn chạy nên vấn đề này defer được khỏi P1–P7.
Cách giải — broker qua platform origin (frontend-only, không đụng Google config, không đụng backend):
- Login modal phát hiện đang ở custom domain (
shouldUseBroker) → render nút "Continue with Google" mở popup tớiglamvoo.com/oauth/broker?return=<origin>thay vì GIS inline. - Broker page (chạy trên platform = origin đã authorized) validate
returnhost là domain ACTIVE (GET /public/domains/resolve) → render<GoogleLogin>→ lấy idToken →postMessagevề opener vớitargetOrigin= chính return origin đã validate. - Custom domain nhận message (chỉ chấp nhận
event.origin === platformOrigin) → gọi/api/auth/customer/googlecủa chính nó (same-origin qua Next proxy) →Set-Cookiehost-only trên custom domain → logged in.
Hai lớp validate origin: broker→opener (chống leak idToken sang origin lạ) + opener→broker (chống nhận token từ window lạ).
Tại sao backend 0 thay đổi: /auth/customer/google verify idToken theo audience = GOOGLE_CLIENT_ID, không quan tâm origin; cookie host-only + sameSite=strict vẫn đúng vì request là same-site.
Files (booking-web):
lib/google-oauth-broker.ts—shouldUseBroker(host),getPlatformOrigin(),loginViaGoogleBroker(): Promise<idToken>(popup + postMessage, rejectpopup_blocked/popup_closed).app/oauth/broker/page.tsx— broker page; parsereturnthuần trong lazy initializer, validate ACTIVE qua effect (async setState, React-Compiler clean).CustomerLoginModal.tsx— nhánhneedsBroker.messages/{nb,en}.json— namespaceoauthBroker.- Env:
NEXT_PUBLIC_PLATFORM_ORIGIN(mặc địnhhttps://glamvoo.com).
Tests: lib/google-oauth-broker.test.ts — 11 tests (shouldUseBroker normalize, popup_blocked reject, resolve-credential với origin validation). lint + typecheck + next build pass.
Deploy: set NEXT_PUBLIC_PLATFORM_ORIGIN=https://glamvoo.com ở build web rồi redeploy để live. Google Console không cần thêm gì (custom domain không bao giờ chạm Google trực tiếp).
2026-05-29 — Booking draft / shareable cart (?sessionId=) cho V2 stepper
Khách book V2 giờ có giỏ hàng lưu server-side: F5 giữ nguyên toàn bộ lựa chọn (service + staff + date + time + voucher), và link ?sessionId= chia sẻ được (kiểu giỏ hàng e-commerce). Trước đó ?services= chỉ giữ services; staff/time mất khi reload. Nền tảng cho remarketing/abandoned-cart/add-on sau này. Test plan + kịch bản: docs/testing/v2-booking-conflict-scenarios.md.
Privacy: draft chia sẻ được nên KHÔNG lưu PII người tạo (tên/điện thoại/email/notes) — chỉ lưu selection. Người mở link tự điền thông tin ở Step 4. Slot/time luôn re-check lúc submit (checkConflict + BOOKING_START_TIME_IN_PAST).
Backend (booking-api):
- Model
BookingDraft(booking_drafts):tenantId/customerIdscalar (no relation — ephemeral),itemsJson[{serviceId,resourceId|null}],selectedDate,selectedStartTime,voucherCode,expiresAt. Index[tenantId]+[expiresAt]. Migrationadd_booking_draft(bảng mới, no backfill). BookingDraftService(CLOCK-injected):create/get/update. TTL 7 ngày (BOOKING_DRAFT_TTL_MS), refresh mỗi lần ghi. Validate mọi service thuộc tenant + active, mọi resource bookable-online →DRAFT_INVALID_ITEMS. Lazy-expiry on read (DRAFT_EXPIRED410) + opportunisticdeleteMany(expired)on create. Cron BullMQ dọn draft defer (bật khi volume/remarketing tăng — copy patternpayment-expiry).- 3 endpoint public:
POST/GET/PATCH /public/tenants/:slug/bookings/draft[/:id](@OptionalCustomerAuth, throttle 20/—/60 per 60s). ScopetenantIdmọi query.UpsertBookingDraftDto.
Frontend (booking-web):
booking-api.ts:createDraft/getDraft/updateDraft+ types;public-api.ts(server) thêmgetDraft+PublicBookingDraftDto.page.tsx: hydrate?sessionId=quabuildFromDraft(resolve serviceId→service, drop inactive, coerce resource invalid→null, expired→empty). PrecedencesessionId>from>services>serviceId. sessionId + items → land Step Time (slot re-validate visibly).BookingFlowV2.tsx: statesessionId+selectedStartTimeinit từ draft; debounced persist effect (600ms) trên signature{items(service+resource),date,time,voucher}— chưa có sessionId →createDraft→ ghi?sessionId=(replaceState); có rồi →updateDraft; cart rỗng → gỡ?sessionId. Best-effort (lỗi save không block booking).?services=giữ nguyên (F5-safe đồng bộ cho services); draft là nguồn giàu hơn, thắng khi hydrate.StepperHeader: nút "Copy booking link" (icon,canSharekhi sessionId tồn tại) → clipboard + toast. i18npublicBooking.stepper.shareLink/linkCopied/linkCopyFailed(en/nb).
Tests: booking-draft.service.spec (11: TTL 7d, expiry-on-read, tenant isolation, validate inactive service/resource, update refresh TTL), public-booking.controller.spec +mock provider, test/public-booking-draft.e2e-spec.ts (7: round-trip create→get full selection, no-PII, PATCH overwrite, inactive/empty → DRAFT_INVALID_ITEMS, DRAFT_NOT_FOUND, tenant isolation). 2152 unit pass + 7 draft e2e + web build clean + lint 0. OpenAPI spec regen (2 draft routes).
2026-05-29 — Customer booking V2 Stepper (industry-standard 4-step) — replaces V2 1-page
Khách đặt lịch online giờ đi qua stepper 4 bước thay vì 1-page. Thay thế hoàn toàn BookingPageV2 (1-page, ~610 LOC) bằng flow Services → Staff → Date+Time → Confirm, mobile-first, lazy-fetch chain availability. V1 (BookingPage) giữ nguyên cho bookingUiVersion='v1'. Plan: docs/plans/customer-booking-stepper-v2.md.
Backend (booking-api):
AvailabilityService.findNextAvailableDate()— scan tiến từfromDate+1tớimaxDaysAhead(default 30, clamp 90), reusegetChainedSlots(), dừng ở ngày đầu tiên có slot; trả{ date: string | null }. Powers nút "Jump to next available date".POST /public/tenants/:slug/availability/next-available-date(NextAvailableDateQueryDto/ResponseDto, HttpCode 200,@Public).- Helper
addDaysToDateString()trongtimezone.util.ts(calendar arithmetic UTC-anchored, DST-safe).
Frontend (booking-web, book/components-v2/):
BookingFlowState.ts— pure state machine:canAdvanceTo(),furthestReachableStep()(clamp deep-link),parseStep().assigned_onlygate enforce staff trước Time.BookingFlowV2.tsx— orchestrator: tất cả state + handlers + submit; nav quahistory.replaceState(URL?step=shareable/F5-safe, không server round-trip); active step luôn clamp về furthest reachable (self-heal stale URL / empty cart).BookingFlowContextchia state cho steps/header/footer/summary.- Steps:
Step1ServicesPicker(search + ServiceCard toggle),Step2StaffSelector(per-service StaffOption list, "Any" mặc định, strict-skill fetch),Step3DateTimePicker(DateStrip +useChainAvailabilityhook + TimeSlotGrid +EmptyDayBanner),Step4ConfirmDetails(BookingSummary + RHF details + provider-aware Pay/Confirm CTA). - Shared:
StepperHeader(progress bar tappable + counter),StepperFooter(sticky total + Continue, steps 1-3),ServiceCard,StaffOption,EmptyDayBanner(jump-to-next-date CTA),BookingSummary. page.tsxroute V2 →BookingFlowV2; explicit?step=luôn thắng (link salon page →?serviceId=X&step=servicesland bước Services với service chọn sẵn); nếu không có step thì infer:?from=→ Time,?serviceId=→ Staff.ServiceList"Book" link thêm&step=services.- Desktop layout: 2-cột (content
lg:col-span-2+BookingSummarysidebar stickylg:col-span-1, hiện mọi bước), footer Continue/Pay full-width 100vw (fixed inset-x-0 bottom-0, innermax-w-[1440px]). Mobile: 1 cột, sidebar ẩn, summary inline ở Step4, footer sticky. CTA confirm (Pay/Confirm) gom vào footer chung. - Shareable cart qua URL
?services=<codes>(URL = single source of truth): thêmService.shortCode(6-char base36, opaque, không hiển thị, gen on-create với collision retry; partial unique indexWHERE deleted_at IS NULLđể soft-deleted nhả code; backfill existing rows bằngmd5(id)substr). PublicgetServicesexposeshortCode. Stepper sync?services=code1,code2vào URL mỗi khi cart đổi (replaceState, thay?serviceIdlegacy).page.tsxresolve codes → preSelectedItems (order-preserving, dedupe, skip unknown). ServiceList "Book" link dùngservices=<code>. → share link tự select đủ services; F5 khôi phục services từ URL; quay lại salon page rồi book lại = cart sạch (không có draft để resurrect). Comma ghi literal trong URL (replaceUrlKeepingCommasun-escape%2C→?services=a,b,ccho gọn). Staff/ngày/giờ là transient (chọn lại sau reload). - KHÔNG dùng sessionStorage: bản nháp trước có thử lưu draft vào sessionStorage + detect reload bằng
PerformanceNavigationTiming, nhưng SPA client-nav sau khi F5 ở trang khác vẫn đọc type='reload' → resurrect cart cũ sai. Bỏ hẳn — URL gánh toàn bộ persistence của services. - Tên staff trong summary:
BookingFlowItem.resourceNamecache lúc chọn staff (Step2) → hiển thị dưới service name trongBookingSummary+ChainPreview(trong phiên; reset sau F5 vì staff không vào URL). Step2 staff đổi sang dropdown/service (SearchSelect) cho gọn khi nhiều staff × nhiều service; desktop nút Continue/Pay nằm dưới summary (sidebar), mobile giữ footer sticky. - FIX core: chain conflict per-leg (
booking.service.ts).create/updatetrước coi multi-service là song song —endTime = start + max(duration)+checkConflictdùng cả booking envelope cho MỌI item — nên DISAGREE vớigetChainedSlots(chain tuần tự): slot được offer rồi bị reject với falseBOOKING_CONFLICT(vd leg cuối của staff X check nhầm với envelope thay vì leg thật). Helper mớifillChainedStartTimes(bookingStart, items)(documented đầy đủ) chain item back-to-back khi thiếu per-item startTime (V2 stepper); V1/admin gửi startTime rõ ràng → respect nguyên si (no-op, data-driven, không cần cờ V1/V2). Conflict check +endTime+ persistBookingItem.startTimegiờ dùng leg thật từng item → khớp 100% với availability; calendar render đúng từng service. V1 single-service không đổi; V1 multi-service từ over-conservative (envelope) → precise (per-leg). Tests: +1 regression (per-leg window) + sửa multi-service test sang chain tuần tự (endTime=sum). 396 booking/public-booking/service tests pass. - Xóa:
BookingPageV2.tsx,ServiceItemV2.tsx,ChainTimePicker.tsx(logic chuyển vàouseChainAvailability).ChainPreview.tsxgiữ (reuse Step3). - i18n: namespace
publicBooking.stepper(15 keys × en/nb).
Tests: backend +8 (availability.service.spec 5 next-available-date cases + controller spec 3 delegation) — booking-api Jest pass, lint 0/build clean, OpenAPI+types regen. Frontend: BookingFlowState.test.ts 13 vitest pass (state machine), e2e/public-booking-stepper.spec.ts 4 Playwright pass (happy path → confirm CTA, back-nav preserves selection, progress-bar jump, ?serviceId= deep-link). Verified end-to-end qua Playwright (4 steps render, chain slots load, 0 console errors).
Deferred: morning/afternoon slot grouping (TimeSlotGrid flat hiện đủ); jump-to-next-date e2e (seed non-deterministic — cover bằng backend unit tests); browser back-button giữa steps (dùng replaceState, in-app Back chevron là primary nav).
⚠️ Legacy e2e/public-booking.spec.ts target V1 selectors (per-item time picker, date-strip-day ở step đầu) → sẽ fail dưới bookingUiVersion='v2'. Cần migrate sang stepper nav hoặc pin v1 — chưa làm trong commit này.
2026-05-28 — Plans CMS PC3: super-admin CRUD + edit UI
Plans bây giờ edit được qua UI. Trước commit này (PC1+PC2+PC4+PC5 shipped 2026-05-27), Plans + features chỉ seed-once-at-boot, edit qua SQL. Mở route /admin/superadmin/plans cho ADMIN với list + create + edit modal + delete (với subscriber guard).
API (booking-api/src/core/plans/):
PlansServicethêm 5 method admin:listAdmin(gồm hidden +subscriberCounttừ Subscription groupBy single query),getAdmin,listAdminFeatures(master feature catalog),create(validate planKey format + XOR pricing + duplicate guard),update(planKey immutable — chỉdisplayName/tagline/pricing/visibility/sortOrder/trialDays/provider ids),replaceFeatures(atomic delete+createMany trong $transaction, validate featureKeys tồn tại trước khi mutate),remove(block 409 khisubscriberCount > 0— operator phải flipisPublic=falseđể retire instead).AdminPlansController@Roles('ADMIN')6 endpoints:GET /superadmin/plans(list),GET /superadmin/plans/:id(detail),GET /superadmin/plans/features(master catalog),POST /superadmin/plans(create),PATCH /superadmin/plans/:id(update),PUT /superadmin/plans/:id/features(replace memberships),DELETE /superadmin/plans/:id. Full class-validator DTOs (CreatePlanDto/UpdatePlanDto/ReplacePlanFeaturesDto) với@Typecho nested +@ArrayUniquecho feature membership keys +@Matchesregex cho planKey format.
Frontend (booking-web):
- Hook
useAdminPlans.ts— 6 hooks (list / detail / features / create / update / replaceFeatures / remove). Invalidate cả admin list + detail + public['plans', 'public']key cho pricing page nhìn thấy edit ngay. - Route
/admin/superadmin/plans/page.tsxmount component mới. SuperadminPlansContent.tsx— table với 7 columns (name, planKey mono, price formatted Intl, visibility badge, subscriber count, sortOrder, actions). Sort theosortOrderasc. New plan button → modal create. Row click name / pencil icon → modal edit. DeleteButton (đã có sẵn) với confirm dialog; disabled khisubscriberCount > 0+ tooltip hint "flip Public off to retire". Skeleton loading state + empty state.PlanEditModal.tsx— 2 tab modal:- Basics: planKey (read-only on edit, hint "Immutable after create"), name + name_nb, tagline + tagline_nb, billing cycle select (monthly/yearly), currency 3-char, trial days, pricing radio (
flat/per_seat/custom) với conditional input cho flat hoặc per-seat (major-unit input, lib chuyển sang minor), visibility toggle (isPublic + selfServeCheckout), sortOrder, provider variant IDs (collapsible<details>cho LS / Stripe / Paddle). - Features: master catalog grouped by category, checkboxes; "Snart" badge cho roadmap features; featureKey mono trailing cho debug. Disabled tab khi create (cần planId trước khi insert junction). Save independently from Basics (2 separate mutations).
- Basics: planKey (read-only on edit, hint "Immutable after create"), name + name_nb, tagline + tagline_nb, billing cycle select (monthly/yearly), currency 3-char, trial days, pricing radio (
- Sidebar entry
superadminPlansvới Tag icon + nb/en translations (32 keys/locale gồm column labels, modal field labels, category labels, pricing mode, toast messages).
Tests (+8): service spec extended với CRUD cases (create validates planKey format, XOR pricing, duplicate guard, persists + records updatedBy; update NotFoundException; remove blocks at subscriber>0, allows at 0; replaceFeatures rejects unknown featureKeys trước khi $transaction). Full Jest: 2124 pass / 2 skipped / 2126 total (was 2116 post PC1-5). 0 lint errors trong subscription/plans modules; nest build + web BUILD_SKIP_TYPECHECK=1 yarn build clean — /admin/superadmin/plans route compiled dynamic. OpenAPI regen + FE types regen.
UI quality details:
- DeleteButton wrapped trong
<span title>để tooltip hover-explain disabled reason — DeleteButton component không accepttitleprop, không refactor để giữ surface nhỏ. - Button component là
defaultexport (khôngnamed) → 2 import sites adjusted. - Modal max-h 70vh + overflow-y-auto cho body để feature list dài không vỡ viewport.
Deferred (PC6 + PC7):
- PC6 LS adapter refactor — đọc
Plan.lsVariantIdthay vìPlatformBillingPlanMapping(S2.5 parallel table). Admin nhập variant ID qua modal nhưng adapter chưa consume. - PC7 Playwright admin flow test + drag-drop sortOrder UI (hiện chỉ edit qua sortOrder field).
2026-05-27 — Plans CMS PC1 + PC2-min + PC4 + PC5: pricing API + public /pricing page
Plans → DB + public marketplace pricing page. Trước commit này, plan catalog hardcode trong plan-catalog.ts TS constants — admin không edit được pricing/features mà không deploy. Route /pricing thì dùng CMS content text generic, không phải pricing matrix. User yêu cầu API + UI hiển thị plans trước S5 (BillingGuard) để stakeholders đánh giá pricing.
Schema (migration 20260527034239_add_plans_cms). 3 model mới:
Plan— 21 fields gồm planKey UNIQUE (stable identifier —Subscription.planKeyreference giữ nguyên qua mọi rename displayName), billingCycle (monthly/yearly), bilingual displayName/tagline/description (en+nb), flatPriceMinor XOR pricePerSeatMinor (asserted ở service), currency CHAR(3) default NOK, selfServeCheckout (Enterprise=false), isPublic + sortOrder, trialDays override, provider variant id placeholders (lsVariantId / stripePriceId / paddlePlanId — wire ở PC6).PlanFeature— master feature catalog. featureKey UNIQUE, bilingual displayName/description, category enum-as-string (bookings/customers/marketing/branding/payments/reports/integrations/support), isRoadmap flag (dim trên pricing page), valueType (boolean/number/string — MVP boolean only), iconKey (Lucide), sortOrder per category.PlanFeatureOnPlan— junction Plan × Feature. Boolean features = row presence; numeric features store stringified value (parse runtime).
Seeder (core/plans/plans-seeder.service.ts + plans.constants.ts). 4 plans (Solo 199 / Pro Monthly 149-per-seat / Pro Yearly 1490-per-seat / Enterprise contact-sales) + 27 features. Pricing model giữ current Subscription decision (KHÔNG đổi sang Pattern A 2026-05-20 flat-per-location vì Subscription.planKey reference giữ stable). Audit shipped vs roadmap theo memory 2026-05-20: 19 shipped + 8 roadmap (SMS notifications, multi-location infrastructure features không gateable bằng flag, loyalty points L4+, marketing automation, custom domain, advanced analytics, CSV export, REST API). onModuleInit upsert với update:{} — admin edits sticky qua restart, chỉ fresh DB mới pull từ constants. Junction reconcile qua createMany skipDuplicates — admin add bonus features qua PC3 (chưa ship) seeder không undo.
Service (PlansService.listPublic — read-only PC2 minimal). 2 queries song song (prisma.plan.findMany({where:{isPublic:true}, include:{features:{include:{feature:true}}}}) + prisma.planFeature.findMany({orderBy:[category,sortOrder]})). Trả {plans, featureCatalog} shape — FE comparison matrix iterate master catalog rồi cross-check plan.features.some(f.featureKey===...), đảm bảo render được mọi category row kể cả khi plan đó include 0 feature trong group. CRUD endpoints defer cho PC3 super-admin UI.
API (GET /public/plans — PC4). @Public() no-auth. Cache-Control: public, max-age=300, stale-while-revalidate=60 (5 phút staleness chấp nhận được — pricing thay đổi rất hiếm, traffic thấp; revalidate-on-write từ admin edit sẽ wire khi PC3 ship). Full DTO swagger annotations → openapi-typescript emit clean string union cho FE types.
Frontend /pricing rewrite (booking-web/src/app/(customer)/(info)/pricing/). Bỏ InfoPageContent CMS text. Build server component mới: hero (eyebrow + heading + subheading) + responsive plan card grid (grid-cols-1 sm:grid-cols-2 lg:grid-cols-4) + horizontal-scroll feature comparison matrix grouped by category. Cards: name + tagline + price (Intl.NumberFormat per locale) + trial badge + 6 top features (sorted shipped-first then sortOrder) + disabled CTA ("Start trial" cho selfServe, "Talk to sales" cho Enterprise — disabled aria-disabled cho tới khi S6 admin upgrade flow ship). Matrix: ✓ shipped (emerald), — roadmap shipped (amber Minus icon + "Snart" badge), — not included (gray dash). Bilingual via getLocale() + getTranslations("staticPages.pricing") — 32 i18n keys per locale gồm category labels, CTA strings, trial plural, badge text. SSR direct fetch qua lib/plans-server.ts (mirror content-pages-server.ts pattern) — cache: 'no-store' để API cache header rules; fallback InfoComingSoon khi API down → graceful degrade.
Tests (+6): service spec 3 cases (junction features map, query shape filter + order, billingCycle coerce); seeder spec 3 cases (every upsert preserves admin edits via update:{}, junction skipDuplicates:true, swallow prisma errors at boot). Full Jest: 2116 pass / 2 skipped / 2118 total (was 2110 post-S4).
Quality: lint 0 errors trong core/plans (10 pre-existing warnings module khác giữ nguyên). nest build clean. yarn generate:openapi rerun → GET /public/plans xuất hiện trong booking-api/openapi.json + PublicPlansResponseDto schema có sẵn cho FE typegen. BUILD_SKIP_TYPECHECK=1 yarn build web pass — /pricing route compiled dynamic.
Deferred (next phases):
- PC3 super-admin CRUD UI (
/admin/superadmin/plans— drag-drop sortOrder, feature matrix checkboxes, provider variant test buttons). - PC6 LS adapter refactor — drop env-based
LEMONSQUEEZY_VARIANT_*lookup, readPlan.lsVariantIdtừ DB row. Adapter hiện tại vẫn dùngPlatformBillingPlanMapping(S2.5) parallel với Plan table — không gãy. - Subscription S5 BillingGuard + seat-limit policy.
- Playwright pricing page snapshot test (deferred to PC7).
2026-05-27 — Subscription S4: aggregate + SyncFromWebhookHandler + Tenant read-model sync
Đóng kín S3 → state: webhook đi qua SubscriptionWebhookIngestService giờ đã load (hoặc tạo mới) Subscription aggregate, apply event, persist, sync Tenant.subscriptionStatus / trialEndsAt / currentPlanKey / seatLimit, rồi mark inbox processedAt. Pipeline LS đã end-to-end (chưa ship checkout UI nên không có data thực — nhưng provider POST hợp lệ vào endpoint sẽ flow đúng đến DB).
Domain layer:
domain/value-objects/subscription-id.ts— UUID-v4 VO wrap để không nhầm Tenant.id / providerSubId.domain/subscription-status-transitions.ts— pure transition matrix. ACTIVE/PAST_DUE/CANCELED tự do, EXPIRED terminal (chỉ self-loop). Self-loop ở mọi state để webhook replay idempotent về mặt status.listAllowedNextStates(from)cho debug UI sau này.domain/subscription.ts—Subscriptionaggregate (~370 LOC). Mutable inner state, immutable id/tenantId/provider exposed as public readonly. 7apply*methods (1 perDomainSubscriptionEventvariant) + 2 factories (startTrial,createdFromCheckout) +rehydrate(snapshot). Mỗi apply method: tenant-match guard → status transition guard → state mutate →recordEvent(domain event ghi vào pending list).applyCreatedreject mismatchproviderSubId(existing aggregate đã bound provider sub khác → bug).applyUpdatedpermissive: chấp nhận plan/seat change in-place, flip CANCELED khicancelAtPeriodEnd=true, reverse về ACTIVE khicancelAtPeriodEnd=false(owner un-cancels qua portal).applyCanceledsplit CANCELED vs EXPIRED tùycancelAtPeriodEndflag.applyExpiredforce EXPIRED + updatecurrentPeriodEnd = expiredAt.pullPendingEvents()clear-on-read để handler dispatch ngược ra outbox/event bus về sau.domain/errors.ts— 4 errors mới:SUBSCRIPTION_NOT_FOUND(404),SUBSCRIPTION_INVALID_STATE_TRANSITION(409),SUBSCRIPTION_TENANT_MISMATCH(409),SUBSCRIPTION_ALREADY_EXISTS(409). FilterSubscriptionDomainErrorFiltermap sẵn 4 mã mới.
Application layer:
application/ports/subscription-repository.port.ts— 4 methods:save(upsert),findById,findByTenantId(latest by createdAt — multi-row history sau khi expire+reactivate),findByProviderSubId. DI tokenSUBSCRIPTION_REPOSITORY.application/ports/tenant-billing-read-model.port.ts— 1 methodsync(tenantId, snapshot). Port riêng (không call PrismaService trực tiếp trong handler) để tests stub được + cho phép Tenant context flip sang event-listener pattern sau (subscribesubscription.*events, update read model async).application/commands/sync-from-webhook.handler.ts— main handler. Resolution order: (1)findByProviderSubId(post-checkout common case) — nếu match tenant mismatch → throwSubscriptionTenantMismatchError; (2)findByTenantId— nếu local row đã bound providerSubId khác mà event làsubscription.created→ throwSubscriptionAlreadyExistsError; (3) không match +subscription.created→Subscription.createdFromCheckoutmaterialise mới; (4) không match + event khác → log warn, returnsubscriptionId:''(handler không throw — S8 cron sẽ replay). Sau khi save, gọitenantReadModel.sync()vớiseatLimitderived từfindPlan(planKey)?.seatLimit ?? null(plan removed from catalog → keep null, log warn — không block ingest path). Return{ subscriptionId, created }.
Infrastructure layer:
infrastructure/persistence/subscription.mapper.ts—toSubscription(row)rehydrate,toPersistencePayload(snapshot)strip id/tenantId/provider/createdAt cho update path (immutable fields).metadatacast quaPrisma.InputJsonValue.infrastructure/persistence/prisma-subscription.repository.ts—savedùngfindUnique({select:{id:true}})→ quyết định create vs update path. Tránh Prismaupsertvì create branch cần thêm fields (tenantId, provider, createdAt) mà update không touch — conditional split rõ ràng hơn.findByTenantIdordercreatedAt descđể pick latest row khi tenant có history (expire + reactivate = new row, never mutate).infrastructure/persistence/prisma-tenant-billing-read-model.ts— directprisma.tenant.update({where:{id},data:{...4 fields}}). Không route qua Tenant service vì surface tiny + tránh import cycle giữacore/tenant↔core/subscription.
S3 → S4 wiring (application/subscription-webhook-ingest.service.ts):
- Constructor inject thêm
SyncFromWebhookHandler. - Flow mới: insertIfAbsent → if dedup → return early; if ignored event →
markProcessedimmediately, no dispatch; if domain event →syncHandler.execute({event, now})synchronous, thenmarkProcessed+attachSubscriptionId(skip nếu handler return empty subscriptionId — non-createdevent arrived before aggregate exists). Handler throws →markFailed+ rethrow → provider retry; UNIQUE inbox collapse trên retry. - Synchronous dispatch (không enqueue BullMQ) đủ cho subscription throughput thấp (≤ vài event/giờ/tenant); nếu sau này latency thành vấn đề, flip sang BullMQ same as
payment-webhook.processor.ts.
Module: 3 providers mới (SUBSCRIPTION_REPOSITORY, TENANT_BILLING_READ_MODEL, SyncFromWebhookHandler) + 2 exports (SUBSCRIPTION_REPOSITORY cho S5 BillingGuard, các port khác giữ internal).
Tests (+38, total +61 từ trước S3):
- Domain: transition matrix 5 cases, aggregate 14 cases (factories 3, applyCreated 3, applyRenewed 1, applyUpdated 2, payment failed/recovered 2, canceled/expired 3, isInTrial 2 sub-cases).
- Repository: 6 cases (create vs update path, findById, findByTenantId order, findByProviderSubId scope, null returns).
- Handler: 8 cases (materialise on no-row, apply on trial, route by providerSubId, skip non-created when no aggregate, tenant mismatch, already-exists guard, unknown plan keeps seatLimit=null, solo plan derives seatLimit=1).
- Ingest service: refactored 4 existing tests + 2 new (handler crash markFailed + rethrow, empty subscriptionId skips attachSubscriptionId).
- Full Jest: 2110 passed / 2 skipped / 2112 total (was 2072 post-S3). 0 lint errors trong subscription module.
nest buildclean.
Deferred (S5–S9 còn lại):
- StartTrialCommand +
OnTenantOnboardedListener(S5 phase —TenantOnboardedevent chưa emit từ onboarding flow). - CreateCheckoutCommand + GetPortalUrl query (S6 admin UI).
- Cancel / SyncSeats / Reactivate commands (S6 + S5).
DunningPolicy.assess()(S5 BillingGuard).- E2E test double-fire + load test (S9 cần live LS sandbox).
2026-05-27 — Subscription S3: Webhook controller + inbox idempotency
Đóng kín pipeline LS webhook: provider POST /webhooks/subscription/:provider → adapter verify signature → persist SubscriptionWebhookInbox (UNIQUE collapse retries) → 200 ack. Không publish domain event yet (S4 sẽ làm) — chỉ tạo seam idempotent + audit trail. Blocker mở khoá: live LS subscription giờ ack-able, S4 sẽ pick findUnprocessed() để drive aggregate.
Domain port refactor (domain/ports/billing-provider.port.ts). parseWebhook đổi return type từ DomainSubscriptionEvent | null → ParsedWebhook { providerEventId, eventName, payload, event }. Lý do: khi event_name là domain-ignored (LS subscription_paused), controller vẫn cần providerEventId để insert inbox row + audit. Cũ trả null mất luôn meta → không record được. Mới: throw rules giữ nguyên (signature → WebhookSignatureInvalidError → 401; payload xấu → WebhookPayloadInvalidError → 400; no active config → PlatformBillingProviderNotAvailableError → 503); ParsedWebhook.event=null báo ignored, mọi metadata khác vẫn surface. LS adapter cập nhật + thêm guard meta.event_name missing (trước throw từ mapper, giờ throw ngay trong adapter để giảm log noise).
Webhook inbox port + Prisma repo (application/ports/subscription-webhook-inbox-repository.port.ts + infrastructure/persistence/prisma-subscription-webhook-inbox.repository.ts). Mirror PaymentWebhookInbox pattern: insertIfAbsent swallow P2002 unique violation và return inserted=false (giả lập upsert mà không cần update path). 6 methods: insertIfAbsent / findById / findUnprocessed (verified=true, processedAt=null, asc) / markProcessed / markFailed / attachSubscriptionId (S4 sẽ wire khi resolve local Subscription.id). tenantId nullable on input — domain-ignored events không có custom_data.tenantId nên insert as null. UNIQUE composite key Prisma-default tên provider_providerEventId.
Ingest service (application/subscription-webhook-ingest.service.ts). 1 method ingest({ provider, rawBody, headers, signature }) → { ok, deduped, ignored, inboxId }. Flow: registry.get(provider) → adapter.parseWebhook (throws bubble lên filter) → inbox.insertIfAbsent. deduped = !inserted (retry hit existing row); ignored = parsed.event === null (paused/refunded). Signature on input field kept on inbox row for audit (later secret rotation reconciliation). Log 1 line per delivery, distinguish recorded vs deduped path để monitor catch double-fire ratio.
Controller (interface/webhooks/subscription-webhook.controller.ts). POST /webhooks/subscription/:provider @Public() (LS không có auth header), @HttpCode(200) (ack always 200 unless adapter throws), @Throttle({ ttl: 60_000, limit: 60 }) per-IP (match Payment surface, well above LS retry budget). Provider param case-insensitive, parse → BillingProvider enum, unknown → 404 NotFoundException. Raw body 1MB limit (match Payment), missing rawBody → bootstrap-misconfig error (NestFactory.create({ rawBody: true }) đã có sẵn từ Payment). Headers collected into Record<string,string|undefined> để forward sang adapter (LS dùng x-signature + optional x-event-id).
SubscriptionModule wired. New providers: SUBSCRIPTION_WEBHOOK_INBOX_REPOSITORY → PrismaSubscriptionWebhookInboxRepository, SubscriptionWebhookIngestService. New controller: SubscriptionWebhookController. Inbox repo + ingest service exported cho S4 worker sẽ inject. SubscriptionDomainErrorFilter (đã có từ S2.5) map domain errors → HTTP status, không thay đổi.
Tests (+23): repo spec 8 cases (insert OK, P2002 dedup, P2002 vanished defensive, rethrow non-unique, findById map, findUnprocessed where clause, markProcessed clear failure, markFailed without processedAt, attachSubscriptionId); ingest service spec 6 cases (happy → recorded, ignored event → null tenantId/ignored=true, dedup → no error, signature err bubbles, payload err bubbles, provider-not-registered err bubbles); controller spec 7 cases (forward to ingest, case-insensitive provider, unknown provider 404, missing rawBody throws, oversize 413, dedup/ignored flag pass-through, filter maps signature err → 401 envelope). LemonSqueezy adapter spec updated + 1 new case (meta.event_name missing → WebhookPayloadInvalidError). Registry spec mock updated to new ParsedWebhook shape.
Quality: lint 0 errors trong subscription module (10 pre-existing warnings ở các module khác giữ nguyên). nest build clean. Full Jest: 2072 passed / 2 skipped / 2074 total (was 2049 → +23 S3). yarn generate:openapi rerun → POST /webhooks/subscription/{provider} xuất hiện trong booking-api/openapi.json (FE generate:types pickup khi cần).
Deferred (S4): Domain aggregate Subscription.ts + state transitions; SyncFromWebhookHandler đọc unprocessed inbox rows, dispatch via EventBus, mark processed. BullMQ worker tương tự payment-webhook.processor.ts nếu cần async fan-out (in-process EventBus là default cho subscription vì throughput thấp hơn payment).
2026-05-26 — Subscription S2.5: DB-backed PlatformBillingConfig + super-admin UI
Move LemonSqueezy credentials từ .env xuống DB. Trước commit này, S1+S2 lưu API key / store ID / webhook secret / 3 variant IDs vào 5+3 env vars. Vấn đề: deploy mới mà thiếu env nào → app boot crash (cipher provider throws) hoặc subscription command fail tại runtime với generic error. User KPI rõ ràng: "Không có dữ liệu thì không có link để thanh toán subscription thôi" — deploy phải resilient, super-admin tự configure qua UI thay vì sửa env+restart.
Schema (migration 20260526072713). 2 model mới:
PlatformBillingConfig(per (provider, isTest), UNIQUE) — AES-256-GCM encrypted apiKey + webhookSecret (reusePAYMENT_ENCRYPTION_KEY— cùng cipher service của Payment context, không tách key để giảm rotation surface), plus public storeId / apiBaseUrl / storeDomain / displayName / keyVersion / lastHealthCheckAt/Status. Hard rule: only ONE row platform-wide cóisActive=true— enforced ở service layer (partial UNIQUE on bool không portable).PlatformBillingPlanMapping— 1:N với config,(configId, planKey)UNIQUE. Replaces envLEMONSQUEEZY_VARIANT_*(3 self-serve plans). Bulk replace atomically (deleteMany+createManytrong 1 tx).
Domain (core/subscription/domain/platform-billing-config.ts). Plain entity class — không extend AggregateRoot. Lý do: PlatformBillingConfig là platform-singleton (không thuộc tenant nào) nhưng DomainEvent.tenantId: string (non-null required). Thay vì hack sentinel value, drop event emission entirely cho platform-level state. Audit trail nếu cần sau có thể add PlatformBillingAuditLog table riêng (giống ImpersonationAuditLog). Methods: create / update / rotateCredentials / activate / deactivate / recordHealthCheck / decryptCredentials. VO PlatformBillingConfigId (UUID v4 validation). 7 domain errors (PLATFORM_BILLING_CONFIG_NOT_FOUND / DUPLICATE / ALREADY_ACTIVE / ALREADY_INACTIVE / EMPTY_CREDENTIALS / VARIANT_MAPPING_MISSING + BILLING_PROVIDER_NOT_AVAILABLE).
Repository + service (application/). PlatformBillingConfigRepositoryPort + PrismaPlatformBillingConfigRepository (8 methods: save, findById, findByProviderAndMode, findActive, listAll, delete, replacePlanMappings, listPlanMappings + fast-path findVariantId(configId, planKey)). PlatformBillingConfigService wraps repo + cipher + clock; activate(id) enforces singleton-active by deactivating every other ACTIVE row trong cùng call (idempotent — re-activate same row throws); helper getActiveWithCredentials() để adapter không cần inject cipher port riêng.
LemonSqueezy adapter refactor (infrastructure/providers/lemonsqueezy/lemonsqueezy.adapter.ts). Constructor inject PlatformBillingConfigService thay vì static LemonSqueezyConfig + LemonSqueezyClient. Helper resolveConfig() (lazy resolve mỗi method call) lấy active config qua service, decrypt creds, build runtime LemonSqueezyConfig + new client. Visible-for-testing clientFactory cho phép spec override mà giữ NestJS @Injectable() constructor 1-arg. Khi getActive() return null → throw PlatformBillingProviderNotAvailableError (graceful — filter map → 503 cho UI degrade gracefully). resolveProviderVariantId đọc từ DB PlatformBillingPlanMapping qua service.resolveActiveVariantId(planKey); parseWebhook lookup reverse variant→planKey từ listPlanMappings(configId). Drop lemonsqueezy.config.ts env reading + plan-mapping.config.ts entirely. LemonSqueezyModule xóa hẳn (folded vào SubscriptionModule) — adapter cần cả PlatformBillingConfigService + BILLING_PROVIDER_REGISTRY đều ở module cha, child→parent DI cycle khó break sạch; flat module sidestep được.
Super-admin controller (interface/admin/platform-billing-config.controller.ts). 10 endpoints dưới /superadmin/billing-configs, @Roles('ADMIN') class-level: GET / (list) / GET /:id (detail + plan mappings) / POST / (create with duplicate guard) / PUT /:id (update non-creds) / POST /:id/rotate-credentials / POST /:id/activate (singleton-enforced) / POST /:id/deactivate / POST /:id/health-check (gọi adapter.healthCheck() + record status) / DELETE /:id / GET PUT /:id/plan-mappings (bulk replace). DTOs với class-validator + class-transformer @Type(() => Nested) cho credentials. @ApiOperation + @ApiOkResponse đầy đủ trên mọi endpoint → frontend types regen ra string union thay vì Record<string, never>. SubscriptionDomainErrorFilter (APP_FILTER) map 12 codes → HTTP status, gồm BILLING_PROVIDER_NOT_AVAILABLE → 503 (graceful degrade signal).
SubscriptionModule re-enabled trong app.module.ts (đã commented out từ S2 ship 2026-05-20 vì env-based config risk). Module skeleton fold tất cả providers vào 1 chỗ: cipher (reuse AesGcmCipher), repo (Prisma impl), service, billing provider registry, LemonSqueezyAdapter. onModuleInit() register adapter vào registry — luôn register (không depend env). OutboxModule import để có CLOCK provider. AuthModule import để controller có JwtAuthGuard + RolesGuard.
generate:openapi switched từ ts-node sang nest build && node dist/scripts/generate-openapi.js. Node 24 ESM loader nghiêm hơn → ts-node fail với ERR_REQUIRE_CYCLE_MODULE khi import AppModule. tsx im lặng exit 1 không error. Compiled JS chạy clean; cp dist/openapi.json → openapi.json ở repo root cho generate:types nuốt.
Side fix: SuperadminActivityKind DTO. @ApiProperty({ example: '...' }) mà không có enum field → swagger không infer được enum từ type alias (chỉ infer được từ class hoặc explicit enum). openapi-typescript fall back Record<string, never> → web typecheck break trên cast event.kind as ActivityKind. Fix: thêm enum: SUPERADMIN_ACTIVITY_KINDS as const, enumName: 'SuperadminActivityKind' → openapi.json giờ emit enum: ["BOOKING_CREATED","PAYMENT_CAPTURED","TENANT_CREATED"] → openapi-typescript emit string union sạch. Unrelated to subscription nhưng exposed bởi regen.
Frontend: Platform Settings tab refactor (/admin/superadmin/settings). User UX feedback: "phần Platform settings a cũng muốn làm dạng tab này, để sau này mở rộng được nhiều settings khác nữa". Refactor wrapper PlatformSettingsContent.tsx mirror tenant SettingsContent.tsx — desktop sidebar 3 tabs (Branding / Subscription / Billing) + mobile bottom nav, content area phải, ?tab= URL param. Old PlatformSettingsForm.tsx extract logic xuống settings/BrandingSection.tsx (Wrapper/Inner pattern giữ nguyên — key={data.updatedAt} để form mount fresh khi server snapshot đổi). settings/SubscriptionSection.tsx = placeholder "Coming soon" card (Plans CMS epic riêng, sẽ fill sau).
settings/BillingSection.tsx — surface CRUD đầy đủ:
- List PlatformBillingConfig cards với 3 badge (Active/Inactive · Test/Production · last health-check OK/Failed) + grid metadata (provider / storeId / keyVersion / lastCheck / optional apiBaseUrl + storeDomain mono font)
- Empty state khi
configs.length === 0(CTA "Add provider") + warning banner amber khiconfigs.length > 0 && !hasActive("No active billing provider — tenants attempting checkout get 503") để super-admin không bị im lặng failure - Modal Create: provider fixed = LEMONSQUEEZY (Stripe/Paddle là stub), Test toggle, storeId / apiBaseUrl / storeDomain / displayName optional, credentials block (apiKey + webhookSecret với show/hide eye toggle qua
SecretFieldwrapper) - Modal Edit: chỉ non-credential fields (force user dùng Rotate cho credentials)
- Modal Rotate: amber warning "After saving, previous credentials become invalid immediately" + fresh apiKey + webhookSecret
- Modal Plan Mappings: 3 input rows cho
SELF_SERVE_PLANS = ['solo_monthly', 'pro_monthly_per_seat', 'pro_yearly_per_seat'](enterprise admin-only, no mapping), empty value = "unmapped" với hint vềPLATFORM_BILLING_VARIANT_MAPPING_MISSING - Inline actions: Activate ↔ Deactivate (toggle), Health-check (lights up badge sau khi run), Edit, Rotate, Plan Mappings, Delete (ConfirmDialog với danger variant + bold name)
Hook useSuperadminBillingConfig.ts — 9 method (1 list query + 1 detail query với enabled: Boolean(id) gate + 7 mutations). Invalidates list + detail trên mọi write (invalidateAll(qc, id) helper).
Side fix: Button component. Trước đây không có type prop → render <button> không có attribute, mặc định type="submit" trong form context. Cancel buttons trong modal mới sẽ accidentally submit form. Add type?: 'button' | 'submit' | 'reset' (default 'button') — explicit submit buttons phải dùng SubmitButton (đã có sẵn type="submit"). Existing callers unchanged.
i18n: 52 keys mới mỗi locale (en + nb). superadmin.settings.menu.{branding,subscription,billing} cho tab labels, tabSubtitle.* cho mô tả dưới page header, subscription.{placeholderTitle, placeholderDescription, comingSoon} cho placeholder card, billing.* cho toàn bộ section (subtitle, addProvider, emptyTitle/Description, noActiveTitle/Body, providerLabel, storeIdLabel, apiBaseUrlLabel, storeDomainLabel, displayNameLabel, keyVersionLabel, lastHealthCheckLabel, isTestLabel/Hint, apiKeyLabel, webhookSecretLabel, credentialsHeader/Hint, providerFixedHint, statusActive/Inactive, modeTest/Production, healthOk/Failed, neverChecked, activate/deactivate/healthCheck/edit/rotate/planMappings/delete/cancel/save, createTitle/Subtitle, editTitle/Subtitle, rotateTitle/Subtitle/Warning, mappingsTitle/Subtitle/EmptyHint, variantIdPlaceholder, confirmDeleteTitle/Body, created/updated/rotated/activated/deactivated/deleted/mappingsSaved).
Tests. +36 new tests trong subscription: domain spec 13 (encrypt round-trip, defaults, empty creds throw, update patches, rotate replaces, activate/deactivate idempotency, recordHealthCheck overwrite, snapshot+rehydrate), service spec 12 (create + duplicate guard + test/prod coexist, findById not found, singleton activation flipping, rotate, plan mappings replace + clear, resolveActiveVariantId, getActiveWithCredentials), controller spec 8 (list/get DTO map, create delegation, rotate body pass-through, healthCheck OK/FAILED/not-registered, replaceMappings delegate). Plus +3 graceful-degrade tests trong adapter spec: createCheckoutSession + parseWebhook throw BILLING_PROVIDER_NOT_AVAILABLE, healthCheck swallows error returns {ok:false}. subscription jest: 96/96 pass. Full booking-api: 2049/2051 (2 skipped, 0 failed). Web vitest 170/170 (no regression).
Verification. booking-api: yarn lint 0 errors / 10 pre-existing warnings · yarn build (nest build) clean · yarn test 2049/2051. booking-web: yarn lint 0/0 · yarn typecheck clean · yarn build (Next 16, 32 pages) clean · yarn test 170/170. OpenAPI regen + types regen clean. gitnexus_detect_changes — booking-api 8602 → 8765 symbols (+163), booking-web 5623 → 5699 (+76).
Deferred decisions / future work:
- Customer-facing subscription checkout UI (S3 phase) — sẽ consume the 503 response để render "Subscription temporarily unavailable" banner. Hiện tại adapter throws đúng error, filter map đúng status, frontend chỉ cần wire vào khi S3 land
- Provider audit log table (PlatformBillingAuditLog mirror ImpersonationAuditLog) — defer to compliance request
- Stripe / Paddle adapters — stub folders giữ nguyên, swap dễ qua BillingProviderPort interface
- Public availability endpoint
GET /public/billing/status— defer until concrete consumer exists (customer subscription page chưa có)
2026-05-26 — docs(progress) audit: features.md catch-up cho các entry 2026-05-13
Audit pass phát hiện 4 tính năng ship 2026-05-13 đã có changelog đầy đủ nhưng thiếu row tương ứng trong features.md. Bổ sung thẳng vào Epic 9 (Customer Portal) để feature overview khớp với history thực tế:
- Tenant cover split desktop ↔ mobile — model JSONB swap
coverFocalX/Y→ optionalcoverMobileKey, migration20260513082622_split_tenant_cover_desktop_mobile,FocalPointPickerUI removed. - Claim guest booking after login —
POST /public/tenants/:slug/bookings/:id/claim+hasCustomerboolean + 60-min window + 7 controller specs; race-safe atomicUPDATE WHERE customer_id IS NULL. - Hide platform header on booking detail / invoice / payment subroutes —
CustomerHeaderreturnsnullcho regex/^\/b\/[^/]+\/bookings\/[^/]+(\/.*)?$/, "Back to salon" quiet text link. - Modal fade transition + scrollbar gutter + mask normalize —
SalonOverlay4-phase state machine viauseReducer,html { overflow-y: scroll }replacescrollbar-gutter: stable, 22-file mask consolidation sangbg-gray-900/25.
Fix stale entry: Phase 4.8 (line 624 features.md) vẫn ghi coverFocalX/coverFocalY là current model — thêm inline note _(Superseded 2026-05-13: focal-point keys removed, replaced by optional coverMobileKey...)_ để future reader không nhầm Phase 4.8 còn là source of truth.
Side maintenance: refresh gitnexus index counts trong AGENTS.md + CLAUDE.md cả 3 repo (root/booking-docs: 1676 → 1721 symbols, 1930 → 1982 relationships, 5 execution flows).
Status: docs-only commit, không code change. features.md 877 → 881 dòng (4 entry mới + 1 inline supersede note). changelog.md đã có entries đầy đủ cho tất cả tính năng từ 2026-05-06 → 2026-05-25 trước audit này.
2026-05-25 — Super-admin impersonation + audit log
Cho phép super-admin truy cập trang quản trị tenant dưới danh nghĩa OWNER ảo, full audit trail. Trước đây ADMIN user có tenantId: null nên mọi tenant-scoped endpoint return 500 (xem project_admin_tenant memory note); workaround duy nhất là dùng owner account thật. Giải pháp ship hôm nay không tạo OWNER giả hay chia sẻ password — server issue JWT mới có sub = adminUserId (token-version check vẫn ăn vào admin row) nhưng override role=OWNER + tenantId=target + thêm claim imp = { sessionId, adminUserId }. Mọi RBAC / tenant scope guard hoạt động bình thường mà không cần sửa.
Schema. Migration 20260525074345_add_impersonation_session_and_audit ship 2 model: ImpersonationSession (admin FK CASCADE, tenant FK CASCADE, reason text, ip/ua, started/endedAt, imp_refresh_token_hash để revoke chính xác token mới khi end), ImpersonationAuditLog (sessionId FK CASCADE, occurredAt, method, path, statusCode, body_hash = sha256). Body hash không lưu raw để tránh PII / secret leak (GDPR compliance) — đủ chứng minh "admin gọi endpoint nào, body nào" mà không tiết lộ data. Index (admin, startedAt) + (tenant, startedAt) + (endedAt) cho list/audit query; (sessionId, occurredAt) cho drill.
JWT layer. JwtPayload.imp?: { sessionId, adminUserId } optional, backwards-compat (token cũ vẫn parse). JwtAuthGuard parse claim vào request.user.impersonation. RequestUser interface mở rộng — TS lint pass nhờ existing as never casts trong specs.
AuthService. Hai method mới startImpersonation(adminId, tenantId, {reason, ip, ua}) và endImpersonation(adminId, sessionId). Start validate caller role = ADMIN + tenant tồn tại + isActive, sinh sessionId trước khi sign JWT (vì payload cần id), insert session row, generate tokens với imp claim, update session với hash refresh token mới. End validate session.adminUserId === caller, mark endedAt = now(), revoke imp refresh token theo hash, re-issue admin tokens không có imp. Idempotent với endedAt đã set. /auth/me mở rộng trả impersonation block khi JWT có claim — FE render banner từ đây.
Controllers. ImpersonationController mount dưới /superadmin/impersonate. Class không @Roles('ADMIN') ở class-level vì /end cần JWT impersonation (role=OWNER) gọi được; method /start + /sessions* gắn @Roles('ADMIN') riêng. /start refuse nested sessions (caller đang impersonate). /sessions paginated với filters tenantId + activeOnly. /sessions/:id/logs paginated mutation list.
Audit interceptor. ImpersonationAuditInterceptor register APP_INTERCEPTOR global. Tap into response success + error → nếu request.user.impersonation có AND method ∈ [POST, PUT, PATCH, DELETE] AND path không phải /superadmin/impersonate/end → fire-and-forget insert log row (catch error qua logger để không leak vào request budget). GET skip để tránh log volume khi admin chỉ xem dashboard. End-of-impersonation skip vì session row đã có endedAt làm canonical record.
FE. OpenAPI types regen (yarn generate:openapi + generate:types). useSuperadmin.ts thêm 4 hook: useStartImpersonation, useEndImpersonation, useImpersonationSessions(filters), useImpersonationLogs(sessionId). SuperadminTenantsContent thêm icon button LogIn (brand color, disabled khi tenant inactive) → mở StartImpersonationModal (warning amber + textarea reason 500 chars). On success → window.location.href = '/admin' (hard reload để AuthContext rebootstrap với cookies mới — không state-shuffle, không race). ImpersonationBanner (sticky top z-50 amber, ShieldAlert icon + "Acting as ... / Logged in by ..." + Exit button) inject vào (dashboard)/layout.tsx. Exit → end mutation → reload /admin/superadmin/tenants. /admin/superadmin/impersonations page mới list tất cả sessions với Active filter checkbox; row click mở ImpersonationLogsDrawer xem method/path/status/bodyHash. Sidebar nest "Activity" thành 2 subitem (feed + impersonation log).
i18n. 30 keys mới mỗi locale: superadmin.impersonate.{rowTitle, title, warning*, target*, reason*, banner*, list.*, logs.*}. Tất cả tiếng Anh + Norsk Bokmål, không hardcode.
Tests. +14 unit tests total. auth.service.spec.ts +8: startImpersonation (happy/non-admin forbidden/missing user/tenant not found/inactive tenant), endImpersonation (happy/different admin forbidden/idempotent already-ended); 1 existing me test update để include impersonation: null; 1 jwt-auth.guard.spec update. impersonation.service.spec.ts +4 (list with mutation counts joined, clamps limit/offset, skips count query when empty, listLogs). impersonation-audit.interceptor.spec.ts +5 (POST records body hash, DELETE error path records 404, GET skipped, no-imp skipped, end-endpoint skipped). API 2011/2013 unit pass (1 already-skipped + 1 cross-cutting jwt-guard spec touched and fixed); web build + lint clean.
Files touched. API: schema.prisma, auth.service.ts, auth.controller.ts, jwt-payload.interface.ts, jwt-auth.guard.ts, app.module.ts (APP_INTERCEPTOR), 4 file mới under core/superadmin/{impersonation.controller, impersonation.service, impersonation-audit.interceptor, dto/impersonation.dto}.ts. Web: AuthContext.tsx, useSuperadmin.ts, layout.tsx, SuperadminTenantsContent.tsx, 3 file mới under components/superadmin/{StartImpersonationModal, ImpersonationBanner, ImpersonationLogsDrawer, SuperadminImpersonationsContent}.tsx + page boilerplate; en.json + nb.json + AppSidebar. Docs: docs/architecture/role-matrix.md §2.13 mới + docs/operations/super-admin-impersonation.md runbook mới + features.md (Epic 8 entry).
Decisions & trade-offs.
- Identity = OWNER not ADMIN-with-tenantId. Lý do: zero RBAC code change. Alternative (giữ role=ADMIN, sửa mọi guard accept ADMIN bypass) đã rejected vì risk miss check.
- Body hash thay vì body raw. GDPR-friendly; "what changed" suy luận từ method + path là đủ cho compliance trail.
- No nested sessions.
/startrefuse khi caller đã cóimp. Mỗi admin một session tại một thời điểm; UX rõ ràng, end flow deterministic. - Reuse admin tokenVersion. Bump tokenVersion (change password) → mọi imp token invalid → admin phải re-login. Single source of revocation.
- No auto cleanup of stale sessions. Session bị abandon (đóng tab) sẽ stay
endedAt = nullmãi. Trade-off: chấp nhận; queryactiveOnlyít có nghĩa thực tế nhưng audit log vẫn complete. Cron sweep deferred.
2026-05-20 — Subscription S2: BillingProviderPort + LemonSqueezy adapter
Provider-agnostic billing layer goes live. S1 (2026-05-18) shipped the schema + plan catalog skeleton; S2 fills the adapter contract so the domain can talk to any provider through the same 5-method interface. LemonSqueezy is the first (and MVP-only) implementation; Stripe/Paddle slots remain stubs with READMEs. The whole context still cannot accept a real webhook end-to-end — webhook controller + inbox land in S3 — but the parsing, signature verification, event mapping, and HTTP client all work in isolation and are covered by 60 unit tests.
Domain contract (core/subscription/domain/). New BillingProviderPort interface with 5 methods (createCheckoutSession, getCustomerPortalUrl, cancelSubscription, updateSubscriptionQuantity, parseWebhook) + BillingProviderRegistry lookup contract + BILLING_PROVIDER_REGISTRY symbol token for NestJS DI. DomainSubscriptionEvent is a 7-variant discriminated union (created/renewed/updated/payment_failed/payment_recovered/canceled/expired); every variant carries provider, providerEventId (for inbox idempotency), providerSubId, and tenantId — adapters MUST surface tenantId from meta.custom_data.tenantId set at checkout; domain never infers tenant from email or customer_id (security memory feedback_tenant_scope_mandatory). Anything outside the 7 events (subscription_paused/unpaused/payment_refunded) is folded into subscription.updated by adapters or ignored entirely — keeps the domain small and stable as providers add edge events. New SubscriptionDomainError hierarchy with 4 cases (BILLING_PROVIDER_NOT_REGISTERED, WEBHOOK_SIGNATURE_INVALID, WEBHOOK_PAYLOAD_INVALID, CHECKOUT_FAILED) mirrors the Payment context error pattern; all carry machine-readable code for the API error envelope.
LemonSqueezy adapter (core/subscription/infrastructure/providers/lemonsqueezy/). Six files implement the port: lemonsqueezy.config.ts reads env (LEMONSQUEEZY_API_KEY, STORE_ID, WEBHOOK_SECRET + optional API_BASE_URL / STORE_DOMAIN) with LemonSqueezyConfigMissingError on absence; lemonsqueezy.client.ts is a thin native-fetch wrapper with 15s timeout, 3-attempt exponential backoff (200/400/800ms) on 5xx/429/network failures (programming-error 4xx never retried — would waste budget); lemonsqueezy.signature.ts does HMAC SHA-256 verification with timingSafeEqual + explicit length-mismatch guard (timingSafeEqual throws on length mismatch — pre-check avoids leaking that signal); lemonsqueezy.event-mapper.ts translates LS payloads into DomainSubscriptionEvent with planKey resolved primarily from custom_data.planKey and fallback via resolvePlanKeyByVariantId(variant_id); lemonsqueezy.adapter.ts implements all 5 port methods (POST /checkouts embeds custom_data.{tenantId,planKey} so every subsequent webhook routes correctly; DELETE /subscriptions/:id handles cancelAtPeriodEnd=true via LS's default "cancel at end of period" behaviour; PATCH /subscriptions/:id sets quantity + invoice_immediately:false so seat changes ride the next renewal invoice rather than triggering immediate prorated charges — UX-friendlier); lemonsqueezy.module.ts uses factory providers so missing env vars degrade gracefully (warn + skip registration) in dev, while production deploys without env still boot the rest of the app and only fail at first subscription command with BILLING_PROVIDER_NOT_REGISTERED.
Provider registry (InMemoryBillingProviderRegistry). Single shared instance provided by SubscriptionModule; every provider module receives the SAME registry via DI so all adapters register into one map. Idempotent re-register (same provider key wins) so a hot-reload doesn't ghost old adapters. List() preserves insertion order for diagnostics. Adapters call register(this) from their own onModuleInit AFTER assertProviderMappingComplete passes — adapter never registers in a half-configured state. BillingProviderNotRegisteredError surfaces at the boundary where the domain tries to dispatch a command for an unconfigured provider.
Plan mapping reverse lookup. Added resolvePlanKeyByVariantId(provider, variantId, env) to plan-mapping.config.ts. Primary planKey source is meta.custom_data.planKey set at checkout creation; the reverse helper is a fallback for legacy subscriptions created before custom_data was wired (theoretical for MVP, real for the eventual provider migration in S12). Returns null on no-match so the caller can choose between hard-failing or DB lookup.
Webhook plumbing decisions. providerEventId (used for subscription_webhook_inbox UNIQUE constraint) is read primarily from the X-Event-Id header; when absent (some LS retry paths don't include it), the adapter falls back to a deterministic SHA-256 hash of the raw body (lsq_<32-char-hex>). Same body = same hash, so duplicate deliveries of the same payload still collide on the inbox UNIQUE constraint. Less robust than an explicit id (different payloads with the same content would conflict) but safe for the actual LS retry semantics. Signature verification happens BEFORE JSON parsing — any reformat (re-stringify after parse) breaks HMAC because key ordering, whitespace, and number encoding are not preserved; the webhook controller in S3 MUST capture rawBody before NestJS body-parser touches it.
Tests (60/60 pass). lemonsqueezy.signature.spec.ts (8 cases): valid signature accepted, wrong secret rejected, missing header rejected, empty signature rejected, missing secret rejected, empty body rejected, single-byte tampered body rejected (replay-with-tamper), wrong-length signature rejected without throwing. lemonsqueezy.event-mapper.spec.ts (15 cases): all 7 event types mapped correctly + custom_data planKey extraction + variant_id reverse lookup fallback + tenantId-missing throws + paused event returns null + unknown event names return null + missing event_name throws. lemonsqueezy.adapter.spec.ts (16 cases): name property, createCheckoutSession (happy + HTTP error wrapping + missing URL response), getCustomerPortalUrl (happy + missing URL), cancelSubscription (cancelAtPeriodEnd=true + immediate throws), updateSubscriptionQuantity (happy + quantity<1 throws), parseWebhook (happy + bad signature + malformed JSON + body-hash fallback + ignored event types). billing-provider.registry.spec.ts (5 cases): empty start, register + lookup, throw on unknown, idempotent re-register, multi-provider listing order. Webhook fixtures under test/fixtures/lemonsqueezy/*.json — 8 sanitized payloads (created/updated/payment_success/payment_failed/payment_recovered/cancelled/expired/paused), all test_mode:true, tenantId is a placeholder ULID.
.env.example documents 6 LS env vars: API_KEY (scoped to one store), STORE_ID (numeric, from dashboard URL), WEBHOOK_SECRET (Settings → Webhooks), optional API_BASE_URL (for local mock server), optional STORE_DOMAIN (diagnostics only), and 3 VARIANT_* mappings (SOLO_MONTHLY/PRO_MONTHLY/PRO_YEARLY — enterprise is admin-only and intentionally absent).
Verification: yarn prisma validate, nest build, yarn eslint src/core/subscription/ (0 errors / 0 warnings — one targeted eslint-disable on parseWebhook async-without-await with reason comment), yarn test src/core/subscription/ (60/60 pass), gitnexus_detect_changes MEDIUM risk (added resolvePlanKeyByVariantId touches assertProviderMappingComplete's call cluster — behaviour unchanged, only new function added). Not yet shipped: webhook controller (S3) — the adapter parses webhooks but no HTTP route accepts them yet. Domain handlers (S4) and BillingGuard (S5) also remain pending.
Next — S3 (~0.5 day). Webhook controller (POST /webhooks/subscription/:provider), inbox persistence with (provider, providerEventId) UNIQUE, raw-body capture middleware, idempotent guard (skip if processedAt != null), async dispatch to S4 command handlers via NestJS EventEmitter.
2026-05-18 — Subscription S1: schema + plan catalog foundation
Đặt foundation provider-agnostic cho platform billing. Doc planning 2026-05-16 chốt 4 plan tiers + adapter pattern; commit này ship phase S1 — schema + plan catalog + module skeleton. Code chưa wire bất kỳ provider thật nào (S2 sẽ làm), nhưng cấu trúc adapter đã sẵn để LemonSqueezy / Stripe / Paddle plug-in mà không sửa domain.
4 open questions đã chốt (architecture doc §14): OQ1 auto-start 14-day trial khi TenantOnboarded (giảm friction), OQ2 không yêu cầu card cho trial (conversion cao hơn 2-3×, day 11/13/14 reminder), OQ3 downgrade seat overflow → khoá tạo Resource mới + giữ existing (KHÔNG tự deactivate, email day 30), OQ4 EXPIRED tenant ẩn khỏi / discovery + search nhưng giữ /b/<slug> accessible với 503 banner (SEO + returning customer, không mention billing reason). OQ5 (Bambora platform fee per-transaction) defer V2 — sẽ tách model PlatformFee riêng, không nhét vào Subscription (fee per period ≠ fee per transaction).
Schema (Prisma migration 20260518040226_add_subscription_context). 2 enums + 3 models + 4 Tenant columns. SubscriptionStatus (ACTIVE / PAST_DUE / CANCELED / EXPIRED — trial là derived state qua trialEndsAt > now, không tạo enum riêng để tránh combinatorial state machine). BillingProvider (LEMONSQUEEZY / STRIPE / PADDLE). Subscription: provider immutable, providerSubId nullable (NULL trong trial — chưa tạo provider resource), providerCustomerId, planKey, seatQuantity, status, period start/end, trialEndsAt, cancelAtPeriodEnd, canceledAt, paymentFailedAttempts, lastFailedAt, metadata (adapter-specific bag), unique (provider, providerSubId), index trên (tenantId, status), currentPeriodEnd, (status, lastFailedAt). SubscriptionEvent: append-only audit per state transition (mirror PaymentEvent — eventType, eventVersion=1, payload, occurredAt). SubscriptionWebhookInbox: idempotent inbox unique (provider, providerEventId) + signature + verified + processedAt + failureMessage (mirror PaymentWebhookInbox). Tenant additions là read-model cache cho hot-path guards (BillingGuard, public 503): subscriptionStatus SubscriptionStatus DEFAULT 'ACTIVE' grandfathers existing tenants → BillingGuard treats currentPlanKey IS NULL + status=ACTIVE as legacy/pass; sync từ Subscription aggregate qua event listener (Subscription = source of truth). Quan hệ Tenant ↔ Subscription = 1:N (không 1:1) vì flow S11 reactivation tạo Subscription row mới + S12 provider migration parallel forever — multiple rows per tenant by design, current resolved via status=ACTIVE ORDER BY updatedAt DESC LIMIT 1.
Plan catalog (src/core/subscription/infrastructure/plan-catalog/). Decision đổi runtime: bỏ zod (booking-api dùng class-validator chứ không có zod), thay bằng plain TypeScript PlanFeatures interface + runtime invariant assertions via satisfies readonly Plan[] + assertPlanInvariants() (XOR flatPriceMinor vs pricePerSeatMinor, seatBased ↔ pricePerSeatMinor, ISO-4217 currency, trialDays >= 0). Catalog hardcoded const → TypeScript compile-time check + invariants chạy 1 lần khi import. Doc §4.4 updated. 4 plans: solo_monthly (199 NOK flat, 1 seat, no loyalty/marketplace/branding), pro_monthly_per_seat (149 NOK/seat, unlimited seats, full features minus API), pro_yearly_per_seat (1490 NOK/seat ~17% off), enterprise_custom (admin-only, selfServeCheckout=false, API access enabled). PlanFeatures 8 flags: maxBookingsPerMonth/maxStaffSeats (null=unlimited, explicit 0=none), smsCreditsIncluded, emailCreditsIncluded, loyaltyEnabled, marketplaceEnabled, customBrandingEnabled, apiAccessEnabled. Errors: UnknownPlanKeyError (SUBSCRIPTION_UNKNOWN_PLAN_KEY), PlanFeatureNotIncludedError (PLAN_FEATURE_NOT_INCLUDED). API: listPlans({selfServeOnly?}), findPlan, requirePlan, requirePlanFeature cho 4 boolean features.
Plan mapping (plan-mapping.config.ts). planKey ↔ provider variant ID resolution qua env vars: LEMONSQUEEZY_VARIANT_SOLO_MONTHLY, LEMONSQUEEZY_VARIANT_PRO_MONTHLY, LEMONSQUEEZY_VARIANT_PRO_YEARLY (3 self-serve plans, enterprise admin-only không có mapping → resolve throws). STRIPE_PRICE_* + PADDLE_PLAN_* env keys đã reserve sẵn nhưng provider chưa wire — cho V2/V3. MissingProviderVariantError (SUBSCRIPTION_MISSING_PROVIDER_VARIANT) khi env absent/empty/whitespace. assertProviderMappingComplete(provider, env) chạy startup tại S2 module bootstrap — fail-fast với danh sách planKeys thiếu thay vì lazy error tại checkout. Pattern này cho phép rotate variant IDs (test→prod) qua env mà không sửa code.
Module skeleton (subscription.module.ts). NestJS module rỗng, imports PrismaModule, providers/controllers/exports đều [] — S2-S5 sẽ fill (BillingProviderPort + LemonSqueezyAdapter, webhook controller, domain handlers, BillingGuard). Registered vào AppModule ngay sau PaymentModule. 3 provider stub folders với README chỉ rõ trạng thái: lemonsqueezy/ (S2 spec — file list, env vars, fixture strategy), stripe/ (V2 defer — implement khi MRR >50K USD, lý do MoR fees), paddle/ (V3 defer — backup nếu LS unreliable).
Tests. 17 unit tests trong plan-catalog.spec.ts cover toàn bộ accessor + invariants: listPlans default vs selfServeOnly, findPlan known/unknown, requirePlan throws, requirePlanFeature pass on Pro / throws on Solo / throws unknown key, catalog invariants (default trial plan exists, seat-based ↔ pricePerSeatMinor, all NOK), resolveProviderVariantId happy/missing/whitespace/admin-only, assertProviderMappingComplete happy/throws listing. 17/17 pass.
Verification. yarn prisma validate + migrate deploy (62 migrations now), yarn build (nest build pass), yarn eslint src/core/subscription/ src/app.module.ts (0 errors / 0 warnings on new files — existing tech debt in other files untouched), yarn test src/core/subscription/ (17/17 pass), gitnexus_detect_changes (risk LOW, 0 affected processes — module standalone, không impact existing flows). Not yet shipped: backfill seed cho tenants hiện hữu (sẽ ship cùng S5 guard — hiện grandfathered qua DEFAULT 'ACTIVE').
Next — S2 (~1 ngày, blocked on LS sandbox account setup). Implement BillingProviderPort interface (5 methods: createCheckoutSession, getCustomerPortalUrl, cancelSubscription, updateSubscriptionQuantity, parseWebhook) + LemonSqueezyAdapter + event mapper + HTTP client + HMAC signature verify. Fixtures lấy từ LS sandbox webhooks → assert mapper normalize về 7 DomainSubscriptionEvent variants. Provider registry DI module inject động theo Subscription.provider.
2026-05-16 — SaaS Subscription planning (architecture + flow docs)
Đặt nền móng cho platform billing. Hiện trạng pre-doc: schema tenant chỉ có isActive boolean — không có khái niệm plan, trial, past-due, hay subscription state; Tenant.isActive=false cũng KHÔNG được guard nào enforce (chỉ làm filter dữ liệu trong list queries). Khi launch chạy thương mại, tenant không trả tiền vẫn dùng full app, không có cơ chế suspend, không có audit billing. Doc 2 file mới đóng gap này ở mức design — code chưa viết.
docs/architecture/subscription-architecture.md (new — 15 sections, 720 lines). DDD + Hexagonal bounded context tách hoàn toàn Payment Context (Bambora cho salon-as-merchant): khác provider, khác audit, khác compliance, không reuse model 4 ngày code, 0 thay đổi domain). Roadmap 8 phase S1-S8 (~6 ngày MVP).Payment. 12 quyết định kiến trúc: D1 tách context, D2 plan catalog độc lập provider (planKey ↔ provider variant IDs qua mapping table), D3 BillingProviderPort abstract (LS / Stripe / Paddle implement cùng interface, domain 0 if(provider===)), D4 Subscription.provider immutable (provider migration qua row mới + parallel forever, không in-place swap), D5 webhook inbox idempotent qua (provider, providerEventId) UNIQUE, D6 trial là derived state (không tạo state riêng — ACTIVE + trialEndsAt > now), D7 per-seat tracked ở Subscription.seatQuantity sync 2-chiều, D8 graceful degrade thay vì hard suspend (PAST_DUE ≤7d soft banner / >7d hard read-only / EXPIRED full block / 90d data retention), D9 path-based webhook URL /webhooks/subscription/:provider, D10 customer portal = provider-hosted không tự build, D11 dunning email do provider gửi MVP, D12 seat limit check tại boundary không cron. Provider comparison matrix LS vs Stripe vs Paddle (fees, MoR vs direct, per-seat support, trial, proration). Schema Prisma đầy đủ: Subscription, SubscriptionEvent, SubscriptionWebhookInbox, Tenant additions (subscriptionStatus, trialEndsAt, currentPlanKey, seatLimit). Hexagonal module structure đầy đủ. Migration strategy LS → Stripe 7 bước (
docs/flows/subscription-flow.md (new — 12 scenarios E2E, ~470 lines). S1 welcome trial auto-start, S2 add staff hit seat limit, S3 checkout LS hosted, S4 customer portal redirect (signed URL), S5 renewal auto-charge, S6 payment failed soft past-due, S7 payment recovered, S8 owner self-cancel (cancelAtPeriodEnd), S9 period end → EXPIRED, S10 hard past-due retries exhausted, S11 reactivation post-EXPIRED (new sub row, không mutate cũ), S12 provider migration LS → Stripe (cohort, parallel period). Mỗi scenario có actors, preconditions, steps đánh số, state transitions, side effects, error edges (race conditions, webhook trễ, owner đóng tab giữa flow). State machine Mermaid sub-state (ACTIVE_TRIAL, PAST_DUE_SOFT/HARD, ACTIVE_CANCELING) — 4 status DB nhưng diagram gộp để dễ đọc. Error reference 10 codes (SUBSCRIPTION_EXPIRED, SUBSCRIPTION_PAST_DUE_HARD, SUBSCRIPTION_INACTIVE, SEAT_LIMIT_REACHED, SEAT_LIMIT_RACE, PLAN_FEATURE_NOT_INCLUDED, CHECKOUT_FAILED, WEBHOOK_SIGNATURE_INVALID, WEBHOOK_ALREADY_PROCESSED, PROVIDER_NOT_AVAILABLE). UI surface map. 6 cron jobs cần thiết.
docs/progress/features.md — added Epic 12: SaaS Subscription với 4 plan tiers (Solo 199 NOK flat / Pro Monthly 149 NOK per-seat / Pro Yearly 1490 NOK per-seat 17% off / Enterprise custom), 9 phase checklist (S1 schema + plan catalog → S9 E2E + load test) + V2 Stripe adapter + V3 usage-based add-ons. Backfill plan cho tenants hiện hữu khi deploy S5 guard: tạo Subscription(planKey=pro_monthly_per_seat, trialEndsAt=now+90days) để không bị block đột ngột.
docs/README.md — index 2 entries mới dưới Architecture + Flows sections.
Status: planning-only commit. Không code, không schema, không test. Implementation pending OWNER chốt 5 open questions ở section 14 của architecture doc (trial gating policy, card-required-for-trial, seat overflow on downgrade, public marketplace EXPIRED visibility, Bambora platform fee model).
2026-05-15 — Tenant temporary close + admin schedule popover portal fix
Owner can pause new online bookings tenant-wide when overloaded ("hôm nay quá đông, dừng nhận online") while existing bookings + walk-in / admin-created bookings keep working. Pre-fix flow had no kill switch — owners had to either tolerate the load or temporarily change business hours (which then needed reverting). New surface lives at /admin/settings?tab=temporaryClose (Operations group, between Business hours and Booking).
Schema. New TenantSettings.temporaryClose struct { enabled, until: ISO|null, message, startedAt: ISO|null } — defaults to { enabled: false, until: null, message: '', startedAt: null } for both beauty and barbershop industries. Strict validator (booking-settings.helper.ts:parseTenantSettings) adds temporaryClose to REQUIRED_KEYS so legacy rows would throw TENANT_SETTINGS_CORRUPT — backfill migration 20260515021622_backfill_tenant_temporary_close ships in the same commit using the defaults || existing jsonb pattern (idempotent, FE-PR safe). Note on validator: setTemporaryClose skips parseTenantSettings entirely for simple presets (custom / indefinite / minutes_*) — those don't read business hours, so legacy tenants on stale validators still pause cleanly while BusinessHours-dependent presets (end_of_current_slot / next_open) keep the strict gate.
API. New helper module src/core/tenant/tenant-closure.ts: isTenantTemporarilyClosed(settings, now) (auto-reopen on past until, defensive null on corrupt timestamps), resolveTemporaryCloseUntil({ preset, now, settings, customUntil }) covering 7 presets (minutes_30/60/120 / end_of_current_slot / next_open / custom / indefinite), buildTemporaryClose({ until, message, now }) stamping fresh startedAt. Three OWNER+ADMIN endpoints on TenantController: PATCH /tenants/:id/temporary-close (server resolves preset against tenant tz + BusinessHours so client clock skew can't shift the deadline), PATCH /tenants/:id/temporary-close/message (edits message field only — preserves until/startedAt so wording can be tweaked mid-pause without re-arming the deadline; throws 422 TENANT_TEMPORARY_CLOSE_NOT_ACTIVE when no active closure), DELETE /tenants/:id/temporary-close (manual reopen / clear stale config). PublicBookingController.createBooking rejects POST /public/tenants/:slug/bookings with 409 TENANT_TEMPORARILY_CLOSED when paused; admin-create / walk-in deliberately bypass per spec ("salon may still take in-person customers"). Public tenant DTO synthesizes temporaryClose: { until, message } | null (auto-null past until) so customer FE never sees stale enabled=true flag. New error codes: TENANT_TEMPORARILY_CLOSED, TENANT_TEMPORARY_CLOSE_NOT_ACTIVE.
Admin UI. TemporaryCloseSection card with explicit 3-state machine: open (icon ▶ green + "Salon is accepting new bookings" + Pause CTA), active (icon ⏸ red + "New bookings paused" + Until / Since / Message-with-edit + green Resume CTA), expired (icon ▶ green + amber inline-block notice "Configuration expired — bookings have reopened automatically" + retained pause context for audit + Pause CTA + Clear configuration CTA). Inline message edit (Pencil button → textarea full-width + Save/Cancel) hits the message-only endpoint so until doesn't drift. TemporaryCloseModal redesigned per UX feedback: vertical preset list inside a divide-y card (mỗi preset 1 dòng), per-option time preview "Now → HH:mm dd/MM" in salon timezone (formatDateTimeInZone) computed via lib/tenant-closure.ts (port of backend resolver — preview client-side, server re-resolves on save), "Pick exact time" embeds the <input type=datetime-local> inline with min={now+1min} to block past dates, "Indefinite" shows "Manual reopen only" hint instead of preview. now captured via useState lazy init so previews stay stable across re-renders even though new Date() is impure.
Customer UI (/b/[slug]). New TemporaryCloseBanner (red bg-error-600, white text, rounded-2xl) sits BELOW the cover (between cover + content sections) per UX feedback — reads as part of the salon's own page, not platform-wide. OpenStatusTrigger + OpeningHoursCard (cover chip + sidebar card) flip to "Paused" badge + red pill regardless of actual business hours; sidebar card prepends a red sub-banner with Reopens HH:mm dd/MM (or Reopens manually only for indefinite). ServiceList Book CTA renders as a disabled button with tooltip "Salon is temporarily paused" instead of the live <Link>. The /book route stays accessible (per spec — no hard 403); backend guard handles the actual block at POST time.
Side fix: ScheduleCell add-shift popover portal. Pre-fix, the "+" dropdown on /admin/work-schedule cells used absolute top-full — when clicking the "+" on the last staff row, the popover overflowed the table's bounding box and forced the page to scroll just to see the menu items. Refactor: createPortal(document.body) + position:fixed with getBoundingClientRect()-anchored coords + auto-flip-up when bottom space < ~132px + auto-close on scroll (capture) / resize (fixed coords drift otherwise) / outside click (covers both trigger ref + portal menu ref).
i18n. New settings.temporaryClose.* namespace covering card states, modal preset labels, edit-message buttons, expired-config notice, clearedToast (en + nb). New publicBooking.temporaryClose.* for banner + book-disabled tooltip + pill / card-notice copy. Two new error codes mapped in errors.*.
Verification. booking-api: lint 0/9 pre-existing warnings, nest build pass, full Jest suite 1933/1933 (5 new helper tests + 3 service + 2 controller). booking-web: lint 0/0, next build pass, vitest 170/170. GitNexus impact upstream on createBooking = LOW (0 callers — HTTP entry only; the detect_changes "high" flag was process-count noise).
2026-05-14 — Featured tenants: curated marketplace homepage list
Super-admin can now pick which salons surface on the marketplace homepage. Pre-fix, GET /public/tenants returned every onboarded active tenant sorted by createdAt DESC (last 50). With dozens of salons live, the homepage became a random-feeling grid. New surface: a curated list at /admin/superadmin/featured-tenants with drag-to-reorder ordering. Search/industry filters on the public side still hit the full tenant population — the curation is for the discovery default, not a hard wall.
Schema. New model FeaturedTenant { id, tenantId @unique, position, createdAt, updatedAt } with FK CASCADE on tenant (deleting a tenant auto-removes the featured row). Index on position. Migration 20260514150344_add_featured_tenants — starts empty; admin curates from scratch.
API. New module src/core/featured-tenant (@Roles('ADMIN') class-guard, same pattern as /superadmin/*): GET /superadmin/featured-tenants (full list sorted by position ASC), GET /superadmin/featured-tenants/available?search=&page=&limit= (paginated tenant pool for the Add modal — excludes already-featured, includes suspended so admin can re-feature post-reactivation), POST /superadmin/featured-tenants (bulk add — silently skips already-featured + pre-onboarding tenants, hard-caps at 50), DELETE /superadmin/featured-tenants (bulk remove with body — api.delete extended to accept body), PATCH /superadmin/featured-tenants/reorder (validates payload includes every currently-featured tenant exactly once + positions are contiguous 0..N-1, then two-phase transaction: push every row to the negative range then write target positions to sidestep unique-position collision). PublicBookingController.listTenants updated: no filter → featuredTenantService.listPublicFeatured() (active + onboarded only, sorted by position); filter present → unchanged full-tenant search. Error codes: FEATURED_TENANT_LIMIT_REACHED, FEATURED_TENANT_REORDER_MISMATCH, FEATURED_TENANT_REORDER_UNKNOWN, FEATURED_TENANT_REORDER_INVALID_POSITIONS.
Admin UI. New page /admin/superadmin/featured-tenants (sidebar nav Star icon between Tenants and Activity). SuperadminFeaturedContent renders a drag-drop table (@dnd-kit/sortable + verticalListSortingStrategy) — drop auto-fires PATCH /reorder (mirrors PortfolioGalleryEditor pattern) with a local-order optimistic state cleared in onSettled. Multi-select via checkbox column + select-all header; bulk "Remove selected" toolbar action with ConfirmDialog. Each row also has an individual remove trash icon. AddFeaturedTenantsModal — debounced search (300ms), paginated 10/page list, multi-select with cap-aware disable (selection blocked when picking would exceed 50). Hooks useFeaturedTenants / useAvailableTenantsForFeatured / useAddFeaturedTenants / useRemoveFeaturedTenants / useReorderFeaturedTenants with cross-key invalidation on every mutation (both featured-tenants and featured-tenants/available query trees).
i18n. New superadmin.featured.* namespace (en + nb) covering page header, table headers, toolbar, confirmation dialogs, toasts, and the Add modal (with Intl.PluralRules-aware addAction / selectedCount / servicesCount / toastAdded). Sidebar key superadminFeatured = "Featured salons" (en) / "Utvalgte salonger" (nb).
Verification. booking-api: lint 0 errors / 9 pre-existing warnings, nest build pass, 28 new tests (featured-tenant.service.spec + featured-tenant.controller.spec) + updated public-booking.controller.spec (no-filter path mocks FeaturedTenantService.listPublicFeatured) — full suite 1910/1912 pass. booking-web: lint 0/0, next build pass, vitest 170/170. OpenAPI + api.generated.ts regen'd (new operations under /superadmin/featured-tenants).
2026-05-14 — Staff invitation flow (Phase 1–5): email invite + self-service password
Đóng cross-tenant password-reuse leak. Pre-fix flow: owner tạo staff với password do mình tự đặt → staff đó cũng làm cho salon khác → owner đoán cùng password đó trên login form → tenant picker hiển thị mọi tenant staff đang work, lộ workplace. Root cause: identity model per-tenant nhưng password do tenant A đặt vẫn match user record của tenant B nếu staff dùng lại pass. Fix: owner không bao giờ thấy/đặt password staff — staff tự đặt qua invite link.
Phase 1: Schema + migration. Model StaffInvitation mới (tenantId + email + role + token (UUID) + expiresAt + acceptedAt? + revokedAt? + recipientUserId? + inviterUserId) + indexes (tenantId,email,acceptedAt,revokedAt partial cho pending list; token unique). Migration 20260514033919_add_staff_invitation + reverse relations User.staffInvitationsReceived + User.staffInvitationsSent + Tenant.staffInvitations. Per-tenant identity model giữ nguyên — không refactor Membership.
Phase 2: API. New module src/core/staff-invitation. StaffInvitationAdminController (OWNER guard, tenant scope): POST /staff-invitations (rate limit 5/h/tenant), GET /staff-invitations (paginated pending list), POST /staff-invitations/:id/resend (regen token + extend expiry), POST /staff-invitations/:id/revoke (soft cancel). StaffInvitationPublicController (no auth, throttle): GET /staff-invitations/verify/:token (returns salon brand + email + role preview), POST /staff-invitations/accept (token + new password → set User.password + tokenVersion++ + isActive=true → set httpOnly cookies → return 200). AuthService.issueTokensForUser exposed cho accept path (mirror generateTokens internals nên cookie + body shape giống POST /login). auth-cookies.ts extract setAuthCookies + clearAuthCookies + constants (DRY giữa login flow và accept flow). 29/29 tests cover happy path, tenant isolation (cross-tenant lookup fail), rate limit, expired/revoked/already-accepted token branches, orphan-User reuse.
Re-invite recovery (orphan-User reuse). Pre-Phase 1 flow tạo Resource + User cùng lúc. Phase 2 ResourceService findById/findAllByTenant/findAllByTenantNoPagination thêm include: { user: { include: { staffInvitations: { take: 1, where: { acceptedAt: null, revokedAt: null } } } } } — Pending badge + Resend/Revoke render trên first paint (eliminates race nơi list-then-find khiến button stuck disabled). resolveExistingUserForInvite phát hiện orphan User (active row nhưng Resource đã soft-delete qua Prisma extension deletedAt) → reuse in-place với password=null + isActive=false + tokenVersion++ — tenant-scoped qua where: { id, tenantId } double filter, không bao giờ touch row tenant khác (per-tenant identity model).
Phase 3: Admin UI (booking-web). InviteStaffModal (mới — email + optional displayName + role select STAFF/OWNER, không password). StaffList split CTA: "Invite staff" primary brand button (always visible), "Add resource" outline + conditional khi industry có non-staff resource types (spa/fitness/clinic) — beauty/nail/barbershop chỉ thấy Invite. Pending badge surface từ user.isActive=false rows. StaffFormModal: xóa hoàn toàn password fields (Zod + JSX) — 3 regression test assert "no password input renders in any mode". ActiveLoginAccess (phone + role read/edit khi user.isActive=true) vs PendingInviteAccess (Resend + Revoke + warning banner khi user.isActive=false) branch theo user.isActive. Hooks useStaffInvitations + useInviteStaff + useResendInvitation + useRevokeInvitation với cache invalidation cross-key [staff, resources, staff-invitations].
Phase 4: Accept page. /admin/accept-invite (mới) — React Query verify token → render branded form (salon name + logo + email + role preview) + Zod password (min 6 + match). POST accept → backend set httpOnly cookies (mirror POST /login) → router.push('/admin') + queryClient.clear() để swap identity sạch. Identity-swap UX: khi owner đang sign-in click invite link, warning banner giải thích cookie sẽ overwrite. Proxy whitelist /admin/accept-invite AND bypass redirect-when-logged-in rule cho riêng path này (tránh proxy đẩy về /admin trước khi user kịp đọc warning).
Phase 5: i18n + error codes. acceptInvite namespace (en + nb) cover form labels + warning banner + success/error toast. inviteStaff + pendingInvite namespaces cho admin modal. 9 INVITATION_* error keys (INVITATION_NOT_FOUND, INVITATION_EXPIRED, INVITATION_REVOKED, INVITATION_ALREADY_ACCEPTED, INVITATION_RATE_LIMIT_EXCEEDED, INVITATION_INVALID_TOKEN, INVITATION_EMAIL_MISMATCH, INVITATION_TENANT_MISMATCH, INVITATION_GENERIC_ERROR) mapped qua useErrorMessage để FE show i18n toast thay vì raw codes.
Side fix: HttpExceptionFilter 429 → RATE_LIMIT_EXCEEDED. statusToErrorCode pre-fix không có branch cho 429 → ThrottlerException returned INTERNAL_ERROR thay vì rate-limit code, FE không thể i18n đúng. Map mới: 429 → 'RATE_LIMIT_EXCEEDED'.
Remaining roadmap (Phase 6–8). Phase 6: xóa hẳn Resource.password field + clean up legacy owner-set-password code (DB migration + Resource model trim). Phase 7: Playwright E2E suite (happy invite + accept, expired token, revoked token, re-invite orphan, identity-swap warning). Phase 8: branded email template qua queue (replace inline plaintext) + Norwegian i18n cho email body + ops runbook for "invitation didn't arrive" support flow.
Verification. booking-api: lint 0 errors / 9 warnings pre-existing, nest build pass, tests 1882/1884 pass (2 known-flaky unrelated). booking-web: lint 0/0, next build pass, vitest 170/170. GitNexus indexed 1589 symbols (up from 1588). Plan: docs/plans/staff-invitation-plan.md.
2026-05-13 — Claim guest booking after login + hide platform header on booking detail
Save guest booking to your account. Customers booking as guests can now attach the booking to their account by logging in from the confirmation page. New endpoint POST /b/<slug>/bookings/<id>/claim (customer auth required) sets Booking.customerId and upserts a TenantCustomer row so the salon's customer list sees the link. GET /b/<slug>/bookings/<id> response gains a hasCustomer: boolean (server NEVER exposes the raw customerId — only the boolean — so anonymous viewers can't enumerate ownership). BookingConfirmedClient shows a "Save this booking to your account" CTA below "View invoice" whenever !authLoading && booking.hasCustomer === false; click fires the claim directly when already logged in, otherwise opens CustomerLoginModal and auto-fires the claim on login via a useRef flag (avoids the React 19 set-state-in-effect lint).
Security model: claim is gated by (a) customer JWT, (b) tenant scope from slug, (c) customerId IS NULL (atomic UPDATE … WHERE customer_id IS NULL makes simultaneous claims race-safe), and (d) a 60-minute window from Booking.createdAt. Identity (email/phone) is NOT enforced — guests often book on behalf of someone else who then logs in to save it on their own account; the time window limits the blast radius of a leaked URL. Error codes: BOOKING_NOT_FOUND (404), BOOKING_ALREADY_CLAIMED (409, surfaced when an interleaved write wins too), CLAIM_WINDOW_EXPIRED (403). On 409 the frontend invalidates the booking query so the CTA disappears regardless of which account got there first. i18n: saveToAccount, claimSuccess, claimErrorExpired, claimErrorAlready, claimErrorGeneric (en + nb). Tests: 7 controller specs (happy in-window, happy without identity match, 404, 409 already, 403 expired, 409 race-loss, slug 404) — API suite 1862 / 1864 pass.
Hide platform header on booking detail / invoice / payment subroutes. CustomerHeader now hides on every /b/<slug>/bookings/<id> route and its sub-routes (/invoice, /payment/return, /payment/cancelled) — same focused-screen rationale as the booking flow. Query strings (?from=payment-return) are naturally covered since usePathname excludes them. Pattern: /^\/b\/[^/]+\/bookings\/[^/]+(\/.*)?$/.
Booking detail "Back to salon" matches invoice style. Was a filled primary button — replaced with the same quiet text link that lives under invoice's footer so both screens read like the same design system. "View invoice" stays as the outline primary action above it.
2026-05-13 — Tenant cover split + salon landing UX polish
Customer feedback drove a simpler cover model. Tenants asked for two separately framed covers — one for desktop, one for phone — instead of one image + an owner-picked focal anchor. TenantBranding JSONB swaps coverFocalX / coverFocalY for an optional coverMobileKey. Public renderers prefer coverMobileKey on phones (and on the homepage 5:3 thumbnail, whose aspect is closer to 5:2 than 5:1) and fall back to coverKey when the salon ships a single image. BrandingSection now shows two ImageUploader cards (desktop 5:1 + mobile 5:2) with an amber warning when only desktop is set ("phone visitors will see the desktop image cropped to fit"). The FocalPointPicker UI is removed; the underlying ProxyImage / useImageProxyUrl focal props stay as a generic primitive. Migration 20260513082622_split_tenant_cover_desktop_mobile strips both focal keys and seeds coverMobileKey: null using the defaults || existing JSONB pattern so re-runs never overwrite a value set via the new UI. branding.resolver.ts, PublicBookingController.listTenants and the public-tenant DTO are updated to the new shape. Auxiliary fix: public-booking.controller.spec.ts had a stale result.settings assertion missing contactEmail / contactPhone after the 2026-05-13 contact-fields commit — added so the suite is green on main.
Salon landing section titles synced + side-card icons removed. Services / About / Location (main column) used text-base font-semibold, while sidebar cards (Opening hours, Team) used text-sm font-semibold with a leading icon — visual hierarchy felt fractured. Unified everyone on text-lg font-semibold; dropped the Clock and Users icons from the sidebar headers so titles read like a single design system.
Map full-height + skeleton loading + StaticMap takes height prop. The Location & contact modal map (pigeon-maps) used to mount with no reserved frame, so the modal popped open at ~140px and jumped to ~600px once the dynamic chunk landed. Fixed by giving StaticMap a required height: number prop (pigeon-maps needs an explicit px value, not a CSS class), threading MAP_HEIGHT_PX = 400 from the modal + the salon landing's LocationSection, and rendering a same-height animate-pulse skeleton from dynamic({ loading }). Container is now auto-sized to the map (instead of a fixed h-[400px] that left whitespace between map bottom and the "Get directions" button on render).
Modal scroll-lock no longer shifts the page sideways. Setting body.overflow: hidden propagates to the viewport per CSS spec, which removed the root scrollbar and yanked content left by ~15px on open. Fixed at the html layer in globals.css: replaced scrollbar-gutter: stable (which doesn't apply when overflow is hidden — that's the spec) with overflow-y: scroll so the gutter is reserved permanently and body.overflow: hidden never propagates. Tried a JS padding-right compensation first; it interacted poorly with the centred max-w-[1440px] content (computed scrollbar width was much larger than expected, content shifted ~900px left), so it was reverted in favour of the CSS-only fix.
Modal open/close fade transition via SalonOverlay state machine. Modals popped in/out hard — no entry or exit animation. SalonOverlay now drives a four-phase state machine (closed → entering → open → leaving → closed) via useReducer so dispatch calls don't trip React 19's react-hooks/set-state-in-effect rule. Entry: double requestAnimationFrame between BEGIN_ENTER and COMMIT_ENTER so the initial paint with opacity-0 scale-95 translate-y-2 lands BEFORE the visible state, giving the browser something to interpolate from. Exit: one rAF after BEGIN_LEAVE before the 150ms unmount timer so the CSS transition has a frame to start painting (a single setTimeout(100) was firing before the paint, making the exit invisible). Backdrop shows instantly (animating it just added perceived latency); only the dialog fades + scales. CSS duration duration-150 matches TRANSITION_MS = 150.
Mask opacity normalized + backdrop-blur removed across 22 files. Modals/drawers/dialogs/sidebar backdrop were a grab bag of bg-black/20, /30, /40, /50, plus bg-gray-900/40 and /50 on SalonOverlay and the mobile sidebar Backdrop. Now all share bg-gray-900/25 — lighter and consistent. Removed backdrop-blur-[2px] from SalonOverlay and the Modal primitive (mask alone is enough). Lightbox image viewers (bg-black/95) and in-uploader loading overlays (bg-white/70) are deliberately untouched since they're not modal masks.
BookingTicket whitespace fix. Outer details wrapper used py-5, which left 20px of dead space between the full-bleed BALANCE DUE strip and whatever rendered below (QR section, invoice footer). Switched to pt-5 so the strip sits flush against its neighbour — applies to booking detail, booking confirmed, and invoice pages.
2026-05-13 — Salon landing UX overhaul: in-page login modal, opening-hours pill, location & contact modal, team grid
Public contact (email + phone) trong TenantSettings. TenantSettings interface + TenantSettingsDto thêm 2 field optional contactEmail / contactPhone. DTO dùng @ValidateIf để cho phép gửi chuỗi rỗng (user xoá contact) bypass @IsEmail validation. PublicBookingController.sanitizeSettings expose 2 field qua GET /public/tenants/:slug — empty string → null để FE branch sạch. Admin settings: tab Location rename → Location & contact (EN + NB), section mới "Public contact" với 2 input email/phone + icon, submit merge vào settings JSON cùng address. Backward-compat tự nhiên vì optional + không nằm trong REQUIRED_KEYS của parseTenantSettings validator.
Cover header — 3 dòng + chips. Cover overlay desktop: logo SalonAvatar size="lg" (80×80) + 3 dòng (tên / SalonClock / chip row). Chip row: OpenStatusTrigger (pill xanh/đỏ với label "Opening hours: 09:00–19:00") + LocationContactTrigger (chip dark bg-black/40 border white). Mobile (cover 5:2 không có overlay): giữ 2 dòng + chip row riêng dưới meta block (chip light theme). useOpenStatus(businessHours, timezone) hook trích từ OpeningHoursCard cũ — hydration-safe (state null khi SSR, set sau mount để tránh "Closed" → "Open now" mismatch), re-tick 60s. Pill color (red/green) ALONE báo trạng thái khi showDot={false}. Trigger return null khi !status.ready thay vì render gray placeholder (placeholder co lại "đần").
Modal Opening hours. SalonOverlay (primitive mới qua createPortal(document.body) — tách hẳn khỏi DOM cover, không inherit text-shadow của tên salon nữa) + OpeningHoursModal reuse BusinessHoursTable + OpenStatusPill. Header row: icon round-bg trái + title/subtitle + close button phải, cùng baseline. Body có pt-16 sm:pt-20 chừa khe an toàn dưới close button. OpeningHoursCard (sidebar) refactor dùng cùng hook để DRY.
Modal Location & contact. LocationContactModal (mới): map full-width làm hero với FAB "Get directions" (mở Google Maps directions URL — coords nếu có, fallback encoded address), address card với icon round-bg + label uppercase, contact buttons grid 2 cols (Call/Email) — mỗi nút có icon brand-bg, label + giá trị. Hover chỉ đổi background (hover:bg-gray-50) — bỏ translate-y + shadow-md per user feedback (dị ứng button di chuyển).
Login modal in-page (thay redirect /account/login). CustomerLoginModal (shared, ở (customer)/CustomerLoginModal.tsx) bọc SalonOverlay, dùng cùng loginWithGoogle() flow. Login thành công → đóng modal, giữ trang hiện tại (không redirect). CustomerHeader button Login đổi từ <Link href="/account/login"> thành <button onClick={setLoginOpen(true)}>. Login page (/account/login) vẫn tồn tại cho deep-link nhưng đã chuyển t.rich('termsNotice', ...) để link "Terms of Service" / "Privacy Policy" trỏ đúng /terms + /privacy. Footer logo wrap <Link href="/"> để có entry quay về home từ trang con.
Header platform — compact h-14 trên /b/[slug]. Trên tenant landing: CustomerHeader position: relative (KHÔNG sticky — scroll mất cùng cover, salon's StickyTenantHeader take over), height h-14 thay vì h-18, AppLogo size="sm" thay md để không full-bleed container. Nav phải ẩn (isTenantLanding ? 'hidden' : '') — login/avatar handled bởi TenantTopBar overlay top-right z-50 (fixed inset-x-0 inner max-w-[1440px] để chip flush với mép cover container, không bám viewport edge). Avatar pill chuyển sang light theme border-gray-200 bg-white (đồng bộ với CustomerHeader signed-in state) thay dark bg-black/40 cũ. Login button cũng dùng same class bg-gray-900 text-white như homepage.
Team grid sidebar + modal. TeamGridCard mới — sidebar card dưới OpeningHoursCard, 3×3 avatar grid (max 9, căn trái items-start, no hover bg per user feedback), overflow → button "View more (+N)" mở TeamModal. TeamModal (mới) render grid 2 cols mobile / 3 cols desktop, mỗi item avatar 96×96 + name + role. TeamSection (cards vertical với portfolio) bỏ khỏi page footer — giữ file vì còn export TeamMember type + có thể tái dùng cho dedicated portfolio view sau này.
Verification. booking-web: yarn lint 0/0, yarn build pass. booking-api: yarn lint 0 errors / 9 warnings pre-existing, yarn build pass. GitNexus detect_changes báo booking-web medium risk với 4 affected_processes (CustomerHeader, CustomerLoginPage, LocationSection, CustomerLayout) — tất cả step: 1 (entry-point touched, no downstream propagation). Manual confirm tại single-confirm gate per docs/rules/ship-workflow.md.
2026-05-12 — Loyalty hybrid UX (cross-salon dashboard, stamp voucher backend, invoice-style ticket)
Stamp voucher backend (PR1 + PR2). Hai migration đi cùng nhau: 20260512021823_add_loyalty_vouchers tạo bảng LoyaltyVoucher (state machine ACTIVE/RESERVED/REDEEMED/EXPIRED/CANCELLED + reward snapshot), 20260512023720_align_voucher_fk_actions align FK action (ON DELETE RESTRICT cho card/customer, SET NULL cho booking reserved). Aggregate mới LoyaltyVoucher với Crockford base32 code generator (alphabet bỏ I/L/O/U để tránh đọc nhầm trên ticket in/SMS) + resolveDiscount(total) apply reward type. VoucherIssuanceService cộng stamp ở booking level — fix bug pre-refactor mỗi service trong cùng booking lại stamp 1 lần (multi-service double-count) — và auto-issue voucher khi cycle hoàn thành. VoucherApplicationService.preview() validate ownership/status + tính discount cho UI; reserveInTx() dùng atomic UPDATE … WHERE status='ACTIVE' trong transaction để chặn double-redeem race. OnBookingVoucherLifecycleListener flip RESERVED → ACTIVE khi booking cancel, RESERVED → REDEEMED khi complete. VoucherDomainError filter đăng ký qua APP_FILTER + per-status error classes (VoucherAlreadyReservedError, VoucherCancelledError, VoucherAlreadyRedeemedError, VoucherExpiredError, VoucherNotOwnedError, VoucherInvalidTransitionError) để FE i18n granular. LoyaltyService.hasActiveStampCard(tenantId) exposed cho gate FE.
Customer voucher apply flow. POST /public/tenants/:slug/vouchers/preview (CustomerAuth required, throttle 60/min) trả discountAmount + payableTotal cho input "Apply voucher" trên booking page. BookingService.create nhận voucherCode — mutually exclusive với legacy redemption field, lock theo precedence để khỏi double-apply. Booking row pin discountAmount + appliedVoucherId (snapshot tại thời điểm book — voucher đổi reward sau khi reserve không ảnh hưởng booking đã chốt); computeDepositAmount dùng payableTotal thay vì raw total để deposit khớp với phần thật phải trả. OnBookingVoucherLifecycleListener (mới) handle lifecycle: BookingCompleted → REDEEMED, BookingCancelled → ACTIVE release. BOOKING_INCLUDE join appliedVoucher để admin drawer + customer detail render code + reward không cần fetch thêm. getPublicBooking trả appliedVoucher snapshot.
HttpExceptionFilterGlobal fallback cho VoucherDomainError. Pattern đã có sẵn trong Payment context (catch-all instanceof branch) được port qua loyalty. Trước fix, customer apply voucher RESERVED trả 500 INTERNAL_ERROR thay vì 409 LOYALTY_VOUCHER_ALREADY_RESERVED — root cause là NestJS filter resolution race giữa APP_FILTER provider và useGlobalFilters instance khi cả hai cùng đăng ký, request đi qua HttpExceptionFilterGlobal trước khi VoucherDomainErrorFilter bắt được. Fix: HttpExceptionFilterGlobal nhận thêm instanceof VoucherDomainError branch, mirror map VOUCHER_DOMAIN_STATUS từ filter cụ thể để response shape giống hệt. Duplicate có chủ đích (defense-in-depth) — filter cụ thể vẫn là source of truth, fallback chỉ chạy khi race xảy ra.
Cross-salon loyalty dashboard. /account?tab=loyalty viết lại từ "1 block per tenant" thành layout 4 section. Stats card top "X aktive bevis · minst Y kr verdi" (chỉ ACTIVE đếm; "minst" khi có FREE_SERVICE/PERCENT vouchers — chỉ DISCOUNT_AMOUNT contribute vào kr sum). Available vouchers section flat list ACTIVE only với salon name làm breadcrumb. Reserved vouchers section RIÊNG (không lump với Available — RESERVED = đã lock vào pending booking, customer không thể reapply, theo pattern Stripe/Sephora). Mỗi reserved row có deep-link "Reserved for booking #XXXXXXXX — open to cancel" tới booking detail để customer tự cancel → release voucher. Stamp progress compact 1-row/salon: dots inline khi 3 ≤ required ≤ 10, progress bar khi <3 hoặc >10 (1 lonely dot trên 1-booking-free promos nhìn như layout bug). Points balance compact 1-row/salon (parity với stamps). History accordion collapsed default — REDEEMED/EXPIRED/CANCELLED.
Invoice-style ticket layout. BookingTicket (customer) + BookingPaymentSummary (admin) rewrite cùng pattern Stripe/Square: Subtotal → Discount → Total → Paid → Balance due. Subtotal chỉ render khi có discount. Discount row mang voucher code chip emerald-50 mono font (separate badge, không inline "·" text). Total = post-discount = the bill. Paid render với negative sign + positive tone (subtracted, không phải khoản nợ). Balance due emphasis (text-base, semibold, warning tone khi >0, success khi =0) — trả lời thẳng câu hỏi "tôi còn nợ bao nhiêu?". Vertical full-width stack (flex justify-between) thay grid 2-col cũ pair Total|Discount + Payable|Paid awkward. Admin bỏ row "Paid tổng" standalone vì duplicate Deposit Paid intent row. deriveNextPayment + InvoiceClient thêm discountAmount param — Pay button charge payable - committed đúng (voucher discounted booking trước chỉ trừ phần đã pay nhưng vẫn tính trên raw total). BookingList PaymentCell total cũng trừ discountAmount → "Paid: 5 kr / 479 kr" sau 20% voucher thay vì "/ 599 kr".
Apply voucher gated by tenant.hasActiveStampCard. GET /public/tenants/:slug thêm field bool hasActiveStampCard. BookingPage ẩn input "Apply voucher" khi false (avoids dead UI trên salon chưa từng có voucher VISIT_BASED active). Customer chỉ thấy field khi thật sự có thể có voucher.
DEFER + UI hide non-PERCENT reward types. LoyaltyFormModal chỉ expose DISCOUNT_PERCENT cho new + edited cards. FREE_SERVICE + DISCOUNT_AMOUNT hidden (not deleted) do risk: FREE_SERVICE = 100% off high-ticket service, DISCOUNT_AMOUNT fixed øre có thể vượt total trên service nhỏ. Legacy card với reward type cũ render trong dropdown với suffix (legacy) để admin không kẹt UI, nudge upgrade. Backend domain vẫn handle 3 types — không break legacy data. DEFER tag loyalty-reward-types (UI gate) + loyalty-cancel-revert (stamps + clawbackPoints chưa wire listener cho admin force-cancel post-COMPLETED — voucher đã revert qua lifecycle listener nhưng stamp/points thì chưa). Cả 2 documented trong docs/flows/stamp-voucher-flow.md §18.
Seed demo voucher script. prisma/seed-loyalty-demo.ts (yarn seed:loyalty-demo <email>) sinh 7 vouchers/tenant cover full status × reward matrix (ACTIVE FREE/PCT/AMOUNT, RESERVED, REDEEMED, EXPIRED, CANCELLED). Code shape STAMP-SEED-XXXX — phải match normalizeVoucherCode (Crockford base32, no I/L/O/U). Prefix "DEMO" burned khi test vì chứa O.
Verification. booking-api: lint 0 errors / 9 warnings pre-existing, nest build pass, 57/57 public-booking.controller.spec xanh (thêm test dispatch hasActiveStampCard). booking-web: lint 0/0, next build pass. GitNexus impact reviewed cho symbol HIGH ở mỗi repo — không có symbol nào breaking; risk HIGH chỉ do scope refactor lớn (loyalty + invoice ticket pattern + dashboard).
2026-05-12 — Public booking UX polish (sticky tenant brand, service descriptions) + admin signin redirect race fix
Salon landing (/b/[slug]): platform header bỏ sticky để nhường top viewport cho tenant content. Thêm StickyTenantHeader on-scroll: 1 thanh gọn (avatar nhỏ + tên + clock) trượt vào top khi cuộn qua identity section (cover trên desktop, cover + meta block trên mobile). IntersectionObserver theo dõi sentinel <div data-sticky-tenant-trigger> đặt ngay sau khối identity — boundingClientRect.top < 0 → show. CustomerHeader switch relative (bỏ sticky) khi pathname khớp /^\/b\/[^/]+\/?$/. ServiceList card thêm description line (line-clamp-2, ẩn nếu null) giữa tên service và duration/price.
Booking page (/b/[slug]/book): platform header ẩn hoàn toàn (CustomerHeader return null khi pathname khớp /^\/b\/[^/]+\/book(\/.*)?$/). Identity row cũ trong BookingPage (avatar 64px + tên text-base/lg) thay bằng StickyBookingHeader component render ngoài max-w-[1440px] wrapper (size lg avatar 80px, tên text-lg → sm:text-2xl, industry subtitle + clock). Per user feedback: bỏ sticky + bỏ bg/border, scroll cùng content cho clean. ServiceItem cũng thêm description line khớp pattern ServiceList. Dọn unused imports (ArrowLeft, SalonAvatar, SalonClock, tIndustry) sau khi extract.
Admin signin redirect race fix: AuthContext.login / register đổi router.push("/admin") → window.location.href = "/admin" (hard redirect). Bug pre-fix: sau khi nhập sai pass lần 1 rồi nhập đúng pass lần 2, login API thành công nhưng URL ở nguyên /admin/signin cho tới F5. Root cause: race giữa setUser(u) (queue React state update) và router.push — AuthGuard mount trên /admin có thể đọc user=null trước khi commit propagate, kích hoạt window.location.href = "/admin/signin" trong guard, đẩy về signin. Hard redirect bypass React state propagation hoàn toàn — cookies httpOnly vừa set tự gửi, server proxy validate token rồi cho phép /admin, fresh React state. Khớp pattern đã có sẵn trong logout() (xem comment AuthContext.tsx:111 — "explicit hơn và tránh React state propagation race"). Removed unused useRouter() import. Auth tests: 7/7 SignInForm + 3/3 SignUpForm green.
Verification. booking-web: yarn lint 0/0, yarn typecheck clean, auth tests green. GitNexus impact upstream LOW cho CustomerHeader; detect_changes báo CRITICAL chủ yếu là symbol-name noise (register khớp react-hook-form's register !== AuthContext.register). Manual confirm tại single-confirm gate per docs/rules/ship-workflow.md §1.4.
2026-05-11 — Security audit pass: 5 MEDIUM fixes (batch B) + tenant settings backfill
Cherry-pick of behaviour-impacting + easy-win MEDIUMs from the audit. M1 (cancel/retry rate limit), M6/M8 (defense-in-depth repo scoping), L1–L4 (cosmetic) deferred to a later batch.
MEDIUM fixes:
preferredProviderenum whitelist (M2). Public booking DTO previously accepted any@IsString()value — guest could submit"DROP TABLE"or any junk and it would land inbooking.metadata.preferredProvider. Replaced with@IsIn(PUBLIC_FACING_PROVIDER_KEYS)against a literal-array of 6 PSP keys (BAMBORA, WORLDLINE, STRIPE, VIPPS, NETS, ADYEN). Manual rails (MANUAL_CASH/MANUAL_TERMINAL) intentionally excluded — they're admin-only and have no adapter registered for guest-initiated flows.public-booking.dto.ts.Bambora error log PII/PCI redaction (M4).
mapBamboraErrorResponsepreviously logged the full envelope on a WARN line —raw=${JSON.stringify(body)}— which would echo any card / wallet / customer-email field Bambora reflects back in an error response straight into the log aggregator (Datadog / Loki). Switched to an allowlist projection: onlymeta+http_statusreach the log sink. Anything else (e.g.cardno,walletname,customer.email,transaction.encryptedcardnumber) gets dropped at the redaction step.bambora/errors.ts+ regression test that asserts a body with4111111111111111does NOT appear in the warn output.Bambora callback rejects missing
txnid(M3). Pre-fix the adapter fell back to a synthetic${orderId}:unknownproviderEventId whentxnidwas absent. The(provider, providerEventId)unique index would then wedge the inbox slot: a subsequent legitimate callback (with a realtxnid) for the sameorderIdgotON CONFLICT DO NOTHING'd and the payment stayed INITIATED forever. Fix: returnWebhookVerificationErroroutright whentxnidis missing — Bambora retries the callback and the retry usually includes the field.bambora/adapter.ts.forceNewSessionno longer bypasses booking-status guard (M5). Public invoice page passesforceNewSession: trueunconditionally for every Pay click. The handler previously had no booking-status check, so a customer refreshing the invoice URL after the booking was CANCELLED / NO_SHOW could mint a brand-new Bambora session, complete a charge, and the booking listener would then try to projectdepositStatus = PAIDonto a dead row. AddedBookingTerminalForPaymentErrorthrown whenbooking.status ∈ {CANCELLED, NO_SHOW}— applies regardless offorceNewSession. OPEN statuses (PENDING / CONFIRMED / ARRIVED / IN_PROGRESS / COMPLETED) still pass per the original "owners decide when to collect" rationale. Parameterized regression test covers bothforceNewSession: trueandfalse.initiate-remaining-payment.handler.ts.Webhook inbox mutations scope tenantId (M7).
markProcessed/markFailed/attachPaymentIdpreviously usedprisma.paymentWebhookInbox.update({ where: { id } })— bare id-only mutations that the cross-tenant audit (general-purpose agent) flagged as a defense-in-depth gap. Switched toupdateMany({ where: { id, tenantId } })so every write is scoped explicitly. Port signatures now requiretenantId; callerProcessWebhookInboxServicethreadsentry.tenantIdthrough. Three regression tests verify the where clauses carry tenantId.prisma-webhook-inbox.repository.ts+ port + caller.
Tenant settings backfill migration (critical follow-up to batch A's HIGH #11):
The strict parseTenantSettings validator shipped in batch A throws TENANT_SETTINGS_CORRUPT when any required key is missing. Legacy tenants pre-date several of the now-required keys — specifically emailNotifications (added with the branded email pipeline), and conservatively any other key that might be absent on older rows. Pre-batch-A the validator was a silent cast, so the gap never surfaced.
Fix: migration 20260511210507_backfill_tenant_email_notifications uses the defaults || settings jsonb merge pattern (PostgreSQL || takes RIGHT side's value on key collision) to fill ONLY missing keys — existing values win. Idempotent — running twice changes nothing. Covers all 14 required keys to defend against any other latent gap. Seed.ts also updated to include emailNotifications: true so fresh local DBs match.
Production safety: prisma migrate deploy runs BEFORE app start in CI/CD (see docs/operations/docker-deploy.md), so backfill completes before any app code reads the row — end users never see TENANT_SETTINGS_CORRUPT.
Verification. yarn lint 0 errors, yarn build green, 1764/1766 unit tests passing (2 pre-existing intentional skips: bambora/adapter.integration.spec.ts — live-sandbox-only, skip-when-no-creds design). +4 new tests (M3 missing-txnid, M4 redaction, M5 parameterized × 2).
2026-05-11 — Security audit pass: 5 CRITICAL + 8 HIGH fixes (booking + payment + email)
Multi-agent security review (booking module, payment module, cross-cutting tenant-scope audit) surfaced 6 CRITICAL + 10 HIGH findings on booking + payment. This batch closes 5 CRITICAL + 8 HIGH end-to-end with tests; the remaining items are MEDIUM/LOW for a follow-up batch.
CRITICAL fixes:
CustomerServicecross-tenant leak (PII + booking history).findById(id)previously didprisma.customer.findFirst({ where: { id } })with no tenant scope. Any salon OWNER/STAFF who guessed or harvested a customer UUID could read name/email/phone PLUS the last 20 bookings across every tenant.update(id, dto)had the same gap, allowing rename + email/phone takeover prep on another salon's customer.findOrCreateByContact+ admincreate()left a P2002 enumeration oracle (admin types Bob's phone → 409 reveals "exists somewhere"). Fix: every method now requirestenantIdand scopes throughbookings.some({ tenantId }) OR tenantCustomers.some({ tenantId })(with soft-delete bridge exclusion).create()became idempotent — when phone/email already maps to a global Customer it auto-links viaTenantCustomerinstead of throwing P2002, mirroring the guest-book path.customer.service.ts+customer.controller.ts+ spec.Concurrent refund race over-refunds the customer.
RefundPaymentHandlerread parent + sibling refunds OUTSIDE any lock, then called PSP, then saved child + parent. Two simultaneous refund commands with distinct idempotency keys both passed the cumulative-cap check and both credited the card. Fix: newPaymentRepositoryPort.withParentLock(parentId, tenantId, fn)opens an interactive transaction, acquiresSELECT ... FOR UPDATEon the parent row, re-reads refund children inside the lock, and exposes a tx-boundsave()to the callback. The PSP call lives inside the lock window so a second caller blocks for ~2-5s, then recomputes cap against the freshly-written sibling. In-memory + Prisma adapter implementations both ship; existing event-drain semantics preserved (drain only after outer commit). Regression test injects a synthetic concurrent sibling intoctx.refundsand asserts the second refund throws.Bambora webhook replay window unbounded. The Bambora adapter passed
timestamp: nulltoverifyWebhook, so unlike the Worldline adapter (which enforces a 5 min replay window) any valid Bambora callback could be replayed indefinitely until the(provider, providerEventId)unique constraint happened to dedup it. Fix: newparseBamboraCallbackTime(params)readstime=YYYYMMDDHHmmssordate+timesplit form (both already covered by the MD5 hash so an attacker can't strip them), the adapter rejects callbacks outside ±10 min viaWebhookVerificationError. When the timestamp field is absent we fall through to the unique-constraint guard rather than break legitimate callbacks from older Bambora endpoints. 9 new tests insignature.spec.ts+adapter.spec.ts.Public POST
/bookingsreturned the full booking row. The success response was{...booking, requiresPayment, paymentPollUrl}— spreading a Prisma row that includescustomerPhone,customerEmail, thecustomerrelation, themetadata.snapshot(depositPolicy + preferredProvider),depositStatus,tenantId, and processedFor* flags. Guest bookings leaked their own PII back to the public network; authenticated bookings leaked the full Customer row. Fix: explicit whitelist (id,status,startTime,endTime,requiresPayment,paymentPollUrl) mirroring thegetPublicBookingprojection. Regression test injects a fat row and asserts the response keys equal exactly the whitelist.applyRefundProjectionPARTIALLY_REFUNDED divergence. Handler + factory acceptedparent.status ∈ {CAPTURED, PARTIALLY_REFUNDED}butapplyRefundProjectiononly allowedCAPTURED— so a hypothetical legacyPARTIALLY_REFUNDEDparent would succeed at PSP + child save then throw during the projection write, leaving DB diverged from PSP. Post refund-as-row refactor the parent staysCAPTUREDforever (Stripe-style), so the legacy branch in the handler + factory is dead defense — removed to align with the actual invariant.
HIGH fixes:
Terminal-state immutability.
BookingService.updatehad no guard against COMPLETED / CANCELLED / NO_SHOW bookings — STAFF could retroactively edit price / items / time / payment fields on closed accounting records. NewBOOKING_TERMINAL_STATE_IMMUTABLEerror code + guard at the top ofupdate(); parameterized test asserts noprisma.booking.updatecall for each terminal status.Customer portal
getBookingsprojection leak.data.map((b) => ({ ...b, tenant: ... }))spread the full Prisma row withmetadata.snapshot,depositStatus,processedFor*,isPaid/paidAmount/paidMethod. Replaced with explicit whitelist (id,status,depositStatus,startTime,endTime,createdAt,notes,items,tenant); leak-check test asserts response keys exactly equal the whitelist.parseTenantSettingswas an unsafe cast.return raw as TenantSettingsswallowed corruption silently — a missingdepositEnabledwouldundefined-default downstream to "deposit disabled" without admin intent (violating thefeedback_no_fallback_settingsrule). Replaced with strict structural validation: every required key present, every boolean a boolean, enums validated, numbers finite. NewTenantSettingsCorruptErrorclass. 7 validator tests cover null / wrong-type / missing-field / enum violation. Existing fixtures acrossbooking.service.spec,availability.service.spec,public-booking.controller.specextended to satisfy the new completeness requirement.Booking TOCTOU race (double-booking window).
checkConflictran BEFORE the$transactionthat created the row; two concurrent requests for the same slot both passed the check, both committed. Fix: inside the create transaction,pg_advisory_xact_lock(hashtext('booking:${tenantId}:${resourceId}'))per resource serializes concurrent inserts on the same resource. After acquiring the lock we re-run the conflict check via the tx client so a waiter sees the freshly-committed sibling and bails withBOOKING_CONFLICT. The original pre-tx check stays as a fast-fail. Two new tests asserttx.$executeRawis called + the inside-tx re-check throws when a synthetic concurrent sibling is present.Bull Board
admin:adminonly console.warn'd. The queue dashboard exposes rawPaymentWebhookInboxpayloads (signed Bambora callbacks, card last-4, txn ids). Defaults now fail-fast (throw Error('BULL_BOARD_CREDENTIALS_REQUIRED')) whenNODE_ENV=production— dev still gets the warning so local setup isn't blocked.Payment webhook controller had no per-endpoint throttle. Global 100/min/IP was too loose for a public endpoint that returns 401 vs 404 (tenant enumeration oracle) and that Bambora retries up to several times per event. Added
@Throttle({ default: { ttl: 60_000, limit: 60 } })at controller level — generous for legitimate PSP traffic, tight enough that enumeration costs >1 min/60 probes.findExpirablecron-scoped exception explicitly documented. This method is the only intentional cross-tenant scan onPaymentRepositoryPort. Strengthened JSDoc to spell out the per-tenant safety argument (writes route through the aggregate's tenantId, events carry tenantId from the aggregate) and added a callsite comment inAuthorizationExpiryServicewarning that any future write side-effect inside the sweep MUST scope topayment.tenantIdor re-scope by iterating per-tenant.on-booking-completed(tenant-customer) listener tenantId guard. Listener read booking by id-only then upsertedTenantCustomer— a forged or duplicate event with the wrongevent.tenantIdwould have landed the metric upsert on the wrong tenant. Addedevent.tenantId === booking.tenantIdassertion (matching sibling listeners). CAS claim + rollback now also scope bytenantIdfor defense-in-depth. Spec updated.Email date format mismatch with frontend.
formatDateOnlyrendered "mandag 11. mai" (no year),formatDateTimerendered "tirsdag 12. mai 2026 kl. 15:00" — diverged from the app-wide standarddd/MM/yyyy/dd/MM/yyyy HH:mmdocumented onbooking-web/src/lib/timezone.ts::formatDateTimeInZone. Recipients reading the email then checking the admin table saw two different strings for the same instant. Switched both helpers to locale-neutral en-GB format ("11/05/2026","11/05/2026 09:00") so the salon owner sees identical strings everywhere. 12 snapshots regenerated.
Verification. yarn lint 0 errors, yarn build green, full unit suite 1760/1762 passing (2 unrelated skips). +25 new tests (security regressions + leak checks + lock-contract assertions).
2026-05-11 — Refund-as-row follow-up fixes (collision + Stripe-style semantics)
Three follow-up fixes after the morning's refund-as-row refactor shipped to dev. Surfaced via live testing on app-dev.novagoo.com.
1. UNIQUE constraint collision (CRITICAL — caused state divergence with PSP). RefundPaymentHandler copied result.providerRefundId onto the new REFUND child's providerRef. For Bambora Classic + manual providers the adapter falls back to the parent transaction id (Bambora's /credit endpoint has no separate refund identifier — providerRefundId = response.id ?? input.providerTransactionId), so saving the child threw P2002 on the (provider, provider_transaction_id) unique index AFTER the PSP had already credited the customer. The user clicks Refund → Bambora ledger debits 5 NOK → local handler crashes during save → transaction rollback → 502 to the user → retry hits Bambora 114 "Available for credit: 0" because Bambora already credited it. Diagnosed via the Bambora /transactions/{id}/transactionoperations history which shows the Credit op attributed to salon-booking/1.0 at the exact millisecond local DB has no refund event. Fix: default refundProviderRef = ProviderRef.synthetic(providerKey) (NULL tx id, allowed by the unique index since Postgres treats multiple NULLs as distinct) and only overwrite when the adapter returns a refund id genuinely distinct from the parent's (Stripe / Adyen). Webhook processor applyRefundFromWebhook mirrored. New ProviderRef.synthetic(providerKey) factory + mapper fallback (when both providerTransactionId and providerSessionId are NULL — also covers backfilled REFUND rows that the migration created with both NULL because the unique constraint forbade copying the parent's tx id). Two regression tests on capture-void-refund.handlers.spec.ts covering Bambora-style (parent tx reuse) and Stripe-style (distinct refund id) paths.
2. Stripe-style charge semantics — parent stays CAPTURED forever. Payment.applyRefundProjection no longer mutates the parent's status to REFUNDED / PARTIALLY_REFUNDED. The charge row stays in CAPTURED ("Paid" in the admin UI) so the operator sees the captured event explicitly, and refund detail lives on sibling REFUND children. Booking-side projection (OnPaymentStateProjectionListener) was already event-driven — it listens on PaymentRefunded / PaymentPartiallyRefunded event names, not the parent row's status field — so booking.depositStatus continues to flip correctly without any change there. Matches Stripe / Square dashboards. Domain spec + handler spec + integration spec all updated to assert the new "charge stays CAPTURED + refund child created" invariant.
3. List endpoint shows REFUND rows as first-class entries. listByTenant now defaults to including REFUND children (Stripe / Square dashboard style — each refund is its own line so the operator sees the event explicitly, not hidden behind a parent badge). Callers can opt OUT via includeRefunds: false for narrow charges-only views. The list query handler still hydrates refund children only for charge rows (REFUND rows in the page have no grand-children to fetch) so the per-row refundedAmount aggregate is correct without N+1.
FE UX fixes (booking-web):
PaymentStatusBadgebecomes intent-aware. A REFUND child physically carriesstatus = CAPTURED("the refund completed successfully") but the operator should see it as "Refunded" — override only whenintent === 'REFUND' && status === 'CAPTURED'. Pending / failed refunds render their real status. All four callsites (PaymentList, PaymentDetailDrawer, BookingPaymentHistory, BookingPaymentSummary) updated to passintent.BookingDrawergains abookingIdprop that takes precedence overbookingand routes through a newBookingDrawerEditByIdWrapper. This wrapper fetches the booking itself and renders a loading skeleton — it NEVER falls through to create mode, even if the fetch is slow or 404s. Fixes the regression where clicking the "Booking" link onPaymentDetailDraweropened the "Select service" create flow while the fetch was in flight (PaymentsContent'sisOpenguard used strict!== null, butuseBooking.datastarts atundefined, which!== nullevaluates truthy).- New
DrawerNotFoundShellrenders a friendly "Booking unavailable" panel when the fetch returns 404 / forbidden — surfaces when a payment references a booking that was deleted viaseed.tsreset (or belongs to another tenant). Avoids holding the skeleton open forever. PaymentsContentsimplified — drops its ownuseBookingcall and passesbookingId={bookingViewId}directly to BookingDrawer.
i18n additions: bookings.drawer.notFoundTitle / notFoundMessage and payments.intent.REFUND in both en + nb. payments.detail.refundHistoryHeading / refundBackfilled already shipped in the morning commit.
Results: API 875/877 unit (2 unrelated skips) + 16/16 payment-related e2e green. Web 171/171 vitest green. yarn lint + yarn build clean both repos.
Caveat: any payment with refunded_amount > 0 from BEFORE the morning's refactor was backfilled with a synthetic REFUND child (provider tx id NULL, metadata.backfilled = true). On dev DB this hit PAYMENT_INVALID_PROVIDER_REF until today's mapper synthetic fallback shipped. Production had zero such rows so the deploy is safe even without the dev-only state-divergence recovery for payment 019e15ed-c08f-75d7-822f-27cfc3382706 (Bambora-side already credited via the morning's failed save; that single row stays diverged on dev — sandbox, not worth manual reconcile).
2026-05-11 — Refund-as-row refactor: per-event audit on a separate Payment child
The before: RefundPaymentHandler mutated the captured Payment row in place — incremented refundedAmount, walked the status CAPTURED → PARTIALLY_REFUNDED → REFUNDED, fired the matching event. One row carried both the original charge and the running refund total. Per-refund detail (when, why, how much each time) was not persisted; the only "refund history" was the cumulative number.
The after: every refund now writes a new Payment row with intent = REFUND and a parentPaymentId self-FK pointing back at the captured charge. The parent's status is still walked forward (PARTIALLY_REFUNDED / REFUNDED) for the existing booking + notification listeners — but the source of truth for the refund amount lives on the children, queried as sum(refunds.capturedAmount) WHERE parent_payment_id = ?. Matches Stripe / Adyen / Square / PayPal / Vipps. Plan + decision rationale lives in docs/architecture/refund-as-row-refactor.md.
Migration strategy — big-bang, not phased. Refund volume on the shared dev DB was tiny (5 rows total) so the original 5-phase dual-write plan was overkill. One Prisma migration adds the parent_payment_id column + index, backfills one synthetic REFUND child per legacy refundedAmount > 0 row (provider tx id NULL, metadata.backfilled = true), and drops the refunded_amount column. The enum value PaymentIntent.REFUND lives in a separate prior migration so Postgres can commit ALTER TYPE ADD VALUE before the schema migration references it.
Domain rewrite (booking-api/src/core/payment/domain/payment.ts):
- Drop the
Payment.refund(amount, reason, at)instance method and the_refundedAmountprivate field. - Add static factory
Payment.createRefund({parent, amount, reason, at, idempotencyKey, providerRef, metadata?})that returns a CAPTURED child withintent = REFUNDandparentPaymentId = parent.id. Asserts cumulative-cap, currency match, parent ∈ {CAPTURED, PARTIALLY_REFUNDED}, and that the parent itself is not a REFUND (no refund-of-refund). - Add
Payment.applyRefundProjection({cumulativeRefunded, refundedNow, reason, at})that walks the parent's status and emits the legacyPaymentPartiallyRefunded/PaymentRefundedevents with the exact same payload shape as before. - Add helper
Payment.aggregateRefunded(parent, refunds)so call-sites (handlers + listeners) can safely sum children without re-deriving the formula. - New event
PaymentRefundCreatedon the child carriesparentPaymentId, the per-event delta, reason, and the provider refund tx id (which may equal the parent tx id for Bambora Classic / manual providers).
Handler rewrite (refund-payment.handler.ts): fetch existing refund children → cumulative-cap check → call provider adapter (skipped for MANUAL_CASH / MANUAL_TERMINAL) → save child row via Payment.createRefund → call parent.applyRefundProjection → save parent. Result envelope adds refundPaymentId + parentPaymentId for the FE. The webhook processor (process-webhook-inbox.service.ts) routes its refund branch through the same factory so PSP-initiated refunds also produce child rows; the idempotency key wh-refund-${inboxId} collides on retry via the (tenantId, idempotencyKey) unique index.
Reader updates — every place that previously read payment.refundedAmount.amount now subtracts REFUND children directly:
booking.service.previewCancellationaggregates children of the active payment before computing the FULL_REFUND amountbooking.controller.findAllpre-buckets refund children by parent id so the admin list cell renders the correct net amount with no extra querypayment-integration.service.onBookingCancelledqueriesfindRefundsByParentfor the captured charge before deciding VOID vs FULL_REFUNDinitiate-remaining-payment.handler+record-manual-payment.handlertreat REFUND rows as negative contributions toearmarkedin the remaining-amount mathpublic-booking.controller.getPublicBookingflips itssettledRows.reducefromcap - reftointent === 'REFUND' ? -cap : capsuperadmin.serviceaggregateRevenue runs two parallelpayment.aggregatecalls (one excluding REFUND, one including only REFUND) and subtracts; trend + tenants per-row use the same signed-sum pattern
DTOs (booking-api/src/core/payment/interface/admin/dto/payment.dto.ts + the two public controllers):
- Admin
PaymentDtogainsparentPaymentIdandrefunds: RefundLineDto[](per-event timeline: amount, currency, reason, createdAt, providerTransactionId, backfilled).refundedAmountis still on the wire as a derived aggregate so legacy consumers don't break. ListPaymentsHandlerhydrates refund children in batch for every charge row in the page (Promise.all(chargeIds.map(findRefundsByParent))) — no N+1.GetPaymentsByBookingHandlerreturns charges + their refund children flat (FE filters as needed).- The public booking-payment list filters REFUND rows out by default; the
refundedAmounton each charge is the aggregate over its children. - Repo's
listByTenantexcludes REFUND children by default; newincludeRefunds: booleanfilter opts in for the timeline view.
FE migration (booking-web):
src/types/payment.tsaddsRefundLineinterface,Payment.refunds+Payment.parentPaymentId, andPaymentIntent.REFUND. Manual mirror until OpenAPI regen catches up.usePayments.RefundPaymentResultaddsrefundPaymentId+parentPaymentId.PaymentDetailDrawerrenders a new "Refund history" section listing every refund row with date, reason, amount, and a "migrated from legacy data" hint on backfilled rows.- No change to
PaymentList,BookingPaymentSummary,BookingTicket, ornext-payment— they keep reading the derivedrefundedAmountaggregate exposed on the DTO, so the refactor is invisible to them. messages/en.json+messages/nb.jsonaddpayments.detail.refundHistoryHeading+payments.detail.refundBackfilled.
Test rewrite:
payment.spec.ts: full rewrite of refund cases — assertPayment.createRefundreturns a child with the expected parent linkage + status + currency, assertapplyRefundProjectionwalks the parent and emits the legacy events, plus rejection paths for over-refund / wrong currency / wrong parent state / refund-of-refund.capture-void-refund.handlers.spec.ts: refund cases now assert a child row was created (viafindRefundsByParent) and the parent status walked forward. Manual provider tests still assert no adapter call.payment-integration.service.spec.ts+payment.controller.spec.ts+queries.spec.ts+superadmin.service.spec.ts+booking.service.spec.ts: every test fixture that previously seededrefunded: Non a parent row now seeds a sibling REFUND child instead, mirroring the production handler. Mocks for the new query handler shape ({ payment, refunds }) + list query result shape ({ items, total, refundsByParent }) updated everywhere.- Pre-existing e2e drift fixed in passing:
booking-outboxwalks through the current status machine (PENDING → CONFIRMED via admin force → IN_PROGRESS → COMPLETED) and expectscaptureMode = AUTOper ADR-001;public-bookingfixture marks the tenantonboardedAt: new Date();customer-authseeds the BookingItem snapshot columns required since the 2026-05-10 hybrid migration.
Results:
- API: 1729/1731 unit tests pass (2 unrelated skipped), 16/16 payment-related e2e pass (booking-outbox + booking-snapshot + admin-notifications).
yarn build+yarn lintclean (0 errors, only pre-existing warnings in upload/email service). - Web: 171/171 unit tests pass.
yarn build+yarn lintclean. - DB: 5 historical refunded rows on the dev DB backfilled into 5 REFUND children, verified via
SELECT id, intent, status, parent_payment_id, metadata->'backfilled' FROM payments WHERE intent='REFUND'.
Notes / caveats baked into the migration:
- Backfilled REFUND rows carry
provider_transaction_id = NULLbecause the legacy column did not record per-refund PSP ids; the unique(provider, provider_transaction_id)constraint forbids copying the parent's tx id.metadata.backfilled = trueflags them in the drawer's "Refund history" section. - Manual providers + Bambora Classic reuse the parent
providerRefbecause there is no PSP boundary to call (Bambora's API has no separate refund tx id). - Public-booking + customer-auth e2e suites still have ~12 + ~4 failures due to per-route Throttler buckets sharing state via Redis — pre-existing infra noise unrelated to this refactor. Needs a separate test-infra change (override
APP_GUARDtoken or flush Redis between specs). Booking-outbox + booking-snapshot + admin-notifications + every payment / booking / superadmin unit spec passes.
2026-05-11 — Customer delete refactor: tenant-scoped unlink (not global GDPR)
Operator caught a design slip in Phase A: the customer-delete UI was described as "GDPR right-to-be-forgotten" and the API scrubbed PII / revoked tokens / stamped Customer.deletedAt globally. Wrong scope — a tenant admin must not be able to delete data the customer still uses at another salon, and must not be able to revoke the customer's global login. GDPR requests belong to the platform / data controller, not individual salons.
Refactor:
- Schema: drop
Customer.deletedAt+ index. AddTenantCustomer.deletedAt+(tenantId, deletedAt)index. Migration20260511081635_move_customer_soft_delete_to_tenant_bridge(usesDROP COLUMN IF EXISTSso a fresh deploy is identical to a deploy that already applied Phase A's migration). CustomerService.delete(customerId, tenantId)now upserts the TenantCustomer bridge withdeletedAt = now()— does not touch the global Customer row, does not revoke refresh tokens, does not scrub PII. Idempotent for guests who don't have a bridge row yet.CustomerService.findAllByTenantfilters out customers whose TenantCustomer row at this tenant hasdeletedAt != null(NOT: { tenantCustomers: { some: { tenantId, deletedAt: { not: null } } } }).Customerremoved from the soft-delete extension (booking-api/src/prisma/soft-delete.extension.ts) and fromPrismaService.SOFT_DELETE_MODELS. Extension now scopes to Service + Resource only; TenantCustomer's filter is manual because the customer-list query joins viabookings.some.tenantId, which the extension can't express as an auto-injectedwhere.- Error code
CUSTOMER_NOT_AT_TENANTremoved (no longer needed — the action no longer guards by booking presence; the upsert handles non-existence). - Customer controller swagger summary: "Remove a customer from this tenant's list — does NOT affect the global Customer row."
Frontend:
useDeleteCustomerunchanged (still hitsDELETE /customers/:id).- Copy changed across en/nb:
customers.deleteSuccess: "Customer deleted" → "Customer removed from your list"customers.deleteConfirmTitle: "Delete this customer?" → "Remove this customer from your list?"customers.deleteConfirmMessage: dropped GDPR phrasing, now "{name} will disappear from your customer list, search and pickers. Their past bookings at your salon stay intact for accounting."
ConfirmDialog.messageacceptsReactNode(wasstring);DeleteButton.confirmMessagelikewise. All three form modals (Service / Staff / Customer) callt.rich('deleteConfirmMessage', { name, strong: chunks => <strong>{chunks}</strong> })so the entity name reads as bold in the dialog.- Spec
soft-delete.extension.spec.tsrewritten to seedService(one of the two soft-delete models) instead ofCustomer, with a one-time Tenant seed inbeforeAll.
Files —
- booking-api:
prisma/schema.prisma,prisma/migrations/20260511081635_move_customer_soft_delete_to_tenant_bridge/migration.sql(NEW),src/prisma/soft-delete.extension.ts+ spec rewrite,src/prisma/prisma.service.ts,src/core/customer/customer.service.ts+ service/controller specs,src/core/customer/customer.controller.ts,src/common/constants/error-codes.ts(drop CUSTOMER_NOT_AT_TENANT). - booking-web:
src/components/ui/ConfirmDialog.tsx(ReactNode message),src/components/ui/DeleteButton.tsx(ReactNode message),ServiceFormModal.tsx/StaffFormModal.tsx/CustomerFormModal.tsx(t.richwith<strong>),messages/en.json+nb.json(copy rewrite + strip CUSTOMER_NOT_AT_TENANT). - docs:
docs/architecture/soft-delete-pattern.md(rewrite Customer section), this changelog entry.
Results — 146/146 API specs covering the touched modules pass (src/core/customer src/core/service src/core/resource src/prisma). Web yarn build + yarn lint clean. The original Phase A entry below is preserved for archeology; treat this entry as the canonical Customer-delete behaviour going forward.
2026-05-10 (Phase A) — deletedAt soft-delete pattern (Service / Resource / Customer)
Plan A from the same session that shipped Phase B (BookingItem snapshot). The schema was overloading isActive for both "tạm ngưng" (pause/resume) and "đã xoá" (gone). This phase introduces an explicit deletedAt: DateTime? column on Service / Resource / Customer plus a Prisma client extension that auto-filters deletedAt: null on every read/write, so soft-delete is invisible to call-sites. Pattern doc: docs/architecture/soft-delete-pattern.md.
Schema — single migration 20260510175118_add_deleted_at_soft_delete adds deleted_at TIMESTAMP(3) + (tenant_id, deleted_at) index on services/resources, (deleted_at) index on customers. No backfill (NULL = live). Migration name has a manual timestamp prefix > Phase B's last migration (lesson from feedback_prisma_migration_order — migrate deploy sorts lexicographically, not chronologically; phased migrations must have folder names that sort in intent order).
PrismaService extension — new booking-api/src/prisma/soft-delete.extension.ts (~95 LoC) defines a Prisma.defineExtension({ query: { service|resource|customer: { $allOperations } } }) that injects deletedAt: null into where for findFirst|findFirstOrThrow|findMany|count|aggregate|groupBy|update|updateMany|delete|deleteMany. findUnique / findUniqueOrThrow cannot accept non-unique fields, so the extension post-filters: it runs the query as-is and drops the result when deletedAt != null. Bypass with explicit where: { deletedAt: ... } (any explicit value wins) for audit views. PrismaService wires it in two places: copies the extended service / resource / customer model proxies onto this (so prisma.service.findMany(...) call sites get the auto-filter for free) and overrides $transaction(callback) to use the extended client (without this tx.service.findMany(...) would silently bypass inside transactions).
Behaviour change per model (final decision: always soft-delete, no try-hard-delete) —
- Service.delete: was
try-hard-delete → catch P2003 → throw ConflictException('SERVICE_IN_USE')(admin had to flipisActive=falseinstead). Now always setsdeletedAt = now()— single predictable path, no FK try/catch. Image'sUploadedFilereference is released.SERVICE_IN_USEerror code + en/nb i18n removed; callers always get204. - Resource.delete: new endpoint
DELETE /resources/:id(was missing — admin's only path was theisActivetoggle). Always soft-deletes: stampsdeletedAt, clearsuserId(releases@uniqueso the underlying User can be re-linked to a new resource later), flipsisActive=false. We do not cascade-clean child rows (ResourceSkill/ResourceSchedule/ResourceScheduleOverride/TimeOff/PortfolioItem) — they stay queryable for audit and the soft-deleted parent simply makes them invisible in admin lists. - Customer.delete (GDPR): new endpoint
DELETE /customers/:idfor the right-to-be-forgotten flow. Customer is global (one row, many tenants), so we require the caller's tenant to have at least one booking with the customer (CUSTOMER_NOT_AT_TENANT→ 403 otherwise — prevents tenant A deleting tenant B's customer). Action: revoke everyCustomerRefreshToken, bumptokenVersion(kills any in-flight access tokens), NULL outemail/phone/googleId/password/notes(frees the@uniqueconstraints so future signups don't 409), setname = 'Deleted Customer'+deletedAt = now(). Booking history snapshots (already incustomerName/customerPhone/customerEmail) keep accounting trail intact under Bokføringsloven § 13.
Why always-soft (the operator overrode the initial "try hard first" plan): DROP-then-regret is worse than the small DB cost of keeping an extra row around. There is no scenario where the admin benefits from the row physically disappearing. A single code path is also easier to audit and easier to reason about under future schema changes.
Frontend (booking-web) —
- New shared
DeleteButtoncomponent (components/ui/DeleteButton.tsx) — red text-button with trash icon + built-inConfirmDialog(variant=danger). Encapsulates the dialog open/close state so callers only passconfirmTitle,confirmMessage,onConfirm. Defaults label tocommon.delete("Delete" / "Slett") so admin forms read consistently. Replaces the inline duplicate that the first pass had in three form modals. - New hooks
useDeleteStaff(hooks/useStaff.ts) anduseDeleteCustomer(hooks/useCustomers.ts). ExistinguseDeleteService(hooks/useServices.ts) wired into the modal (hook predates this work but the UI was missing). ServiceFormModal/StaffFormModal/CustomerFormModalfooters (edit mode only) gain the sharedDeleteButton. Operator's earlier ask: tenant didn't have a way to delete a service.- Per-module copy lives in i18n:
deleteSuccess,deleteConfirmTitle,deleteConfirmMessage(with{name}interpolation) — no more module-specific "Delete permanently" / "Delete (GDPR)" labels; the trigger button always reads "Delete" viacommon.delete. EachdeleteConfirmMessageexplains the consequence (snapshot keeps history; not reversible; use 'Pause' for temporary). - Removed
errors.SERVICE_IN_USE. Addederrors.CUSTOMER_NOT_AT_TENANT. - Service controller swagger summary updated. Web
api.generated.tsregen pending (nextyarn generate:openapicycle).
Audit / restore UI deferred — no admin has asked yet. Pattern doc has the bypass syntax; ops can run a psql query if needed in the meantime.
Tests — new specs: booking-api/src/prisma/soft-delete.extension.spec.ts (4 cases against real Postgres — findMany default filter, findUnique post-filter on deleted row, explicit-bypass for audit, filter inside $transaction callback). Updated specs: service.service.spec.ts (delete block — old SERVICE_IN_USE test replaced with soft-delete-on-P2003), resource.service.spec.ts (+4 delete cases), customer.service.spec.ts (+3 delete cases), controller specs +1 case each. Web lint 0/0, API nest build clean.
Files —
- booking-api:
prisma/schema.prisma(deletedAt on 3 models + indexes),prisma/migrations/20260510175118_add_deleted_at_soft_delete/migration.sql(NEW),src/prisma/soft-delete.extension.ts(NEW),src/prisma/soft-delete.extension.spec.ts(NEW),src/prisma/prisma.service.ts(wire extension + $transaction override),src/core/service/service.service.ts+service.controller.ts+service.service.spec.ts,src/core/resource/resource.service.ts+resource.controller.ts+ service/controller specs,src/core/customer/customer.service.ts+customer.controller.ts+ service/controller specs,src/common/constants/error-codes.ts(dropSERVICE_IN_USE, addCUSTOMER_NOT_AT_TENANT). - booking-web:
src/hooks/useStaff.ts+useCustomers.ts(+useDelete*),src/components/staff/StaffFormModal.tsx+customers/CustomerFormModal.tsx(delete button + ConfirmDialog),messages/en.json+nb.json(4 keys × 2 modules + error code swap). - docs:
docs/architecture/soft-delete-pattern.md(NEW), this changelog entry.
2026-05-10 — BookingItem snapshot (hybrid pattern) + Service hard-delete
Hybrid snapshot on BookingItem so receipts/invoices/customer history stop drifting when the salon edits the catalog. Schema now mirrors the industry-standard hybrid pattern (Stripe Invoice / Shopify Order / Mindbody Visit / Booksy ServiceVersion): hot fields flatten to indexable columns, cold/audit fields go in JSONB.
Schema (3-step migration, online-safe deploy) — booking-items gains service_name, service_currency, service_snapshot (NOT NULL), resource_name, resource_snapshot (nullable for unassigned items). Step 1 adds nullable cols + safe defaults; step 2 backfills 109 legacy rows from live Service/Resource via raw SQL (joins ServiceCategory for categoryName, AccountingAccount → Tax for taxRate, falls back to booking.created_at for capturedAt); step 3 enforces NOT NULL + drops defaults. serviceSnapshot JSONB shape: { id, name, description, duration, price, currency, imageKey, categoryId, categoryName, taxRate, metadata, capturedAt }. resourceSnapshot: { id, name, type, color, avatarKey, jobTitle, metadata, capturedAt }. Validators in booking-snapshot.types.ts (parseServiceSnapshot / parseResourceSnapshot) — manual TS guards because the project uses class-validator, not Zod.
Write sites — 3 paths populate snapshot atomically with the BookingItem row. (1) BookingService.resolveItems() extends to fetch services WITH category + accountingAccount.tax includes and to batch-fetch resources by id; returns ResolvedItem with 5 new fields (serviceName, serviceCurrency, resourceName, serviceSnapshot, resourceSnapshot). Helpers buildServiceSnapshot() / buildResourceSnapshot() use the booked duration/price (which respect per-item overrides) instead of re-reading the live Service columns. (2) BookingService.walkIn() extends its inline service fetch with the same includes + adds a resource fetch, builds snapshot inline. (3) BookingService.update() with new items reuses resolveItems(). Prisma.JsonNull for unassigned resourceSnapshot so Prisma doesn't error on undefined.
Read sites — split between snapshot (immutable) and live join (current state). Switched to snapshot: EmailBookingPayloadBuilder (currency now items[0].serviceCurrency not tenant.settings.currency so a re-sent confirmation reproduces the exact transaction even after the tenant changes default currency); CustomerPortalService.getBookings ("my bookings"); PublicBookingController.getPublicBooking (confirmation page, invoice page, ticket QR — backend wraps snapshot fields back into the service: {id, name} shape so the public DTO contract stays stable, frontend ticket renders unchanged). BookingsSection.tsx customer history reads serviceName / resourceName direct. Kept on live join (UX requires current state): calendar, dashboard UpcomingBookings, booking drawer (admin edit), check-in client.
Frontend types — BookingItem interface in booking-web/src/types/booking.ts adds 5 snapshot fields + BookingItemServiceSnapshot / BookingItemResourceSnapshot interfaces mirroring the API. BookingCalendar.tsx preview-overlay fallback emits placeholder snapshot (transient, never persisted). OpenAPI spec + api.generated.ts regenerated.
Service hard-delete (preceding fix in same session) — ServiceController was missing @Delete(':id'); the onboarding "uncheck preset" path called api.delete('/services/:id') and got NestJS's auto-generated 404 whose message contained "service" → http-exception.filter.ts mis-mapped to SERVICE_NOT_FOUND. Added the endpoint + ServiceService.delete() with Prisma.PrismaClientKnownRequestError P2003 catch → ConflictException('SERVICE_IN_USE') so non-onboarding callers (where bookings reference the service) get a clean 409 instead of a generic FK explosion. ResourceSkill cascades automatically; UploadService.syncReference(prevKey, null, ...) releases the orphan UploadedFile row so imgproxy garbage-collects the blob within the next sweep. Onboarding ServicesStep.tsx reverted to useDeleteService (DELETE) — fresh tenant data has no bookings, hard-delete is correct.
Tests — booking.service.spec.ts adds a describe('snapshot') block: (a) populates flatten + JSONB snapshot on create, (b) stores resourceSnapshot=JsonNull when item is unassigned, (c) snapshot reflects resolveItems state, not later catalog edits — books once, mutates the mocked Service.name + price, books again, asserts the first booking row keeps the original values (regression guard for the whole feature), (d) walkIn populates snapshot from inline service+resource fetch. New email/booking-payload.builder.spec.ts (4 cases) verifies the SELECT shape on the items query exposes serviceName / resourceName / serviceCurrency and explicitly does not include service / resource keys — the regression guard against accidentally re-introducing the live join. Mock fixture mockService / mockResource extended with the relations resolveItems reads (category, accountingAccount.tax, type/color/avatarKey/metadata). New E2E test/booking-snapshot.e2e-spec.ts (4 cases hitting real Postgres + full AppModule): admin create persists hot+JSONB columns; snapshot stays frozen after prisma.service.update({ name, price }) rewrites the live row (the highest-stakes regression test — fails the entire feature if it goes red); unassigned items store resourceSnapshot=NULL; walk-in path populates snapshot via inline fetch. Test seeds Tax(rate=25) → AccountingAccount → Service so the snapshot's taxRate field exercises the nested JOIN, and pins Resource.metadata.jobTitle to verify extraction.
Files — booking-api: schema.prisma + 3 migrations (snapshot cols / backfill / NOT NULL), booking.service.ts (+93/-16), booking-snapshot.types.ts (NEW, 90 LoC), email/booking-payload.builder.ts, customer-portal.service.ts, public-booking.controller.ts, service.service.ts/controller.ts/service.spec.ts (Service.delete), error-codes.ts (SERVICE_IN_USE), booking.service.spec.ts (+150 LoC test block), booking-payload.builder.spec.ts (NEW), booking-snapshot.e2e-spec.ts (NEW). booking-web: types/booking.ts, BookingsSection.tsx, BookingCalendar.tsx, ServicesStep.tsx (revert + endpoint switch), useServices.ts (restore useDeleteService), messages/{en,nb}.json (SERVICE_IN_USE), api.generated.ts (regen). docs/flows/booking-flow.md gains a "Snapshot Pattern" section. Results: 1711 API unit (+8 snapshot) · 4 new E2E (snapshot end-to-end on real Postgres) · 171 web unit · nest build + Next build clean · lint 0/0.
2026-05-09 — Site scripts editor + sidebar admin flatten + content typography
Goal: round out the superadmin surface for the day — plug in the missing "Integrations" page (third-party JS snippets like GA4, Meta Pixel, Tawk.to) and tighten the editable-footer-pages experience that shipped earlier the same day.
Backend (booking-api)
- New
SiteScriptPrisma model (uuid PK, name, placement enumHEAD | BODY_START | BODY_END, code, enabled, sortOrder, audit) + two migrations:add_site_scripts(initial create with HEAD/BODY_END) thenadd_body_start_placementonce the enum was extended after first review. - Admin CRUD
/superadmin/site-scripts(list, get, create, update, toggle, delete) + public read/public/site-scriptsreturning enabled rows only with audit fields stripped. Class-level@Roles('ADMIN')guard; UUIDs validated byParseUUIDPipe. - Snippets are intentionally not sanitised — purpose is loading vendor JavaScript — gated by ADMIN-only writes,
enableddefaulting to false on create, and an auditupdatedBy. The rendering layer wraps each snippet in<!-- site-script: <name> -->for DOM debug. - Footer pages got
POST /superadmin/content-pages/:slug/resetso the admin can replay the shipped default after editing in place. Default seed copy was rewritten to reflect the real product surface — salon = merchant of record (D2 indocs/architecture/payment-architecture.md), Bambora Classic PSP on a hosted page, Google OAuth + email/phone account, S3 + imgproxy customer media, 5-year retention from Bokføringsloven § 13. Pricing body left intentionally empty (commercial copy is the operator's call). - +13 site-scripts unit tests (service: listPublic projection + filter, listAdmin order, create defaults disabled + explicit true, update 404-first-then-replace, toggle no-code-touch, remove 404 + delete; controller: list, create, toggle, delete delegates) + 2 content-pages reset tests.
Frontend (booking-web)
/admin/superadmin/integrationsroute: table with name + placement badge (HEADinfo /BODY_STARTwarning /BODY_ENDsuccess) + inline Switch toggle (uses the same component as the modal) + last-updated relative time + edit/delete row actions; warning banner makes the no-sandboxing tradeoff obvious. Add/edit modal usesSearchSelectfor placement (requiredso the clear-X never shows), inline validation with red borders + focus on first invalid field, "Is active" Switch defaulting to ON for new records, no double close button (useshowCloseButton={false}+ custom button in header — same pattern as TenantEditModal).- Hooks:
useSiteScriptsList,useCreateSiteScript,useUpdateSiteScript,useToggleSiteScript,useDeleteSiteScript. Mutations invalidate both admin and public query keys so SSR refetches on the next request. src/proxy.tspropagatesx-pathnamevia anextWithPathname()helper applied to everyNextResponse.next()branch (Next.js 16 renamedmiddleware.ts→proxy.ts; the project already usesproxy.tsfor auth refresh). Saved as a memory: never create both files.- Root
src/app/layout.tsxreads the header → fetchesgetSiteScriptsServer()only on public routes (skip/admin/*). HEAD scripts inject via<Script strategy="beforeInteractive">because the App Router doesn't expose<head>and bare React<script>JSX only hoistsasync src=resources per React 19's rules — this is the supported escape hatch. BODY_START / BODY_END use rawdangerouslySetInnerHTMLanchored at body top / body bottom. - Sidebar reorganised for ADMIN role: section header swaps from "Menu" to "Super admin", the six superadmin routes promote to level-1 (Overview / Tenants / Activity / Pages / Integrations / Platform settings) — previously nested under an expandable "Superadmin" parent — and the "Other" group is hidden when role-filtering leaves it empty. Active-state now matches a
${path}/prefix for parents with dynamic children so/admin/superadmin/pages/[slug]keeps the parent "Pages" entry highlighted while editing. - Footer pages editor (shipped earlier today) tightened: locale tabs use
key={activeLocale}to force a fresh<RichTextEditor>mount when switching EN/NB (Tiptap takes its initial content only at editor-init), Save validates inline (no toast), close button no longer doubles up on the Modal's built-in one, "Reset to default" button + ConfirmDialog wired to the new API endpoint. - Customer footer pages render via shared
.content-prosetypography (Tailwind v4 ships without@tailwindcss/typography, so the olderprose prose-smwas a no-op — that's why headings collapsed onto paragraphs in the first review). Class applied to both Tiptap editor and customer pages so the WYSIWYG and the rendered page match. - en/nb translations: 75+ new keys total covering sidebar nav, footer pages CRUD, integrations CRUD, placement labels, form errors. ICU-aware:
<script>removed from translation strings (parser treats it as a rich-text tag and complains aboutUNCLOSED_TAG). - Lint 0 errors / 0 warnings, build green for both repos.
2026-05-09 — Editable footer pages (Privacy / Terms / About / Contact / Help / Pricing)
Goal: Let the super-admin edit the six customer-footer pages in-product — under Norwegian/EU rules these need real wording, and we don't want to ship a redeploy every time legal copy changes.
Backend (booking-api)
- New
ContentPagePrisma model (singleton-per-slug,slugPK, EN+NB title/body,updatedByaudit) + migration20260509083053_add_content_pages(CREATE TABLE, no data backfill — the seed runs at boot). ContentPagesService.onModuleInit()upserts the six locked slugs (privacy,terms,about,contact,help,pricing) with hand-written Norway/EU defaults: privacy cites GDPR Art. 6(1) bases + Personopplysningsloven + Datatilsynet; terms cites Forbrukerkjøpsloven + Angrerettloven § 22(m) for time-bound services + Oslo tingrett as venue; about/contact/help/pricing carry market-friendly boilerplate. Idempotentupdate: {}so admin edits survive a restart.- Server-side sanitiser:
sanitize-htmlstrict profile (h2/h3, p, strong/em/u/s, ul/ol/li, blockquote, a, code, pre, hr) withmailto/tel/http(s)schemes only;<script>, on-handlers,<img>,<style>stripped.target="_blank"links auto-rewritten torel="noopener noreferrer". - New
@Roles('ADMIN') /superadmin/content-pagescontroller — GET list (six rows,hasContentflag drives the "Empty / Published" badge), GET detail, PUT update. Slug enum guard at the controller layer rejects malformed slugs with 400 instead of opaque Prisma errors. New public/public/content-pages/:slug?locale=en|nb— locale query keeps each (slug, locale) pair separately CDN-cacheable. - 4 new files (
content-pages.service.ts,content-pages.controller.ts,public-content-pages.controller.ts,dto/content-page.dto.ts) + constants + module + AppModule wire. - +20 unit tests: 3 service onModuleInit (upsert call shape, empty
update, swallow boot errors), 1 listAdmin (locked enum order + hasContent flags + missing-row placeholder), 2 getAdmin (returns row, throws 404), 2 getPublic (en + nb), 3 update (XSS<script>strip,<img onerror>strip,target=_blankrel injection, audit user id), 5 admin controller, 4 public controller.
Frontend (booking-web)
useContentPagesList/useContentPage/useUpdateContentPagehooks via the regeneratedapi.generated.ts. Mutation invalidates list, detail, and every locale variant of the public read so the next/privacyvisit refetches.- Server fetcher
getContentPageServer(slug, locale)(cache: 'no-store', returnsnullon any failure). - Super-admin sidebar gets a new
Pagesentry (en: "Pages", nb: "Sider"). New routes/admin/superadmin/pages(table with status badge + last-edited relative time + external preview link) and/admin/superadmin/pages/[slug](Tiptap RichTextEditor in EN / NB tabs, single save round-trip updates both locales). Slug param validated against the same locked enum as the API; unknown slugs hitnotFound()instead of fetching a 400. - Six customer pages (
/privacy,/terms,/about,/contact,/help,/pricing) now SSR-fetch the body and render via sharedInfoPageContent(dangerouslySetInnerHTMLis safe here — body is sanitised server-side; we explicitly do NOT re-sanitise client-side, that would just mask a broken save). Empty body falls back to the existingInfoComingSoonplaceholder, so an outage at the API layer never breaks the public page. - en/nb translations: 22 new keys under
superadmin.pages(list columns + status badges + editor labels/warnings) + sidebar nav key. - Lint + build green (
yarn lint0 issues,yarn buildships/admin/superadmin/pages,/admin/superadmin/pages/[slug], all six customer routes as dynamic SSR).
2026-05-09 — Docker production deploy + horizontal scale
Goal: Replace the host-PM2 deploy path with a Docker-native pipeline that lets the API scale horizontally without rewriting throttler / queue / session code.
Build pipeline — every push to main of booking-api and booking-web triggers a GitHub Actions workflow that builds a multi-stage image and pushes to GHCR with two tags: :latest and :sha-<git-sha>. Layer cache lives in GHA storage so subsequent builds are ~1–2 min. The 4 GB VPS never builds locally — it only pulls finished images. Node 24 (Prisma 7.6 engine requirement: ^22.12 || >=24.0).
booking-api — Dockerfile (deps → build → runtime) keeps full node_modules in the runtime stage so prisma migrate deploy works at deploy time without a separate tooling image. tini is PID 1 so SIGTERM reaches Node cleanly (graceful shutdown via app.enableShutdownHooks()). New /api/health endpoint pings Postgres (SELECT 1) + Redis (PING) in parallel, returns 503 when either is down so Docker HEALTHCHECK and any upstream LB drain the replica. main.ts trust proxy is now env-driven (TRUST_PROXY=loopback for host-nginx, =1 for Docker bridge so the throttler reads the real client IP from X-Forwarded-For instead of collapsing every customer into the nginx container's IP).
booking-web — next.config.ts enables output: 'standalone' so the runtime image is ~80 MB instead of shipping the full ~600 MB node_modules. Build args (NEXT_PUBLIC_*) are wired to GHA repo Variables/Secrets so the client bundle bakes the right API URL per environment.
Compose stack — docker-compose.prod.yml declares api / web / redis / nginx on a custom bridge network. Postgres runs natively on the VPS host, reached via host.docker.internal (mapped through extra_hosts: host-gateway) — direct disk I/O without the Docker volume tax on a RAM-tight VPS. Redis is a single shared instance so the rate-limit and BullMQ counters stay correct across --scale api=N replicas (otherwise an attacker round-robins instances and bypasses the cap). Nginx runs inside Docker as a gateway with resolver 127.0.0.11 valid=30s + set $upstream_api http://api:3010 so it re-resolves the service name per-request — docker compose up --scale api=4 immediately spreads load across the new replicas with zero nginx config edits.
Rolling update — deploy.sh runs prisma migrate deploy in a one-shot container (must be backward-compatible with the version still serving traffic), scales api up to 2× temp replicas, waits until ≥ N report healthy, then scales back down so Docker drops the old containers. Web is restarted as a single replica (~5–10 s blip is acceptable). Pin a specific tag to roll back: IMAGE_TAG=sha-<old-sha> ./deploy.sh.
Docs — new docs/operations/docker-deploy.md is the single deploy guide going forward (VPS bootstrap, Postgres pg_hba.conf opening for Docker bridge 172.16.0.0/12, GHCR auth, deploy/rollback/scale, migration policy, troubleshooting). The PM2 path (deploy-vps.md) was retired — it built on the VPS itself and OOM'd on Next 16, and couldn't run multiple api replicas. New docs/operations/README.md routes to the right file. booking-system/CLAUDE.md and docs/README.md index updated.
Test status — booking-api 1624/1624 unit + e2e green (added 7 health module tests). booking-web 171/171 vitest green. Lint clean on health module (0 errors); pre-existing warnings in unrelated files unchanged. gitnexus_detect_changes MEDIUM (bootstrap function touched — expected, additive env-driven trust-proxy + HealthModule registration).
2026-05-08 — Whitelabel platform settings (super-admin)
Feature: Super-admin (ADMIN role) edits platform branding — logo, brand name, per-locale tagline (en + nb), favicon — through a new /admin/superadmin/settings page. Customer footer + admin shell + browser tab title + favicon all read from the singleton row at request time, so partner deployments can rebrand without a code release. Lays the foundation for per-tenant whitelabel later (deferred).
Schema — new PlatformSetting model (singleton, id='singleton', brandName, tagline JSON, optional logoStorageKey/logoMimeType, optional faviconStorageKey/faviconMimeType, updatedAt, updatedBy). UploadedFile.tenantId becomes nullable so platform-wide assets (no tenant scope) can flow through the same orphan-cleanup pipeline. Migration 20260508011554_add_platform_settings. Seed creates the row with current BookingSystem brand + the existing customer footer tagline (en + nb) so a fresh DB renders identically to the old hardcoded copy.
Upload pipeline — added PLATFORM_LOGO (max 2MB) + PLATFORM_FAVICON (max 512KB) purposes to UPLOAD_PURPOSE_CONFIG. UploadService.upload({ tenantId: null, purpose: 'PLATFORM_*' }) writes keys under platform/<uuid>.<ext> instead of the tenant prefix. New assertTenantScope guard rejects mismatches (PLATFORM_* with non-null tenant, or tenant purposes with null tenant) — UploadTenantScopeMismatchError. New syncPlatformReference + deletePlatform mirror the tenant-scoped equivalents but use the new UploadedFileRepository.findByKeyUnscoped() helper, which is reserved for ADMIN/orphan-cleanup paths only.
API — new PlatformSettingsModule with two controllers:
superadmin/platform-settings(ADMIN-only) — GET/PUT (brandName + tagline) + POST/DELETE for logo + favicon. PUT validates non-empty per-locale tagline (≤ 280 chars) + brand name (≤ 60). Logo/favicon endpoints upload viaUploadServicethen callsetLogo/setFaviconinside a transaction that flips the UploadedFile reference and writes the new key + mime to the singleton row. Old asset's reference is cleared so orphan-cleanup reaps it ~24h later.public/platform-settings(no auth, marked@Public()) — minimal payload:brandName,tagline,logoUrl,faviconUrl. URLs are signed imgproxy paths sized for nav-bar rendering (logo @ 128 CSS-px, favicon @ 64). All client surfaces (customer footer, admin shell, error pages, generated metadata) read from this endpoint.
Frontend —
usePlatformSettingsPublic()(5-min staleTime, 30-min gcTime) drivesAppLogo— image when key present, fallbackCalendarDaysicon + env-var brand name during loading or on error.- Customer footer (
(customer)/layout.tsx) is now an async server component that callsgetPlatformSettingsServer()+getLocale()and renders the locale-correct tagline + brand name in the copyright bar. Removed the oldhome.footer.tagline+APP_NAMEenv-var copy from the rendered HTML (the i18n key still exists for future flag flips). - Root
app/layout.tsxswitches staticmetadatafor anasync generateMetadata()that reads platform settings server-side: title default =brandName, template ='%s | ${brandName}', description = the locale tagline, andmetadata.icons.icon=faviconUrl(falls back to file-routeapp/icon.svgwhen null). The 14 dashboard pages with hardcoded' | BookingSystem'titles got their suffix stripped so the root template appends the dynamic brand cleanly. Sign-in/sign-up + 404 pages converted to asyncgenerateMetadataso descriptions interpolate the live brand. - Super-admin sidebar gets a new
Platform settingsentry. New/admin/superadmin/settingspage rendersPlatformSettingsForm— RHF + Zod schema mirrors the API DTO, twoFormFields for taglines per locale, twoAssetUploadercomponents (preview tile + Upload/Replace + Remove withConfirmDialog). Uploads use the existingapi.upload()helper which goes through Next.js/api/*rewrite to the backend. All mutations invalidate both thepublic+adminquery keys so a save propagates to every visible logo on the page.
Tests —
- API:
platform-settings.service.spec.ts(12 cases: getPublic/getAdmin/update/setLogo/clearLogo/setFavicon — including the no-op skip when key is unchanged, the unscoped reference target idsingleton:logo/singleton:favicon, the malformed-tagline defensive throw, and the 404 when the singleton was deleted by hand).platform-settings.controller.spec.ts(6 cases covering the GET/PUT/POST/DELETE delegations). All 1633 booking-api tests pass (133 suites, 2 skipped). - Web:
usePlatformSettings.test.tsx(5 cases: public + admin reads, PUT + DELETE flows, multipart upload, query-key invalidation on success). All 137 web unit tests pass. - E2E:
platform-settings.spec.tsadds 4 Playwright cases — public endpoint serves the seeded brand without auth, OWNER gets bounced from the admin endpoint with 4xx, ADMIN PUT round-trips through the public endpoint, and the/admin/superadmin/settingspage saves a new brand name through the form (with restore infinally). NewADMIN_CREDENTIALS+loggedInAsAdminPage+adminApifixtures so future super-admin specs can reuse them without re-implementing the storage-state dance.
Results: booking-api 1633/1633 unit+e2e pass · booking-web 137/137 unit + 0 lint + 0 typecheck · OpenAPI + api.generated.ts regenerated · 1 new migration · 1 new module (4 files + 2 specs) · 1 new web hook + 1 new server util + 1 new form component · 14 metadata strings de-suffixed · ~290 lines i18n added (en + nb).
Test Coverage
API (booking-api)
| Module | Unit Tests | E2E Tests |
|---|---|---|
| Auth | 38 | — |
| Tenant & Onboarding | 26 | — |
| Resource Management | 32 | — |
| Service Catalog | 26 | — |
| Booking Engine | 50 | — |
| Availability | 20 | — |
| Public Booking API | 25 | 33 |
| Customer Auth | — | 19 |
| TenantCustomer | 12 | — |
| Loyalty System | 28 | — |
| Payment Foundation (DDD) | 469 | — |
| Booking → Outbox (Phase 6B) | 27 | 5 |
| Booking Event Listeners | 19 | — |
| Authorization Expiry Cron | 26 | — |
| Worldline Direct adapter (renamed) | — | — |
| Bambora Classic adapter (Phase 7 Track D1 swap) | 72 | — |
| BookingId prefix filter + no-op update guard (post-D2) | 3 | — |
| Remaining payment handler + controller (Track E1) | 18 | — |
| HTTP shared lift (dedupe worldline-direct copy) | −13 | — |
| Capture-mode hotfix (intent-derived + Bambora instantcapture) | 0 | — |
| Capture trigger move (Confirmed → Arrived + Completed fallback) | 3 | — |
| Lead-time validator (maxBookingDaysInAdvance) | 5 | — |
| Auth-expiry → listener contract test | 1 | — |
| Track L1 — loyalty discount schema (migration + backfill, no tests) | 0 | — |
Track L2 — computeLoyaltyDiscount pure helper |
19 | — |
| Track L3 — DDD layering + reserve on booking create | 39 | 0 |
Multi-day time-off weekday-flake fix (getNextWeekday helper) |
0 | 2 |
| Role-based audit Phase 2 — STAFF scoping (booking/resource/tenant-customer/payment) | 14 | — |
| Role-based audit Phase 3 — web STAFF support + /auth/me resourceId | 1 | — |
| Role-based audit Phase 4 — Add-staff login + auth multi-tenant + cache leak fix | 9 | — |
| Status-matrix P0-1/P0-2/P0-3 — force cancel + deposit guard + walk-in IN_PERSON | 17 | — |
| Status-matrix P1-1 — customer self-cancel endpoint | 6 | — |
| Status-matrix P1-9 — deposit-status projection listener | 13 | — |
| Status-matrix P1-10 — refund/void customer notifications | 13 | — |
| Status-matrix P1-5 — autoConfirm + depositEnabled combo guard | 8 | — |
| Status-matrix P1-8 — cancel refund preview (helper + 2 endpoints + FE dialog) | 23 | — |
| Status-matrix P1-3 — source=PHONE → paymentMode=IN_PERSON derivation | 3 | — |
| Status-matrix P1-11 — Bambora 7-day auth-hold cap + auto-cancel CONFIRMED on PaymentExpired | 10 | — |
| Status-matrix P1-4 — transient vs permanent failure classification + retry endpoint + email/SMS nudge | 26 | — |
| Test type drift fix — 8 specs + ts-jest full diagnostics | 0 | — |
| Admin in-app inbox (P1-12) — schema + repo + service + 2 listeners + REST API | 40 | 7 |
| Invoice page bundle (Stripe-style URL + PaymentLedger + DTOs) | 12 | — |
| ADR-001 instant-capture default (1 spec updated, MANUAL coverage retained) | 0 | — |
| Availability business-hours clipping + multi-provider DTO/listener wiring (2026-04-27) | 5 | — |
| Booking surface security + cleanup audit (2026-04-27) — A1 price-tampering, A3 admin-transition narrowing, A4 UUID validate | 12 | — |
Bulk /resources/schedule-overrides + /resources/time-offs controller spec (2026-04-27) |
4 | — |
| Superadmin Phase 1 — overview / tenants / recent-activity endpoints (2026-04-28) | 11 | — |
| Superadmin Phase 1.1 — trend + distributions endpoints + previousPeriod on overview (2026-04-28) | 6 | — |
| Superadmin Phase 1.2 — tenant edit (name/description) + suspend/activate (2026-04-28) | 5 | — |
| Branded transactional emails — 6 templates × 2 locales + 2 listeners + tenant-wide kill switch (2026-04-28) | 16 | — |
| Upload Phase 1 — provider-agnostic S3 SDK + tenant DELETE fix + magic-bytes + EXIF strip (2026-04-28) | 35 (+27 net) | — |
| Upload Phase 2 — imgproxy URL signing + ProxyImage component (2026-04-28) | 28 (+13 api / +11 web) | — |
| Upload Phase 3 — UploadedFile DB tracking + orphan cleanup cron (2026-04-28) | 26 | — |
Upload Phase 4.1 — wire linkReference for Tenant branding + Resource avatar (2026-04-28) |
6 | — |
Upload Phase 4.2 — <ImageUploader> reusable + Service.imageUrl wiring (2026-04-28) |
0 (refactor) | — |
| Upload Phase 4.4 — Portfolio gallery (admin CRUD + drag-reorder + public lightbox) (2026-05-01) | 13 | — |
| Upload Phase 4.5 — Onboarding documents (3-step presigned PUT + admin verify endpoint) (2026-05-01) | 22 | — |
| Upload Phase 4.6 — Payment attachments (private files attached to Payment rows) (2026-05-01) | 13 | — |
| Total | 1566 | 66 |
Track D2 (admin payments list/detail/refund/void/capture) is frontend-only — no backend changes, 5 existing admin endpoints reused. Post-D2 additions:
ListPaymentsQuery.bookingIdswitched from exact match tostartsWithso 8-char pasted prefixes match;BookingService.updateshort-circuits when diff is empty so quick-tap "Save" no longer bumpsupdatedAtnor writes a noise audit row.
Web (booking-web)
| Module | Unit Tests | E2E Tests |
|---|---|---|
| Bambora Zod schema (Classic, 4 fields) | 17 | — |
| Payment config hooks | 8 | — |
| Provider metadata | 4 | — |
| HealthCheckBadge | 3 | — |
| ProviderCard | 7 | — |
| BamboraConfigForm | 5 | — |
| Vitest infra smoke | 2 | — |
| Payment settings page | — | 3 smoke + 1 @integration |
| Admin payment hooks (D2) | 10 | — |
| Refund schema (D2) | 8 | — |
| Capture schema (D2) | 5 | — |
Shared formatDateTimeInZone helper (D2) |
6 | — |
| Admin payment list page | — | 2 smoke |
| Collect remaining schema + hook (Track E1) | 9 | — |
| Public booking DateStrip (Playwright) | — | 2 |
| Public booking flow E2E (Tranches 1–4, Playwright) | — | 11 |
| Force-override modal (P0-1/P0-2 FE wiring) | 0 | — |
| Customer self-cancel CTA (P1-1 FE wiring) | 0 | — |
| Out-of-window dialog (P1-2 FE wiring) | 0 | — |
| Settings toggle disabled-state (P1-5 FE wiring) | 0 | — |
| Customer retry payment CTA + email deep-link landing (P1-4 FE wiring) | 25 | — |
| Invoice page bundle (next-payment + getPayments + ensureCheckoutSession + totalPaid + AUTHORIZED legacy) | 10 | — |
deriveNextPayment requiresDeposit branch (2026-04-27 deposit-disabled bugfix) |
2 | — |
getWeekRangeInZone salon-tz helper (2026-04-27) |
11 | — |
| Total | 160 | 25 + 1 |
For a per-file breakdown of every test (unit + integration + E2E) across all repos, see
testing.md.
Timeline
| Date | Highlights |
|---|---|
| 2026-06-02 | Custom Domain — DEPLOY PROD + fix. Cutover Caddy thay nginx+certbot live trên VPS prod (auto-HTTPS glamvoo.com + on-demand TLS). Custom domain test live: salon.novagoo.com (subdomain ngoài glamvoo.com) verify ACTIVE → Caddy cấp cert → trang salon load. Fix fix(caddy): bỏ app-dev.novagoo.com khỏi prod Caddyfile (tunnel dev, không trỏ VPS → fail cấp cert). Fix fix(custom-domain) (web 26becd5): CustomerHeader render full platform chrome (logo to + "For business") trên custom domain vì pathname sạch (/) không match /^\/b\/ → (customer)/layout.tsx đọc x-custom-domain → truyền isCustomDomain → header dùng pattern clean-URL → tenant-mode đúng. Document rollback Caddy→nginx (docker-deploy §2.4) + .env.example đầy đủ Caddy/custom-domain vars. Next: P8 (Google login trên custom domain — OAuth broker qua platform). |
| 2026-06-01 | Custom Domain (Feature 1) — tenant gắn domain riêng (book.mysalon.com / apex mysalon.com → /b/<slug>) với clean URLs (khách thấy book.mysalon.com/services thay vì /b/<slug>/...). API: TenantDomain model + additive migration; module tenant-domain — owner CRUD (/tenant-domains), public resolve, internal tls-allow (Caddy on-demand TLS ask). DomainVerificationService verify ownership qua TXT + routing qua CNAME/A (apex-aware — apex không CNAME được nên check A record). Host-aware payment return + email links resolve từ ACTIVE domain của tenant qua shared public-url.helper (/account + /admin vẫn ở platform domain). Web: proxy.ts resolve custom host → rewrite /b/<slug> + set x-custom-domain; SalonLinkContext + useSalonLink sinh clean URLs across 8 client components; admin Settings → Domain tab (add/verify/remove + apex-aware DNS instructions); next.config allowedDevOrigins env-driven (DEV_ORIGINS); Dockerfile + GHA build-arg NEXT_PUBLIC_PLATFORM_DOMAINS; regen api.generated.ts. Infra: Caddy thay nginx + certbot — auto-HTTPS + on-demand TLS (cấp cert per custom domain sau tls-allow ask), caddy/Caddyfile + Caddyfile.dev; docker-compose.prod nginx → caddy; deploy.sh cutover; dev native guide. Docs: custom-domain.md, embed-widget.md, caddy-dev-setup.md + troubleshooting / docker-deploy cutover / README index. 42 api + 23 web tests mới. P1–P7 + apex UI done; P8 (OAuth trên custom domain) deferred. |
| 2026-05-08 | Operational polish — allowDoubleBooking default ON + interactive reset-superadmin CLI + super-admin dropdown trim. (1) allowDoubleBooking default flipped to true in both industry profiles (BEAUTY_SETTINGS + BARBERSHOP_SETTINGS in booking-api/src/core/tenant/tenant-settings.constants.ts) and the FE fallback (DEFAULT_BOOKING_POLICY in booking-web/src/components/booking-policy/schema.ts). New salons land on the onboarding "Booking policy" step with the toggle ON — overlapping bookings are common in beauty (one staff handling parallel services, room-shared treatments). Owners who want strict slot enforcement still flip it off in settings; existing tenants are untouched (only first-create defaults change). 355 tenant + booking jest tests still green. (2) yarn reset:superadmin CLI — interactive rescue command for the platform's super-admin account (role=ADMIN, tenantId=null). Prompts for email (default admin@booking.no) + masked password (typed twice with * echo); min 6 characters. Re-prompts on validation fail (invalid email format, too-short password, mismatch) instead of exiting. Bumps tokenVersion so any leaked / cached JWT for the account is invalidated immediately, sets isActive=true (recovers a soft-disabled account), upserts by role (not email — operator can recover even after losing track of the email). New file booking-api/scripts/reset-superadmin.ts + package.json script. Smoke-tested locally (existing super-admin updated, generated bcrypt hash, tokenVersion incremented). (3) UserDropdown "Edit profile" hidden for ADMIN — super-admin doesn't have a tenant-scoped profile yet (the route assumes a tenant-scoped user record), so the entry was a dead link from the dropdown. Hidden behind user?.role !== "ADMIN"; sign-out spacing adjusted (mt-3 vs mt-4) so the dropdown still feels balanced when the row above is gone. Lint + tsc clean both repos. gitnexus_detect_changes LOW on both (4 changed symbols, 0 affected processes). |
| 2026-05-08 | Platform branding — SVG support + dark logo variant + SSR cache hydration. (1) Backend: image/svg+xml whitelisted on PLATFORM_LOGO + PLATFORM_FAVICON (3 MB cap, super-admin only — sanitised server-side via sanitize-html with strict SVG profile that strips <script> / <foreignObject> / on* handlers / external hrefs, allowing only same-document fragment URLs). file.validator branches on declared MIME = svg, skips file-type magic-bytes (SVG is text) and the sharp raster pipeline; upload.service skips processImage for SVG buffers so the sanitised vector survives intact. ImageProxyService.signUrl gains raw=true → emits /raw:1/plain/... passthrough so SVG keeps vector instead of being rasterised by imgproxy's default WebP/AVIF auto-format. New schema columns logo_dark_storage_key + logo_dark_mime_type (migration 20260508083032_add_platform_logo_dark); setLogoDark / clearLogoDark service methods + POST/DELETE /superadmin/platform-settings/logo-dark endpoints; admin + public DTOs surface logoDarkUrl. (2) Frontend: PlatformSettingsForm ships side-by-side Light + Dark uploaders, accept extended to image/svg+xml, dropped the coloured preview backdrop (bg-brand-500 / bg-gray-900) since uploaded logos already carry their own background; dashed border only on empty state. AppLogo reads useTheme() and picks logoDarkUrl on dark surfaces (falls back to logoUrl when admin only uploaded one variant). SIZES bumped (sm h-10, md h-14, lg h-16) so SVGs with baked-in wordmarks have room to render crisp at headers; outer span gets align-middle + leading-none to strip the inline-flow descender that was pushing <a> wrappers ~7px taller than the <img> (visible in DevTools as <a> 64.03×63 around an h-14 = 56 image). AppSidebar reads showBrandName from public settings → centres the logo when wordmark is hidden; size pinned to md so the 290px sidebar isn't swamped. (3) SSR cache hydration — root layout.tsx now prefetches platform settings via getPlatformSettingsServer(), builds a dehydrated React Query snapshot ({ success, data } envelope matching usePlatformSettingsPublic's queryFn), and wraps children in <HydrationBoundary> inside QueryContext. AppLogo + tagline now render with real brand values on first paint — no env-var fallback flash on F5. (4) i18n + types regen + customer header h-14 → h-18 + (customer)/page.tsx gradient margin updated in lockstep. Tests: +12 SVG sanitizer + 4 validator SVG branch + 1 image-proxy raw + 3 service dark logo + 2 controller dark logo. booking-api 1654 → 1676 unit + e2e green (2 pre-existing skipped). booking-web vitest 171/171 green. Lint + tsc clean both repos. gitnexus_detect_changes MEDIUM/HIGH — breadth from multi-symbol DTO + service + controller changes, all expected within platform-settings + upload modules. |
| 2026-05-08 | Public booking — service + staff avatars in booking form. /b/:slug/book now renders a 48×48 service thumbnail next to each service line (uses imageKey via ProxyImage, falls back to a brand-tinted initial tile when the service has no image). Staff dropdown gains a 28×28 avatar per option (uses avatarKey via ProxyImage, falls back to AvatarText initials with auto-color); the SearchSelect trigger also surfaces the selected staff's avatar. The "Any available" option gets a grey Users icon in a circle so it visually separates from real staff. Implementation: SearchSelect extended with an optional icon: ReactNode field on SearchSelectOption plus a new emptyIcon prop for the empty/"Any" row — fully backward-compatible (icons are optional, every existing caller renders unchanged because the trigger/dropdown only allocates the icon slot when option.icon or emptyIcon is set). New helpers in ServiceItem.tsx: StaffAvatar (28×28 round, ProxyImage→AvatarText fallback), AnyStaffIcon (Users-icon circle), ServiceThumb + ServiceThumbFallback (48×48 rounded-lg, brand-50 tile with first letter when no image). staffOptions memo now carries the icon JSX; trigger reads selected option's icon or emptyIcon for the "Any available" case. Lint clean (0 errors, 0 warnings) · tsc --noEmit clean · gitnexus_detect_changes MEDIUM (expected — only ServiceItem + SearchSelect internal symbols touched, the 2 affected processes are intra-component flows). |
| 2026-05-07 | Multi-instance hardening — Redis throttler storage + graceful shutdown + trust proxy. (1) ThrottlerModule.forRoot([...]) → forRootAsync with ThrottlerStorageRedisService from @nest-lab/throttler-storage-redis@1.2.0; reuses the same Redis the BullMQ workers connect to (REDIS_HOST / REDIS_PORT). Replaces the default in-memory throttler store so per-IP / per-route limits are enforced GLOBALLY across every API instance — without this, an attacker round-robin'ing instances bypasses the cap (effective = instances × limit), critical because public-booking endpoint is 5 req / 60s per IP. (2) app.enableShutdownHooks() in main.ts so SIGTERM from PM2 / k8s / ECS fires OnModuleDestroy on every provider — BullMQ workers finish their current job, ioredis + Prisma pools drain cleanly, throttler Redis client closes via ThrottlerStorageRedisService.onModuleDestroy(). Without this NestJS ignores SIGTERM and the runtime SIGKILLs after grace period → in-flight jobs become "stalled" and re-run on another worker. (3) app.set('trust proxy', 'loopback') in main.ts so Express reads the real client IP from X-Forwarded-For (Nginx already forwards it per deploy-vps.md:301). Previously req.ip resolved to 127.0.0.1 (Nginx loopback) for every request → all customers shared one rate-limit bucket → one spammer locked the salon out for 60s. 'loopback' is safer than true because it refuses to honour X-Forwarded-For from anywhere except 127.0.0.0/8 + ::1 — blocks header-spoofing from external clients while still trusting the colocated Nginx. Direct deps added: @nest-lab/throttler-storage-redis@1.2.0 + ioredis@5.10.1 (the latter was already transitive via BullMQ; promoted to direct so future upgrades don't break the throttler). 1617/1617 unit + e2e green; lint clean (0 errors, 7 pre-existing warnings unrelated); build clean. gitnexus_detect_changes MEDIUM (bootstrap function touched, 2 startup processes affected, expected). |
| 2026-05-07 | Onboarding footer — locale + theme switchers (afternoon polish). OnboardingFooter split into two stacked rows: primary nav (Back · Skip · Next) on top, settings strip with FooterLocaleSwitcher + FooterThemeSwitcher underneath separated by a border-gray-200 / dark:border-white/10 hairline. Switchers justify-end (right-aligned) — non-English salon owners can flip to nb-NO from any wizard step without competing with the primary CTA for visual weight. Theme switchers reuse existing FooterLocaleSwitcher / FooterThemeSwitcher from the customer footer (no new components); admin layout already wraps ThemeProvider so useTheme works under /admin/onboarding. Lint + build clean (web). gitnexus_detect_changes LOW (1 component, 0 affected_processes). |
| 2026-05-07 | Public booking — phone-or-email contact guard + "For business" CTA → /admin/signin. (1) Backend — PublicBookingController.createBooking now rejects with BadRequestException('BOOKING_CONTACT_REQUIRED') when both customerPhone and customerEmail resolve to empty, AFTER the authenticated-user account-fallback step (so logged-in customers whose account already has a contact still pass). Without a channel the salon cannot send confirmation/reminder, loses the dedupe key for repeat customers, and no-show risk goes up. Spec public-booking.controller.spec.ts gains 2 cases: guest with name only → throws; auth user whose account has phone=null, email=null and dto omits both → throws. 54/54 public-booking specs pass · 1617 API unit + e2e green. (2) Frontend — BookingPage detailsSchema extended with Zod `.refine((d) => Boolean(d.customerPhone) |
| 2026-05-07 | Onboarding skip Services + Resources + admin "View salon page" header button (afternoon follow-up). (1) Backend — OnboardingService.complete no longer requires hasResources / hasServices; only salon_info + business_hours block "Finish onboarding". Salons want to set up the shell first and fill the catalog later via the regular admin pages. InvalidBookingStateForRemainingPaymentError-style error flow stays for the 2 remaining requirements. Spec onboarding.service.spec.ts: replaced 'rejects when requirements are missing' (resource=0) with 'rejects when salon_info or business_hours missing' (address=null) + new 'allows complete with zero resources and zero services'. 31/31 onboarding tests pass. (2) Frontend — SKIPPABLE_STEPS extends to ['services', 'resources', 'booking_policy'] so the per-step "Skip" footer button shows up; OnboardingPage.handleNext drops the inline validation.resourcesRequired / validation.servicesRequired toast guards (empty list still advances the cursor); allRequirementsMet mirrors backend (only salon_info + business_hours); ReviewStep "missing" warning lists only those 2 — services / resources still surface ok=false on their summary card so owners see the gap and can jump back, but it doesn't gate Finish. Removed unused activeServicesCount. (3) Admin header "View salon page" button — new <Link> between sidebar toggle and notification bell in AppHeader, opens /b/{tenant.slug} in a new tab (target=_blank rel=noopener). Hidden when tenant?.slug is missing (anonymous routes / pre-login). i18n common.viewSalonPage (en + nb-NO). Lucide ExternalLink icon at h-5 w-5, button shape matches the bell + avatar siblings (h-11 w-11 rounded-full border). Lint + build clean both repos; gitnexus_impact LOW (api) / MEDIUM (web — local component touches only). |
| 2026-05-07 | Track E1.1 — Manual payment record (cash + standalone terminal), status-guard removal, banner + email URL bug fixes. (1) Bug seed — auto-confirm + no-deposit booking sat at CONFIRMED with Remaining 599 kr and no Collect CTA. Initial fix added CONFIRMED to the allow-list; user feedback "khách add thêm dịch vụ post-COMPLETED" + "ghi nhận cancellation fee trên CANCELLED" pushed the final answer: drop the status guard entirely. Both InitiateRemainingPaymentHandler.ALLOWED_BOOKING_STATUSES and RecordManualPaymentHandler.ALLOWED_BOOKING_STATUSES removed along with InvalidBookingStateForRemainingPaymentError / InvalidBookingStateForManualPaymentError (error code PAYMENT_INVALID_BOOKING_STATE kept in the http-exception filter map for backward-compat with old serialised events but no longer thrown). Booking-state validation belongs in the booking context; payment context just records money. BookingNotFound still covers cross-tenant + bogus-id. (2) Manual payment domain — Prisma PaymentProvider enum gains MANUAL_CASH + MANUAL_TERMINAL (migration 20260507012328_add_manual_payment_providers); ProviderKey mirrors with isManualProvider() helper; PaymentConfig.create throws when called with a manual provider (no credentials, no per-tenant config). New RecordManualPaymentCommand + RecordManualPaymentHandler skip the PSP entirely — Payment.initiate(...) with synthetic manual-${paymentId} transactionId → payment.capture(...) in the same domain transaction. metadata.recordedByUserId audits the staff who took the money + optional note for terminal refs. Same earmarked formula as PSP remaining-payment so cash + QR can co-exist on one booking. Refund routed through existing RefundPaymentHandler (system records the refund row, staff returns physical cash outside). (3) Endpoint — POST /admin/payments/manual (Roles: OWNER + STAFF + ADMIN, STAFF resource-scoped via assertStaffOwnsBooking). Body `{bookingId, method: 'CASH' |
| 2026-05-06 | Email SMTP refactor + multi-tenant forgotPassword + admin auth URL fixes. (1) Provider-agnostic email — EmailService swapped Resend SDK for nodemailer SMTP transport. Switch provider via env only (SMTP_HOST/PORT/USER/PASS) — Resend, Brevo (free 9000/mo, recommended), SendGrid, Mailgun, AWS SES, Postmark, Gmail App Password all work without code change. SMTP_HOST=console short-circuits to stdout for dev (no signup). .env.example carries a 7-provider cheatsheet. Drop resend package, add nodemailer + @types/nodemailer. (2) Multi-tenant forgotPassword — was findFirst({email}) which randomly resets one of N tenant accounts when the same email owns User rows in multiple tenants. Now findMany + transactional token-per-User loop; sends a single email with N labelled buttons (one per tenant). Each token is bound to its userId — clicking one does NOT invalidate the others. ADMIN-role users (no tenant) fall back to "Admin" label. Privacy: HTTP response identical for empty vs non-empty results (no email enumeration); request body stays email-only (no tenant-slug probe vector). (3) Admin reset URL fix — email link was building ${webUrl}/reset-password?token=… which 404s; now ${webUrl}/admin/reset-password?token=…. After successful reset, router.push('/signin') was 404 too, now /admin/signin. (4) <TenantPicker> gutter — outer wrapper got px-6 sm:px-8 lg:px-12 py-8 so the picker isn't flush against the viewport edge on monitors ≤1536px (where lg:w-1/2 clamps max-w-3xl mx-auto to zero margin). (5) EmailService.sendPasswordReset signature changed: (to, links: PasswordResetLink[]) instead of (to, resetUrl). Callers updated. Single-link UX unchanged (1 button, no list label). (6) Tests — auth.service.spec.ts +5 cases (no-user silent, single-tenant, 3-tenant multi-link, ADMIN tenant=null label, response-shape leak guard). API suite 1596 → 1601 pass. (7) Risk audit — gitnexus_impact upstream LOW on EmailService.sendPasswordReset (only AuthService.forgotPassword calls it). Public surface preserved on EmailService.send. |
| 2026-05-05 | Test coverage audit + /ship slash command (process tooling, no shipped feature). (1) Coverage baseline — first ever yarn test:cov snapshot for booking-api: lines 83.66 %, statements 82.33 %, functions 78.32 %, branches 68.81 %. Surfaced 0 % gaps in 10 files (4 email module files, 2 booking-email listeners, auth-customer controller+service, http-exception.filter, loyalty redemption repo, tenant-schedule-sync); 30-78 % gaps in auth/payment infra. booking-web @vitest/coverage-v8 chưa cấu hình → flagged as follow-up. (2) branding.resolver.spec.ts đóng core/email/branding.resolver.ts 0 % → 100 % L / 89 % B / 100 % F (+21 tests covering tenant-not-found, ConfigService default fallback, salonUrl trailing-slash strip, logo signing happy + null branches, branding wrong-shape fallbacks (null / array / wrong-type primaryColor), address formatting (full / postalCode-missing / empty / whitespace / wrong-type), locale inference Oslo→nb / non-Oslo→en / null settings / array / wrong-type tz). API suite 1577 → 1596 pass (no regressions). (3) /ship global slash command (~/.claude/commands/ship.md) — thin entry point. Kiểm tra rule file ở 3 convention path (docs/rules/<name>-workflow.md → .claude/<name>-workflow.md → <NAME>.md) trước khi chạy; nếu thiếu → STOP và báo user tạo, KHÔNG improvise. Hard rules baked-in (no --force, no --amend published commit, no auto-retry, no branch switch, no Claude/AI attribution) — project rule file override-able cho mọi thứ KHÁC nhưng hard rules thắng nếu mâu thuẫn. (4) docs/rules/ship-workflow.md project-specific workflow: 4 phase (Plan auto-detect repos qua find -name .git + git rev-parse → Single-confirm gate yes/no/edit-msg/skip-docs → Execute commit + push + reindex → Report). Generic, không hardcode tên repo/branch nào — reusable cho mọi project nếu copy qua. (5) docs/progress/testing.md — coverage snapshot 2026-05-05 prepended + ghi nhận gap đã đóng. |
| 2026-05-02 | Cover crop hardening — admin/frontend parity + focal-point picker v2 + invoice paid-only filter (follow-up to morning's Phase 4.8). Owner reported admin Mobile preview ≠ public mobile crop and "top of cover got eaten" on the live site. (1) Imgproxy aspect-cap bug — ImageProxyService.signUrl was clamping width to MAX_DIMENSION=2000 without scaling height proportionally, so a 1440×288 (5:1) cover at DPR=2 became rs:fill:2000:576 (~3.47:1 — too tall) instead of rs:fill:2000:400 (5:1). Imgproxy then over-cropped L/R while CSS object-cover ate the top — customer lost ~30% of the banner. Fix: when either dim exceeds cap, scale BOTH by MAX_DIMENSION / max(w,h) so requested aspect survives the cap. +1 regression test (1440×288 DPR=2 → 2000×400). (2) Admin/frontend parity — BrandingSection cover <ImageUploader> previously used previewWidth=1280 height=320 (4:1) gravity=sm containerClassName="h-40" which mismatched frontend's 1440×288 (5:1) gravity=ce aspect-[5/1]. Synced all 4 params so the upload preview shows the EXACT crop the customer sees. (3) Focal-point picker v2 — added 3rd Listing card (5:3) PreviewCard so the homepage card render is also previewed (covers all 3 frontend contexts: salon-detail desktop 5:1 + salon-detail mobile 5:2 + homepage card 5:3); LayoutGrid icon + focalListing i18n key (en/nb); grid switched to sm:grid-cols-3. (4) Public renderers — page.tsx + HomeContent.tsx swapped gravity=sm → gravity=ce (forced centre when no focal) and replaced Tailwind object-center (which v4 silently doesn't emit on this build) with explicit style={{ objectPosition: '${X*100}% ${Y*100}%' }} so DevTools shows the rule and aspect mismatches between imgproxy + CSS container always crop on the chosen anchor. Defaulted focalX/Y to 0.5 (matching admin local-state default) so legacy tenants without focal in DB no longer fall back to g:sm while admin previews show g:fp:0.5:0.5 — the pre-fix mismatch the owner spotted on mobile. (5) Invoice paid-only ledger — InvoiceClient filters payments to AUTHORIZED / CAPTURED / PARTIALLY_REFUNDED / REFUNDED before passing to <PaymentLedger>; INITIATED / FAILED / EXPIRED / VOIDED noise from abandoned attempts no longer surfaces on /b/{slug}/bookings/{id}/invoice. Master payments query untouched so polling + deriveNextPayment + returnOutcome keep working. Tests — API 1577/1579 (+1 imgproxy regression), web lint clean. gitnexus_impact LOW on signUrl + SalonPage upstream (page-level, no upstream callers). |
| 2026-05-02 | Public salon page polish — focal-point cover + overlay header + opening-hours pill + service hover preview (Epic 11 follow-up). End-to-end UX cleanup driven by user feedback after the Phase 4.7 service-thumbnail ship; nothing schema-level, all rendering / interaction. (1) Imgproxy g:fp:X:Y focal point + bl:N blur — ImageProxyService.signUrl accepts new focalX, focalY, blur inputs (+8 unit tests covering emit / fallback / clamp / fit-no-gravity). SignImageDto validates focalX/focalY ∈ [0, 1] and blur ∈ [1, 50]. Endpoint loses @Throttle({600/min}) → @SkipThrottle() because pure HMAC computation can't justify rate-limiting against legitimate fan-out (1 salon page = 30+ <ProxyImage> mounts × N visitors). (2) TenantBranding schema gains coverFocalX / coverFocalY (default 0.5/0.5) — JSON columns, no migration. TenantBrandingDto validates @IsNumber @Min(0) @Max(1); BrandingResolver.mergeBranding reads them with type-safe fallbacks; DEFAULT_BRANDING updated. Public GET /public/tenants (list) + GET /public/tenants/:slug (detail) both surface focal coords; PublicTenantBranding + PublicTenantListItem interfaces extended on the FE. (3) <FocalPointPicker> new admin component. Click/drag on cover sets the focal anchor (recorded as 0..1 fraction of image-natural coords, NOT container coords — picker reads <img>.naturalWidth/Height via new onLoad prop on ProxyImage and sets container aspectRatio to match source so click positions map 1:1 to source). Two LIVE preview cards mirror the actual production pipeline: container 5:1 for Desktop and 5:2 for Mobile, both fed width=600,height=120 through imgproxy + objectPosition: ${focalX*100}% ${focalY*100}% so CSS-side crops on aspect mismatches also respect the owner's anchor. Marker is a 36px target reticle (white border + animate-ping red halo + drop-shadow) that pops on every cover (dark/light/busy). (4) BrandingSection admin wires the picker between cover uploader and the form footer; uploader onChange resets focal to (0.5, 0.5) since the previous anchor is meaningless against a different picture. New i18n: settings.{focalLabel,focalDescription,focalHint,focalDesktop,focalMobile} + tightened logoHint / coverHint (Square 1:1 · 512×512+ · PNG transparent and Short banner 5:1 · 1920×384+ · keep important content centered). (5) /b/[slug] cover refactor — final aspect aspect-[5/2] sm:aspect-[5/1] with mobile staying tall enough that the photo isn't a 75px sliver, desktop a short banner. <ProxyImage> requests 1440×288 (matches desktop 5:1 exactly so no double crop) and forwards focal coords via both imgproxy focalX/Y props AND CSS style.objectPosition (CSS handles horizontal crop on mobile where container is 5:2 ≠ image 5:1). Avatar + salon name + clock now overlay the cover bottom-left on desktop+ (gradient from-black/75 via-black/30 to-transparent + drop-shadow text-shadow for legibility on bright covers). Mobile keeps the cover clean — separate avatar/name/clock block sits below in normal flow. Industry-type label removed everywhere per owner ask. (6) ServiceList hover preview — new <ImageHoverPreview> shows a 240×240 enlargement next to the thumbnail after a 250 ms hover. Auto-flips left when right edge would overflow viewport, vertically centred on trigger, rendered through portal so it escapes card overflow / stacking context, pointer-events:none so the preview never steals mouse. (7) <ImageLightbox> polish — switched body backdrop to inline-style rgba(0,0,0,0.8) so we don't depend on Tailwind picking up bg-black/95 after a JIT miss; added body.style.overflow = 'hidden' while open; replaced width=1600 height=1200 HTML attrs (which forced upscale on small sources) with <img style={{width:'auto',height:'auto'}} className="max-w-full max-h-full"> — image renders at natural size capped by viewport. X button: white solid + dark icon + strokeWidth={2.5} + shadow → contrasts on every photo. (8) OpeningHoursCard — open/closed pill replaced the dot-only badge: filled emerald/red ring with animate-ping halo on Open now, secondary status line ("Closes 17:00" / "Opens 09:00") inherits the same colour for clearer "is the salon open right now?" signal. (9) CustomerHeader — sticky behaviour cleaned up: scroll < 10px = bg-transparent border-transparent so cover bleeds full-width; scroll past = bg-white shadow-xs border-gray-200 solid (no /90 glass that was leaking the page underneath). (10) Page layout — About + Location collapsed into the main column (same width as Services) so the salon page reads as one coherent narrative; sidebar Opening hours wrapped in <div className="sticky top-20"> (sticky on inner div, not on grid item — align-items: stretch was defeating sticky on direct grid children even with align-self). Location section gained a Location heading matching Services / About visual weight (new publicBooking.location i18n). (11) docker-compose.yml — opt-in IMGPROXY_IGNORE_SSL_VERIFICATION env (default false) so dev with flaky upstream certs (e.g. VNPT S3 cert expiring before manual renewal) can be unblocked without code change; production keeps verification ON. (12) Tests — API 1574/1576 (was 1567), web 173/173. Lint clean both repos. nest build + next build 32 pages clean. gitnexus_impact LOW across all touched symbols (page-level, no upstream callers). |
| 2026-05-01 | Public salon page — service thumbnails + lightbox preview. Service cards on /b/[slug] now render a 1:1 thumbnail (industry-standard) when the service has an imageKey; clicking the thumbnail opens a full-screen <ImageLightbox> (ESC / X / backdrop close). Layout falls back to text-only when the salon hasn't uploaded an image, so older tenants don't get empty placeholder boxes. (1) API — GET /public/tenants/:slug/services now whitelists fields explicitly (id, name, description, duration, price, currency, isActive, sortOrder, imageKey) instead of leaking the full Prisma row — internal columns (tenantId, metadata, accounting refs) no longer cross the public surface. +2 controller spec cases (whitelisted shape + leak guard) on top of the existing assertions. (2) OpenAPI + types — regen via yarn generate:openapi (api) → yarn generate:types (web); PublicService interface gains imageKey: string | null. (3) <ImageLightbox> — new reusable component at components/ui/images/ImageLightbox.tsx modelled on PortfolioLightbox but single-image (no prev/next navigation). Wrapper/Inner pattern keyed by imageKey so the keyboard listener mounts only while open. Backdrop click + ESC + X all close; clicks on the image itself stop propagation so users don't accidentally dismiss while admiring the photo. Caption shows duration + price. (4) ServiceList.tsx — thumbnail is h-16 w-16 (mobile) / h-20 w-20 (desktop), rounded-lg, served via <ProxyImage resize="fill" gravity="sm" width={160} height={160}>. Wrapping <button> carries aria-label from new i18n key publicBooking.viewImage. State change minimal — added previewService useState; lightbox closes via stable useCallback. (5) i18n — publicBooking.viewImage = "View image of {name}" / "Se bilde av {name}" (en + nb). Verification: API 1567/1569 pass (1566 → 1567 = +2 new whitelist tests, -1 redundant result === mockCategories deep-equal), nest build clean, lint 0/3 pre-existing warnings. Web 173/173 vitest, lint 0/0, next build 32 pages clean. gitnexus_impact LOW (ServiceList has no upstream callers — page-level component). |
| 2026-05-01 | Upload Phase 4.6 — Payment attachments (Phase 4 fully closed) (Epic 11). Final use case for the upload pipeline — receipts, invoices, support correspondence attached to a Payment row. Reuses the entire presigned PUT/GET infrastructure shipped in 4.5 with zero new helpers; the only new code is the feature module itself. (1) Schema — new PaymentAttachment model: id (uuid7), paymentId, tenantId, fileKey, mimeType, sizeBytes, originalName?, note?, uploadedBy, timestamps. Cascade-on-payment + cascade-on-tenant. Indices (paymentId, createdAt) for the drawer list (sorted DESC) + (tenantId) for tenant cleanup queries. Named PaymentAttachmentUploader relation on User for audit. Migration 20260501114326_add_payment_attachments. (2) Backend module core/payment-attachment/: PaymentAttachmentService + PaymentAttachmentController mounted with two route prefixes (/payments/:paymentId/attachments for collection ops + /payment-attachments/:id for single-row ops — same pattern as Portfolio, lets paymentId stay in the URL where it semantically belongs but keeps single-id endpoints flat). Endpoints: GET /payments/:paymentId/attachments (STAFF+OWNER+ADMIN, ordered by createdAt DESC), POST /payments/:paymentId/attachments/presigned-put (STAFF+OWNER+ADMIN — validates against PAYMENT_ATTACHMENT_ALLOWED_MIMES + 10 MB ceiling + per-payment cap of 10 BEFORE issuing the URL so abandoned uploads don't push past the cap), POST /payments/:paymentId/attachments/confirm (STAFF+OWNER+ADMIN — transactional: confirm via UploadService.confirmPresignedUpload → paymentAttachment.create → linkReference), PATCH /payment-attachments/:id (OWNER+ADMIN, note-only update), GET /payment-attachments/:id/download (STAFF+OWNER+ADMIN, force-attachment with sanitised filename — falls back to payment-${paymentId.slice(0,8)}.pdf when originalName missing), DELETE /payment-attachments/:id (OWNER+ADMIN per architecture §10.5 — STAFF can upload but not delete, matches the boss/clerk separation). Cross-tenant payment access blocked at every endpoint via assertPayment before any storage call. (3) Tests — +13 unit (payment-attachment.service.spec.ts): list returns rows ordered DESC, list rejects cross-tenant payment, requestUpload MIME rejection, requestUpload >10MB rejection, requestUpload at-cap rejection (PAYMENT_ATTACHMENT_MAX_REACHED ConflictException), requestUpload happy delegate, confirmUpload inserts row + claims upload in same transaction, update note happy path, update cross-tenant rejection, getDownloadUrl with attachment disposition, getDownloadUrl cross-tenant rejection, delete releases claim, delete cross-tenant rejection. Total API suite: 1566 / 1568 (was 1553). (4) Frontend — usePaymentAttachments hooks: useQuery for list (keyed by paymentId) + useUploadPaymentAttachment 3-step orchestrator (presigned-put → bare fetch PUT → confirm — same pattern as 4.5's useUploadOnboardingDoc) + useDeletePaymentAttachment + standalone downloadPaymentAttachment(id) helper. New <PaymentAttachmentsSection> component nested inside PaymentDetailDrawer between the timeline and the failure-info block. Renders count badge ({n}/10), upload button (disabled when at cap or pending), file list with size + date + note. Delete button hidden for STAFF role (mirrors API guard); STAFF can still upload + download. Client-side rejects > 10MB and non-allowlist MIMEs with useToast before hitting the API. (5) PaymentDetailDrawer integration — single-line import + single section render between </section> of timeline and the failure-heading block. Existing drawer state (useState<ActionKind>, action bar visibility, etc.) untouched. (6) i18n — payments.attachments.* namespace (title, uploadButton, empty, toastUploaded/Deleted, deleteConfirmTitle/Message, errorTooLarge, errorInvalidType) for en + nb. API: lint 0 errors / 3 pre-existing warnings, nest build clean, suite 1566/1568 pass. Web: lint 0/0, next build 32 pages clean. Phase 4 fully closed — all 6 upload use cases live (Tenant logo/cover, Service image, Resource avatar, Portfolio gallery, Onboarding docs, Payment attachments). Public assets via imgproxy + <ImageUploader>; private assets via presigned PUT/GET + 3-step <DocumentsSection> / <PaymentAttachmentsSection> flow. Same UPLOAD_PURPOSE_CONFIG registry drives MIME / size limits everywhere. DEFER: Phase 5 (Mobile / Expo) — <ProxyImage> RN port + camera-roll integration; Phase 6 (production deploy hardening) — Nginx reverse proxy in front of imgproxy, swap MinIO → cloud provider, deprecate MINIO_* env vars + minio package. |
| 2026-05-01 | Upload Phase 4.5 — Onboarding documents (KYC) — private files + 3-step presigned PUT (Epic 11). First private-file use case; previous 4.x phases were public images served via imgproxy. Owner uploads business license / ID / insurance for verification; admin (superadmin) reviews and stamps the row. (1) Schema — new TenantOnboardingDoc model + OnboardingDocType enum (`BUSINESS_LICENSE |
| 2026-05-01 | Upload Phase 4.4 — Portfolio gallery (admin CRUD + drag-reorder + public lightbox) (Epic 11). Beauty/barber staff need a way to show off their work — first new use case fully owned by the upload pipeline (everything before was wiring existing image fields). (1) Schema — new PortfolioItem model (id uuid7, tenantId, resourceId, fileKey, caption?, sortOrder, createdAt, updatedAt) with cascade-on-tenant + cascade-on-resource. Two indices: (resourceId, sortOrder) for the gallery render path, (tenantId) for tenant-wide cleanup / scope checks. Migration 20260501110609_add_portfolio_items. beforeAfterPair deferred — single-photo + caption covers MVP; salons typically upload pre-composed before/after collages, and pair logic doubles schema + UI complexity for a feature whose actual demand is unproven. (2) Backend module core/portfolio/: PortfolioService (listByResource, create, update, reorder bulk, delete) + PortfolioController mounted at /resources/:resourceId/portfolio (list / create / reorder) and /portfolio/:id (update caption / delete). All mutations transactional and call UploadService.syncReference on the photo's fileKey so orphan-cleanup respects in-use gallery photos. Per-resource cap = 30 photos (PORTFOLIO_MAX_ITEMS_PER_RESOURCE); create rejects with PORTFOLIO_MAX_ITEMS_REACHED once full. Cross-tenant fileKey rejected early with PORTFOLIO_INVALID_FILE_KEY before reaching linkReference. New sortOrder is monotonic per resource (MAX(sortOrder) + 1); reorder validates ownership of every passed id before issuing the transactional updates. Roles: STAFF + OWNER + ADMIN can manage; STAFF self-management makes sense because each staff curates their own portfolio (matching the time-off self-service pattern). (3) Tests — +13 unit (portfolio.service.spec.ts): list ordered by sortOrder, list rejects cross-tenant, create monotonic sortOrder + claim upload, create rejects at cap, create rejects cross-tenant fileKey, create rejects cross-tenant resource, update caption-only, update rejects cross-tenant, reorder empty no-op, reorder rejects foreign id, reorder runs transaction, delete releases claim, delete rejects cross-tenant. Public booking spec extended with a PortfolioService provider stub. Total API: 1531 / 1533 (was 1518). (4) Public endpoint — GET /public/tenants/:slug/resources/:resourceId/portfolio (no auth, @Public), guarded so only isActive + isBookableOnline resources expose photos; returns {id, fileKey, caption, sortOrder} only — no internal fields. Owners turning off "bookable online" hides both the staff card and the gallery. (5) Frontend — usePortfolio hooks (useQuery + 4 useFormMutation mutations with React Query invalidation), new <PortfolioGalleryEditor> component using @dnd-kit/sortable for drag-reorder (PointerSensor + KeyboardSensor for accessibility), inline caption editing (Enter saves, Escape cancels), confirm-on-delete via <ConfirmDialog>. Upload uses the existing <ImageUploader purpose="PORTFOLIO_PHOTO"> so the input MIME / size limits / EXIF strip pipeline stays consistent with the rest of the app. Wired into StaffFormModal as a 3rd tab "Portfolio" — only visible in edit mode (needs persisted staff.id to attach photos against). Local-order state cleared on the reorder mutation's onSuccess/onError callback (avoids setState-in-useEffect flag from React Compiler). (6) Public salon page — new <TeamSection> rendered on /b/[slug] between the services + sidebar grid and the About section. Server-side fetches the resources list + each resource's portfolio in parallel (failures tolerated — empty array fallback so the salon page never breaks because of a 5xx on the portfolio endpoint). Each staff card shows up to 10 thumbnails plus a "+N" overflow tile; click opens <PortfolioLightbox> (full-screen, prev/next via arrow keys + chevron buttons, Esc closes). Lightbox uses the wrapper/inner pattern keyed by openIndex so internal useState(initialIndex) initializes fresh on each open without violating the React Compiler setState-in-effect rule. Staff with no avatar AND no portfolio are filtered out so empty cards don't add visual noise. (7) Deps — added @dnd-kit/core@6.3.1, @dnd-kit/sortable@10.0.0, @dnd-kit/utilities@3.2.2 (web). (8) i18n — new portfolio.* namespace (hint, drag, photoAlt, captionPlaceholder, deleteConfirmTitle/Message, 4 toast messages) + resources.portfolioTab + publicBooking.team for both nb and en. gitnexus_impact LOW · API lint 0 errors / 3 pre-existing warnings · nest build clean · next build 32 pages clean · web lint 0/0. DEFER: 4.5 (Onboarding documents — private files, presigned PUT 3-step flow), 4.6 (Payment attachments — private files), pair-photo / before-after slider (revisit if salon owners actually request it). |
| 2026-05-01 | Upload Phase 4.3 — rename *Url → *Key across schema, API, and FE (Epic 11). Storage references in the DB now hold bucket-relative keys instead of full URLs; URLs are computed at render time by ImageProxyService. Decouples data from provider/CDN host so swapping S3 vendors or imgproxy domain becomes an env change rather than a DB migration. (1) Schema migration (20260501100105_rename_image_url_to_storage_key): services.image_url → image_key, resources.image_url → avatar_key, JSON tenants.branding.{logoUrl,coverUrl} → {logoKey,coverKey}. Backfill SQL strips ^https?://[^/]+/[^/]+/ prefix from any existing values — verified 0 rows still hold http URLs after migration. (2) API DTOs: CreateServiceDto.imageUrl → imageKey (drops @IsUrl, uses @IsString); CreateResourceDto.imageUrl → avatarKey same shape; TenantBrandingDto.{logoUrl,coverUrl} → {logoKey,coverKey} with @ValidateIf for null-clearing. TenantBranding interface and DEFAULT_BRANDING const renamed in tenant-settings.constants.ts. Service/Resource/Tenant service layers updated everywhere — syncReference now accepts keys directly, no URL parsing. BrandingResolver injects ImageProxyService and pre-resolves logoKey → 48×48 imgproxy URL inside EmailBrand.logoUrl so email templates (Gmail/Apple Mail can't lazy-resolve) still work. EmailModule imports UploadModule. (3) FE types: Service.imageKey, Resource.avatarKey, TenantBranding.{logoKey,coverKey} in types/booking.ts; PublicTenantBranding, PublicTenantListItem, PublicResource in lib/public-api.ts; payment.tenant.logoKey in public-payment-api.ts. (4) FE forms: BrandingSection state vars + payload, ServiceFormModal schema + imageKey, StaffFormModal schema + avatarKey. ImageUploader.onChange(res.data.key) instead of onChange(res.data.url). (5) Display sites: SalonAvatar prop renamed logoKey, TenantPicker.tenant.logoKey, BookingTicket passes logoKey, HomeContent reads tenant.coverKey, public salon page reads tenant.branding.{coverKey,logoKey}, BookingPage same. SignInForm JSON parser reads logoKey. (6) Cleanup (Phase 4.3.D): extractStorageKey strips URL-shape branch — now rejects :// inputs (callers must send bare keys post-Phase 4.3). POST /upload response drops url field — only {id, key, sizeBytes, mimeType, width, height}. DELETE /upload body simplified to {key} only. UploadService.resolveKeyFromUrl + buildPublicUrl private helpers deleted; UploadServiceConfig.publicBaseUrl field deleted; STORAGE_PUBLIC_BASE env no longer used. Verification: API lint 0 errors / 3 pre-existing warnings, nest build clean, suite 1518/1520 pass (net -3 vs baseline = 2 resolveKeyFromUrl tests + 1 DELETE accepts url test deleted). Web lint 0/0, next build 32 pages clean, vitest 173/173. End-to-end smoke (curl): upload returns key only, service/resource POST accept imageKey/avatarKey, DB stores bare keys (no http prefix), UploadedFile.referenced_at flips correctly through claim/swap/release. DEFER: Phase 4.4 (Portfolio gallery — new model), 4.5 (Onboarding docs presigned PUT), 4.6 (Payment attachments). |
| 2026-04-29 | Upload — fix empty STORAGE_PUBLIC_BASE falling through ?? (Epic 11). API smoke test caught a pre-existing bug surfaced by the FE wiring: upload.module.ts used publicBase ?? endpoint ?? 'http://localhost', but a .env line like STORAGE_PUBLIC_BASE= is read by NestJS ConfigService as the empty string "", which ?? happily passes through (only null / undefined trigger the fallback). Result: upload returned bucket-relative URLs like /dev/<tenant>/<uuid>.png, which Resource.imageUrl (@IsUrl()) and Tenant.branding.{logoUrl,coverUrl} (@IsUrl()) reject at the validator. Fix: treat empty / whitespace-only env values as missing, so the fallback chain reaches STORAGE_ENDPOINT and produces a real https://… URL. No DTO change — Service DTO's @IsString() was already permissive enough that smoke tests revealed the issue only when crossing into Resource/Tenant. Confirmed via curl smoke against running dev API: upload + create + swap + clear all succeed for both SERVICE_IMAGE and RESOURCE_AVATAR purposes; UploadedFile.referenced_at flips correctly through every transition (claim → swap → release). Suite still 1521 / 1523 pass (upload module + service spec mocks already injected explicit publicBaseUrl so the production-shape behaviour didn't change). |
| 2026-04-29 | Upload Phase 4.2 follow-up — wire <ImageUploader> into ServiceFormModal + StaffFormModal (Epic 11). Close the two FE-only defers from yesterday's Phase 4.2 ship. Now every admin form that owns an image field (branding, service, staff) drops in the same <ImageUploader> — no more orphan UI surfaces leaving DB columns unreachable from the UI. (1) ServiceFormModal — added imageUrl to the Zod schema (z.string().nullable().optional()), defaultValues (service?.imageUrl ?? null for edit, null for create), and a Details-tab <ImageUploader purpose="SERVICE_IMAGE"> block (600×400 fill preview, h-24 w-36 rounded-lg object-cover). PATCH path is diff-aware: imageUrl only goes on the wire when next !== previous, so non-image edits (rename, price tweak, isActive toggle) stay a single direct UPDATE rather than tripping into service.service.ts:156 $transaction branch. POST path forwards imageUrl only when truthy — CreateServiceDto doesn't accept null. Hydrates service.imageUrl from the API response now that Service interface in types/booking.ts exposes the column (was missing — the type silently swallowed the field even though the API has been returning it since yesterday). (2) StaffFormModal — same shape. Added imageUrl to schema + defaultValues (staff?.imageUrl ?? null), wired <ImageUploader purpose="RESOURCE_AVATAR"> in Details tab between description and the metadata/color row (80×80 fill/sm preview, h-20 w-20 rounded-full object-cover). PATCH path mirrors Service — diff-aware so non-avatar edits stay single UPDATE. POST path forwards only when truthy (CreateResourceDto.imageUrl is string, not `string |
| 2026-04-28 | Upload Phase 4.2 — <ImageUploader> reusable + Service.imageUrl wiring (Epic 11). Ship the upload UI as a single component every form can drop in, and finally close the loop on Service images (the field existed in admin queries but never round-tripped through create/update). (1) Schema migration — added Service.imageUrl String? @map("image_url") (20260428160418_add_service_image_url). The earlier audit assumed Service had this column because service.service.ts:157 selected imageUrl: true, but that select was inside getResourcesForService reading the Resource field — Service truly didn't have an image column until now. (2) DTO — CreateServiceDto.imageUrl?: string and `UpdateServiceDto.imageUrl?: string |
| 2026-04-28 | Upload Phase 4.1 — wire linkReference for existing image fields (Epic 11). Make the orphan-cleanup worker actually do its job for the three image fields already shipping in production: Tenant.branding.logoUrl, Tenant.branding.coverUrl, and Resource.imageUrl. Without this wiring the worker would have reclaimed in-use logos / avatars 24h after upload because no feature module ever called linkReference(). (1) UploadService.syncReference(prev, next, target, tenantId, tx?) — single helper that handles the four cases a feature update can produce: prev=null+next=URL claims a new file, prev=URL+next=null releases the old one, swap (URL→URL) does both, no-change is a no-op. Best-effort: silently skips files with no UploadedFile row (legacy uploads from before Phase 3) and silently skips cross-tenant keys (findByKey returns null for them — defense-in-depth on top of the caller checks). Never throws — caller's domain save shouldn't be blocked by bookkeeping. (2) ResourceService.create / update — wires syncReference for the avatar field. Create path adds it to the existing transaction (right after tx.resource.create, claims with null → imageUrl). Update path is conditional: only wrap in prisma.$transaction when imageUrl is in the patch DTO — keeps the 99% non-image-edit (e.g., toggling isActive, renaming) a single direct UPDATE. The change-login branch + add-login branch both honour the same pattern. ResourceModule now imports UploadModule. (3) TenantService.update — wires syncReference for both branding.logoUrl and branding.coverUrl. Reads previous value from tenant.branding before merge, computes nextBranding after merge, then runs both syncs inside the same transaction. Same conditional-transaction optimisation: only wraps when at least one of the two URLs actually changed; pure colour / non-branding edits stay a single UPDATE. (4) Test suite repair — adding UploadService constructor dep broke ResourceService (3 spec files) and TenantService (1) test bootstraps; fixed by adding stub { provide: UploadService, useValue: {syncReference, linkReference} } to each providers array. payment.module.spec.ts transitively imports TenantModule → UploadModule which validates STORAGE_* + IMGPROXY_* env on boot — extended beforeEach to set stub values for those vars (test only verifies wiring, not S3 calls). (5) Tests — +6 (upload.service.spec.ts syncReference: prev=next no-op, claim-on-null-prev, release-on-null-next, swap claims-and-releases, missing-file-row silent skip, cross-tenant silent skip). Suite 1521/1523 pass. Lint 0 errors / 3 pre-existing warnings, nest build clean. DEFER: rename *Url → *Key schema columns + the polymorphic foreign-key migration (Phase 4.3). Wire Service.imageUrl (DTO doesn't currently expose it for create/update — Phase 4.2 alongside the <ImageUploader> reusable component). Cover the 3 brand-new private models (Phase 4.4-4.6: Portfolio, Onboarding doc, Payment attachment). Hard-delete unlinkReference on entity deletion — Resource and Tenant are soft-delete only today, so the orphan worker eventually picks up unused files when an admin manually clears the field. |
| 2026-04-28 | Upload Phase 3 — UploadedFile DB tracking + orphan cleanup cron (Epic 11). Track every upload in Postgres so the cleanup worker can reclaim files that never got linked to a feature row (form abandoned, browser crashed, user re-uploaded then never saved). (1) Schema — new UploadedFile model + UploadPurpose (7 values matching upload-purpose.config.ts) + UploadVisibility (PUBLIC / PRIVATE) enums in prisma/schema.prisma. Columns: id (uuid), tenantId, key (unique), purpose, visibility, mimeType, sizeBytes, originalName?, width?, height?, checksumSha256?, uploadedBy, referencedBy (Json polymorphic {type, id}), createdAt, referencedAt?. Three indices: (tenantId, purpose, createdAt) for admin browsing, (referencedAt, createdAt) for the cleanup query, (tenantId, visibility) for future quota / role filtering. Tenant.onDelete: Cascade so a deleted tenant takes its file rows with it (Phase 6 will wire S3 sweep). FK to User for audit. Migration 20260428082450_add_uploaded_files. (2) Repository — UploadedFileRepository (infrastructure/persistence/uploaded-file.repository.ts) with tenant-scoped findById / findByKey (returns null when row belongs to a different tenant — defense-in-depth on top of caller checks), create, setReference(id, {type, id}, tx?), clearReference(id, tx?) (sets Prisma.JsonNull + null referencedAt), findOrphans(beforeAt, limit, tx?) (cross-tenant — only the worker uses it), deleteByKeys(keys[]) returning the count. Every method accepts an optional Prisma.TransactionClient so feature modules can do the link/unlink in the same transaction as their domain UPDATE — closes the race window from upload-architecture.md §8. (3) Service — UploadService.upload() now inserts an UploadedFile row with referencedAt = null after the storage putObject succeeds (return shape gains id). New linkReference(fileId, target, callerTenantId, tx?) — verifies tenant ownership via findById, throws UploadNotFoundError on null (also catches cross-tenant attempts since findById nulls those), then calls setReference. New unlinkReference(fileId, callerTenantId, tx?) — symmetric. delete(key, callerTenantId) extended to also deleteByKeys([key]) after the S3 delete (best-effort — legacy Phase 1→3 keys without a row still succeed). New findByKey(key, callerTenantId, tx?) convenience for feature modules whose schema still stores *Url strings (Phase 4 will rename to *Key). (4) Orphan cleanup worker — new BullMQ queue upload-orphan-cleanup registered in UploadModule, repeatable sweep every 1 hour (UPLOAD_ORPHAN_SWEEP_INTERVAL_MS = 60×60_000). OrphanCleanupQueue scheduler mirrors the AuthorizationExpiryQueue pattern (try/catch around queue.add so a Redis blip doesn't block boot, triggerSweepNow() for ops + tests, error listener installed to swallow runtime queue errors). OrphanCleanupProcessor queries findOrphans(NOW − 24h, 100), iterates each row calling storage.deleteObject(key); storage failures are logged + counted but do NOT block — the DB row stays so the next sweep retries. Successfully-deleted keys go into a single deleteByKeys call so DB roundtrips stay constant. Returns a {scanned, storageDeleted, storageFailed, rowsDeleted} summary for BullMQ logging. Constants in queue.constants.ts: queue name, job name, sweep interval (1h), age threshold (24h), batch size (100), 3 attempts × 30s exponential backoff. (5) Module wiring — UploadModule now imports BullModule.registerQueue({ name: UPLOAD_ORPHAN_QUEUE }), registers UploadedFileRepository, OrphanCleanupQueue, OrphanCleanupProcessor, and exports UploadedFileRepository so Phase 4 feature modules can call linkReference() directly inside their own transactions. (6) Tests — +26 (1515 / 1517 pass, 2 skipped): uploaded-file.repository.spec.ts 11 cases (insert with all-nullable optionals, findById tenant-match + cross-tenant null + missing-row null, findByKey tenant-match + cross-tenant null, setReference writes polymorphic + Date, clearReference flips to JsonNull, findOrphans where/orderBy/take, deleteByKeys empty short-circuit + bulk delete count); upload.service.spec.ts extended +5 (upload inserts row, ONBOARDING_DOC PRIVATE visibility, MIME mismatch does NOT insert row, delete bulk-removes DB row by key, linkReference + unlinkReference verify ownership + UploadNotFoundError when missing); orphan-cleanup.processor.spec.ts 5 cases (zero counters when empty, NOW − 24h cutoff + batch size, happy-path bulk delete, partial failure keeps DB row for retry, all-fail passes empty array to deleteByKeys); orphan-cleanup-queue.service.spec.ts 5 cases (registers repeatable on init, error listener installed, Redis blip swallowed, triggerSweepNow has no repeat option, queue close errors swallowed). gitnexus_impact LOW · gitnexus_detect_changes LOW · lint 0 errors / 3 pre-existing warnings · nest build clean. DEFER: tenant cascade S3 sweep (Phase 6 deploy hardening — current cascade only drops DB rows, leaves S3 objects until next orphan sweep claims them); per-tenant storage quota tracking (architecture §15.6 — needs Tenant.storageUsedBytes + decrement-on-cleanup transaction); admin UI for orphan inspection (defer to Phase 4 alongside feature wiring). |
| 2026-04-28 | Upload Phase 2 — imgproxy URL signing + ProxyImage component (Epic 11). Wire the imgproxy container declared in §6 of the upload architecture so every uploaded image (logo / cover / service / avatar) renders through on-the-fly resize + format negotiation instead of bucket-direct <img>. (1) Backend — new ImageProxyService (src/core/upload/services/image-proxy.service.ts) builds the canonical signed path /<sig>/rs:<resize>:<w>:<h>:0/g:<gravity>?/q:<quality>/plain/s3://<bucket>/<key> and signs it with `HMAC-SHA256(salt |
| 2026-04-28 | Upload Phase 1 — provider-agnostic S3 SDK + tenant DELETE fix (Epic 11). Swap minio package → @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner so production can pick any S3-compatible cloud (Cloudflare R2, Hetzner Object Storage, Backblaze B2, DigitalOcean Spaces, Scaleway, …) via 5 env vars without touching code. Closes 8 baseline gaps from the upload audit at the start of the branch: (1) CRITICAL cross-tenant DELETE — UploadService.delete(key, callerTenantId) now parses key prefix and rejects UPLOAD_TENANT_MISMATCH (403) when caller ≠ key owner (previously a tenant could pass another tenant's URL and the server would happily delete it); (2) HIGH SVG XSS removed from MIME allowlist (browser rendering of <script> inside SVG was a stored-XSS vector — beauty-cluster MVP doesn't need SVG, can re-enable behind DOMPurify-svg adapter later); (3) HIGH MIME spoof — file-type@16 magic-bytes detection is now authoritative and must match declared Content-Type, defending against polyglot files (PDF gắn extension .png, JPEG-as-PNG header lies); (4) hardcoded http://endpoint:port → STORAGE_PUBLIC_BASE env (falls back to endpoint for dev); (5) GPS leak via EXIF → sharp().rotate().toBuffer() auto-orients then strips all metadata by default; (6) hard-coded MIME allowlist → per-purpose registry upload-purpose.config.ts (TENANT_LOGO 2MB, TENANT_COVER 5MB, SERVICE_IMAGE 3MB, RESOURCE_AVATAR 2MB, PORTFOLIO_PHOTO 5MB public; ONBOARDING_DOC + PAYMENT_ATTACHMENT 10MB private); (7) provider-locked SDK → StoragePort interface + S3StorageAdapter implementation; (8) no presigned-URL helpers → getPresignedPutUrl + getPresignedGetUrl ready for Phase 5 (private files via direct browser → storage). Architecture (see docs/architecture/upload-architecture.md): hexagonal layout domain/{ports,errors,file.validator,upload-purpose.config} + infrastructure/storage/s3-storage.adapter.ts + interface/filters/upload-domain-error.filter.ts. 10 typed error subclasses prefix UPLOAD_* carry code + httpStatus for FE i18n + retry classification. Backward compat — POST /upload still returns { url } (+ new metadata fields key, sizeBytes, mimeType, width, height); DELETE /upload accepts either url or key body — server resolves + verifies tenant. BrandingSection FE call unchanged. Tests: +27 over baseline (1469 unit total): file.validator.spec.ts 12 cases (PNG/JPEG/WebP/PDF accept × 3 purposes, SVG-XSS rejected, polyglot defense, MIME mismatch, oversize, undersize <100B, declaredSize-vs-buffer mismatch, 56MP rejected, EXIF strip), s3-storage.adapter.spec.ts 8 cases via aws-sdk-client-mock (putObject/deleteObject/HEAD-NotFound/HEAD-metadata/NoSuchKey→UploadNotFoundError/5xx→UploadProviderError/presignedPut TTL+headers/presignedGet uses GetObjectCommand), upload.service.spec.ts 9 cases (happy path, image pipeline, MIME mismatch, tenantId prefix, cross-tenant DELETE blocked, valid DELETE, malformed key, resolveKeyFromUrl prefix-strip + passthrough), upload.controller.spec.ts 6 cases (default purpose, explicit purpose, unknown purpose fallback, DELETE accepts url, DELETE accepts bare key). gitnexus_impact LOW risk · 0 process affected · gitnexus_detect_changes 0 affected processes · lint 0 errors (3 pre-existing warnings in email/recipient.resolver.ts) · nest build clean. DEFER: e2e test (upload-phase-1-e2e, needs real cloud bucket or LocalStack — Phase 4 wire) + OpenAPI/types regen (upload-phase-1-openapi-sync, defer cho Phase 4 cùng FE wire). Phase 2 (imgproxy) + Phase 3 (UploadedFile DB tracking + orphan cleanup) follow. |
| 2026-04-28 | Branded transactional emails — booking lifecycle + payments. Six HTML templates wired into the booking + payment domain events, each rendered with the tenant's logo (or initial-avatar fallback) + primary-color accent and shipped via the existing Resend wrapper. (1) Templates — BOOKING_CREATED (customer), BOOKING_CONFIRMED (customer), BOOKING_CANCELLED (customer, cancelledBy + refund-note paragraphs), PAYMENT_RECEIVED (customer, deposit-vs-full split + remaining-due line), BOOKING_REFUNDED (customer, amount + ETA), BOOKING_NEW_OWNER (every active OWNER + the STAFF whose Resource is referenced by the booking). All six × 2 locales (nb default for Europe/Oslo, en everywhere else — picked via BrandingResolver.pickLocale until tenant.settings.locale lands). Master layout.ts runs inline-style only (Gmail strips <style>, Apple Mail eats <link>); width capped at 600px; preheader hidden via the standard CSS trick. Components — renderItemsTable, renderInfoTable, renderCtaButton, formatMoney/DateTime/DateOnly with timezone parameter — live in templates/components.ts so each template stays a thin compose. (2) Branding — new BrandingResolver.resolve(tenantId) returns {salonName, logoUrl, primaryColor, salonAddress, salonUrl, locale} from tenant.branding + tenant.address + tenant.settings.timezone. Logo upload landing in parallel — render path is logo-aware today, falls back to text-initial avatar so the email is never empty mid-rollout. (3) Recipients — new EmailRecipientResolver with two methods: getCustomer(bookingId) (linked Customer email + name → snapshot fallback → null) and getOwnersAndAssignedStaff(tenantId, bookingId, actorUserId?) (active OWNERs + STAFF whose Resource is on booking.resourceId or any item.resourceId, deduped, optional actor exclusion to avoid self-pings). (4) Pipeline — branded emails ride a dedicated BullMQ queue branded-emails with 3-attempt exponential backoff; BrandedEmailProcessor resolves brand + timezone + per-user emailNotifications opt-out at dequeue time so a recently-uploaded logo or a freshly-toggled mute is honoured even on retries. (5) Triggers — two new listeners in booking module: OnBookingEmailListener (Created → BOOKING_CREATED + BOOKING_NEW_OWNER fan-out, Confirmed → BOOKING_CONFIRMED, Cancelled → BOOKING_CANCELLED with refundIssued = cancelledBy === 'SALON' heuristic), OnPaymentEmailListener (PaymentCaptured → PAYMENT_RECEIVED with deposit/full split derived from Payment.intent, PaymentRefunded + PaymentPartiallyRefunded → BOOKING_REFUNDED). Both swallow-and-log on failure so a Resend hiccup never replays the source domain event — the queue retries each individual job. (6) Tenant-wide kill switch — tenant.settings.emailNotifications: boolean (default true) gates every recipient on the salon (customers + owners + staff alike). Lives in TenantSettings JSON instead of User.emailNotifications because the toggle belongs to the salon, not to individual operators — one OWNER muting themselves while colleagues stay loud was the wrong shape. Settings page gains a Notifications tab (Bell icon) with a single switch + descriptive hint that the in-app inbox + customer SMS pipelines are independent. Switch calls PATCH /tenants/:id with {settings: {emailNotifications}} and rolls back optimistically if the API rejects. (7) Tests — templates/render.spec.ts snapshots 6 × 2 = 12 fixed-context renders + 4 conditional-branch assertions (refund-note paragraph, full-payment hides remaining-due line, initial-avatar fallback when logoUrl is null, both refundIssued branches). API total 1443 (1427 → 1443). Lint + build clean. Phase 2 deferred — reminder 24h cron, NO_SHOW notice, payment-retry HTML upgrade, admin preview/test page, payment-method-specific copy. |
| 2026-04-28 | Superadmin Phase 1.2 — basic tenant edit + suspend/activate. Per-tenant inline action buttons on the superadmin tenants table: pencil → edit modal (name + description), power-off/on → confirm dialog → suspend/activate. (1) Backend — PATCH /tenants/:id/status mới, @Roles('ADMIN') only, body {isActive: boolean} → TenantService.setActive(id, isActive) (uses existing 404-if-missing path). DTO SetTenantActiveDto lives next to UpdateTenantDto but is intentionally separate so the OWNER-callable update() can never flip its own isActive=false and brick login. +5 unit tests (3 service: activate/suspend without touching other fields/missing-id-throws + 2 controller: forwards both branches). (2) Frontend — TenantEditModal dùng Wrapper/Inner pattern, hydrate qua useTenantDetail(id) query (description không có trong list endpoint, fetch riêng để pre-fill). useUpdateTenant() mutation invalidate cả 3 query keys (superadmin.tenants, superadmin.overview, tenant.{id}); useSetTenantActive() đi qua endpoint riêng /tenants/:id/status. Tenants table thêm Actions column với pencil + power buttons (red khi đang active = "click để suspend", green khi inactive). Suspend/activate qua existing ConfirmDialog (variant=danger cho suspend, default cho activate) — copy giải thích chính xác hậu quả ("hide from public booking + block owner login") để admin không bấm vô tình. (3) i18n — superadmin.edit.* namespace + tenants.columns.actions thêm cho en + nb. API total 1427 (1422 → 1427) · web build 32 pages · lint 0/0 · build clean both repos. Phase 2 còn lại: tenant CRUD UI cho create/delete, login-as-tenant impersonation, per-tenant drill-down. |
| 2026-04-28 | Superadmin Phase 1.1 — chart pass on overview + tenants pages. Hero overview now ships 5 sparklined cards + 4 charts; tenants page gains a top-10 ranking chart above the table. (1) Backend — added GET /superadmin/trend?days=30 (daily [{date, bookings, revenue}], days clamped 1–90, gap days zero-filled) feeding both hero sparklines + the big combo chart, and GET /superadmin/distributions returning {byIndustry, byStatus, onboardingFunnel} with completed synthesised from tenant.onboardedAt IS NOT NULL. /superadmin/overview enriched with previousPeriod: {bookings, revenue} (60→30-days-ago window, same query shape) so the FE can render Δ% on cards. Switched from inline string array to a typed REVENUE_STATUSES: PaymentStatus[] constant after the first build flagged the cast — TS narrowed to string[] instead of PaymentStatus[]. +6 unit tests (3 trend bucketing edge cases + 1 distributions sort, 2 controller delegate + clamp). (2) Frontend — wired react-apexcharts (already in package.json, never used). 5 chart components in components/superadmin/charts/: Sparkline (mini line, no axes), RevenueBookingsTrendChart (combo: column + line, dual-axis), TopTenantsChart (gradient horizontal bar, top-N), IndustryDistributionChart (donut with total in center), BookingStatusChart (donut with status semantic colors mirroring badges), OnboardingFunnelChart (distributed horizontal bar, fixed step order via STEP_ORDER array). Every chart dynamic(() => import('react-apexcharts'), {ssr: false}) because react-apexcharts touches window at import time. (3) MetricCard enhanced — added optional sparkline: number[] + sparklineColor props; renders below the trend label, blends in via gradient fill. Existing dashboard usage unchanged (props optional). (4) Pages — overview SuperadminOverviewContent now stitches 4 hooks (overview/trend/tenants/distributions) into a 4-row layout: sparkline cards / big trend / top tenants × industry / status × onboarding. Δ% computed via computeDelta(curr, prev) returning null when prev=0 to avoid misleading +∞%. Tenants page (SuperadminTenantsContent) wraps the same TopTenantsChart at limit=10 above the existing data table — same data source, no extra round-trip. (5) i18n — superadmin.charts.{trend, topTenants, industry, bookingStatus, onboardingFunnel, delta} namespace for en + nb. Onboarding step labels match the existing wizard step keys so no drift. API total 1422 (1416 → 1422) · web build 32 pages · lint 0/0 · build clean both repos. |
| 2026-04-28 | Tailwind v4 canonical-classes ESLint plugin. Installed eslint-plugin-tailwind-canonical-classes (warn level) + @tailwindcss/node peer dep in booking-web. Plugin uses Tailwind v4's canonicalizeCandidates API to flag and auto-fix non-canonical class names: !h-16 → h-16! (postfix-important is the v4 form), w-[16px] → w-4, dark:bg-white/[0.03] → dark:bg-white/3. First lint run reported 49 warnings across 22 files; yarn lint --fix resolved every one (postfix-important on shadcn-style overrides + opacity bracket → shorthand). Configured in eslint.config.mjs with cssPath: './src/app/globals.css'. Install required --ignore-engines because transitive eslint-visitor-keys@5 doesn't accept node 23.x (odd-numbered, non-LTS). Updated ~/.claude/rules/typescript/coding-style.md Tailwind v4 section with the postfix-! rule + plugin reference; added project memory feedback_tailwind_v4_lint. Lint 0 warnings · build 32 pages clean · no behavioural change (className-only edits). |
| 2026-04-28 | Notification UX redesign — detail modal + category icons. Click on a notification (bell dropdown OR /admin/notifications page) used to router.push('/admin/bookings/{id}') which is the QR check-in route — back button bounced to /dashboard instead of the previous page. Replaced with an in-place NotificationDetailModal that shows translated title, category badge, absolute time + relative time. New lib/notification-meta.ts registry phân category (`booking |
| 2026-04-28 | Superadmin Phase 1 — platform overview for the ADMIN role. (1) Backend — new core/superadmin/ module exposing 3 read-only endpoints under @Roles('ADMIN'): GET /superadmin/overview (total tenants / active tenants / total users / 30-day bookings / 30-day captured-minus-refunded revenue, single NOK rollup with the multi-currency rollup explicitly deferred), GET /superadmin/tenants (every tenant + 30-day per-tenant booking + revenue groupBy + last-booking-startTime + reporting currency parsed from tenant.settings.currency, fallback NOK), GET /superadmin/recent-activity (newest 30 BOOKING_CREATED + PAYMENT_CAPTURED + TENANT_CREATED events merged client-side; payments resolve tenantName via a separate tenant.findMany({id: in}) lookup because Payment has no Prisma relation back-ref — only a tenantId FK, blast-radius confirmed via gitnexus_impact before edit). DTOs use @ApiOkResponse({type: ...}) + nullable: true, type: String/Number so OpenAPI generates proper `string |
| 2026-04-27 | Booking surface security + cleanup audit (senior pass). Multi-area sweep across booking-api + booking-web closing 5 security findings, 2 polling/perf wins, and 6 dead-code clean-ups. (A1) Price tampering — CreateBookingItemDto.price field deleted; BookingService.resolveItems now hard-codes price: service.price (was item.price ?? service.price) so admin / staff payloads can no longer inflate or zero-out item prices. Affects both POST /bookings and PATCH /bookings/:id. Calendar drag-drop drops the redundant price: item.price echo from 3 spots (whole-block move, per-item move, parent-move-with-items) since the server now ignores it. Existing test rewritten to assert the override is silently rejected. (A2) Public booking spam — POST /public/:slug/bookings got @Throttle({ default: { ttl: 60_000, limit: 5 } }) so guests can't flood calendars + notification fan-out via raw HTTP. Global ThrottlerGuard was already wired; this just tightens one endpoint. (A3) Admin transition jumps — isAdminTransition was from !== to (i.e. anything goes for OWNER/ADMIN). Replaced with `isValidTransition(from, to) |
| 2026-04-27 | Work-schedule API consolidation (1+2N → 3 calls) + salon-tz hardening across booking-web. (1) Bulk schedule-overrides endpoint — /admin/work-schedule was firing 1 + 2N requests on every load (one useStaffWithSchedules + per-staff useScheduleOverrides + per-staff useTimeOffs); already had GET /resources/time-offs?from&to for the booking drawer but schedule-overrides was still per-resource. Added GET /resources/schedule-overrides?from&to mirroring getTimeOffsForTenant (declared before :id matcher for the same Express dispatch reason); new ResourceService.getScheduleOverridesForTenant(tenantId, from, to) filters via resource: { tenantId } so no per-resource lookup. Frontend hooks useAllScheduleOverrides + useAllTimeOffs parallel the existing useStaffWithSchedules; WorkScheduleContent calls all three once and passes the flat arrays to ScheduleGrid, which buckets them into Map<resourceId, []> so each StaffRow gets its slice in O(1). EMPTY_OVERRIDES/EMPTY_TIME_OFFS module-level constants keep refs stable for staff with no rows (avoids React Compiler memo busting). Mutations (useCreateScheduleOverride/Delete..., useCreate/Update/DeleteTimeOff) now invalidate both the per-resource and bulk query keys. Result: 10 staff = 21 → 3 calls; 50 staff = 101 → 3 calls. (2) Salon-tz week range — old getWeekRange used new Date() + setHours(0,0,0,0) + getDay() (browser tz), so an admin in Asia/Ho_Chi_Minh viewing a Europe/Oslo salon late at night would see "This week" jump a day before the salon clock did. New getWeekRangeInZone(offset, tz) in calendar-utils.ts anchors "today" via todayInZone(tz) + UTC noon, derives dayOfWeek from getPartsInZone, returns weekDates: Date[] (all UTC noon) + from/to (formatDateKey(d, tz)) + label (Intl.DateTimeFormat({ timeZone: tz })). +11 vitest cases including the two killer scenarios: VN viewer at 23:30 Sun (still Sun Oslo) and 02:00 Mon (still Sun Oslo) → both must return the Apr 6–12 week, not jump forward. WorkScheduleContent waits for tenant before computing the range; ScheduleGrid + StaffRow got tz: string prop and now render every day-of-week / day-number / "today" through formatDateKey(d, tz) + getPartsInZone(d, tz) + isToday(dateStr, tz). Weekday short labels switched to Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: tz }) — Date.toLocaleDateString({ weekday }) cannot take a timeZone and silently rendered viewer-local. (3) Date-rule sweep across booking-web — grep'd for getDay/getDate/getHours/setHours/startsWith.*date/.slice(0,10) patterns. Key insight documented in feedback_timezone_rules.md: new Date("YYYY-MM-DDT12:00:00").getDay() IS tz-safe — parse-as-local + read-as-local cancels and noon never crosses day boundary, so calendar-date-in/day-of-week-out is bulletproof. 14/20 grep hits were false positives (ScheduleCell, BulkDayScheduleModal, ScheduleCellEditor, DateStrip, BookingPage, DateField, footer year, PaymentList toIsoDate). Real bugs fixed: (a) useDashboard.todayISO() — replaced browser-tz string-build with useTenant() + todayInZone(tz), query enabled: !!today so dashboard "Today's bookings" never queries with viewer's day; (b) ScheduleGrid compare logic — o.date.startsWith(dateStr) and to.startDate.slice(0,10) compared UTC calendar date against salon-local date (OK for Norway UTC+1/+2, broken for Pacific tz); replaced with formatDateKey(new Date(o.date), tz) via new overrideMatchesDate + findTimeOffsForDate(_, _, tz) helpers; (c) BookingDrawer.maxBookingDate — was new Date().setDate(getDate() + cap).toISOString().slice(0,10) (browser-now then UTC-slice); now todayInZone(tz) + UTC noon anchor + setUTCDate + formatDateInZone. (4) lib/utils.ts trap warning — todayString/extractDateFromISO/extractTimeFromISO/formatTime keep optional tz? (callers all pass it today) but got a top-of-section comment flagging the browser-tz fallback as dangerous in tenant-scoped UI. GitNexus impact ran on every edited symbol upstream; formatDateKey was HIGH (BookingCalendar consumer) so the helper was added not modified. Results: lint 0/0 · build clean · 162/162 vitest pass (20 files including the new calendar-utils.test.ts) · 76/76 resource jest pass. |
| 2026-04-27 | Calendar UX overhaul + multi-provider Pay buttons + bulk-day schedule + invoice settled bug fix. (1) Calendar grid polish — TimeColumn now sticky left-0 with bg, header time-spacer also sticky-left + z-40 so vertical AND horizontal scroll keeps the time labels visible. New 15/30/45 minor labels (text-[10px] font-medium gray-500) under each hour, all four gridline weights unified at border-gray-300 (15/45 keep dashed for hierarchy). WeekOverview lost its inner overflow-auto h-full wrapper — was nested inside the BookingCalendar scroll container which made sticky top-0 attach to the wrong ancestor; date row now properly sticks during vertical scroll. getCalendarHours(settings, dayOfWeeks?) filters by visible weekdays (day mode = selected, week mode = 0-6) so one stray 24h-configured weekday no longer stretches the grid 0-24. (2) Per-item drag — multi-service booking blocks now expose a GripVertical handle at top-right (negative offset peeking outside, bg-gray-900 solid + border + shadow). BookingBlock.onDragStart now accepts `mode: 'whole' |
| 2026-04-26 | Public booking UX polish + Collect-remaining fix. (1) Time slot grid picker — new shared TimeSlotGrid (src/components/form/TimeSlotGrid.tsx) replaces the dropdown-list time picker on the public booking flow with a Calendly-style grid. Trigger keeps the SearchSelect input look (full-width input + chevron + clear button). Click → fixed-position popup with flex flex-wrap gap-1.5, each slot w-16 h-9 text-xs (64×36, fits "10:15"); selected slot uses brand-500 bg + white text. Container max-h-52 so the dropdown caps at 5 rows then scrolls — taller dropdown on busy salons would push the booking summary off-screen. Outside-click + Escape close, position recalc on scroll, radiogroup/radio ARIA. Admin TimePicker stays on the search-list variant (per user request — admin staff scrolling 5-min increments need search). (2) Closed-day notice — BookingPage.isSelectedDateClosed derives day-of-week from selectedDate and checks settings.businessHours[dow].isOpen. When today (auto-pick) or the user's pick lands on a closed day, an amber Alert variant="warning" shows below the date strip with closedDayTitle ("The salon is closed on this day" / "Salongen er stengt denne dagen") + closedDayMessage instructing to pick another date. Service section + ServicePicker + Customer details + Summary sidebar are all hidden on closed days so the customer focuses on date selection instead of wrestling with a form that can't submit. (3) Booking-page header → salon-detail style — SalonAvatar switched from size="sm" (40×40) to size="md" (56×56) with a 64×64 override (!h-16 !w-16) + white border + shadow to mirror the /b/[slug] hero treatment. Subtitle was "Book Appointment" → now tIndustry(industryType) ("Beauty salon") + <SalonClock tz={timezone} /> to match the salon detail's 3-line header (name / industry / live clock). Title scaled to text-base sm:text-lg (was text-xl sm:text-2xl) so it stays proportional to the smaller avatar. (4) BookingPaymentSummary deposit-OFF fix — bug: if (isLoading || payments.length === 0) return null collapsed the entire summary card whenever the salon had no deposit row. For tenants with depositEnabled=false but an active PSP, this meant the Collect-remaining CTA never appeared on COMPLETED/IN_PROGRESS/ARRIVED bookings — staff had to charge cash or skip the gateway entirely. Fixed: only return null when isLoading OR (payments.length === 0 && no active PaymentConfig). When payments rỗng + tenant has active config, render with capturedNet=0, remaining=totalAmount, bookingProvider falls back to activeConfig.provider so the Collect modal routes correctly. Provider-inactive warning + provider meta lookup made null-safe. Cash-only salons (no provider at all) still hide the card. No behaviour change when at least one Payment row exists. |
| 2026-04-06 | Project scaffold, auth, response envelope, Prisma setup, Docker |
| 2026-04-07 | Auth upgrade, Epic 1-4 (tenant, resource, service, booking + availability) |
| 2026-04-12 | Web admin: dashboard, staff, services, bookings calendar, settings |
| 2026-04-13 | Multi-service booking, calendar drag-drop, customer booking page |
| 2026-04-14 | Public page redesign, settings sidebar, location/map, work schedule |
| 2026-04-14 | Multi-slot business hours (industry-standard), schedule editor modal, timezone fix |
| 2026-04-14 | TimeOff feature (API+UI), availability indicators, TimePicker/TimeField |
| 2026-04-15 | Public booking page (single-page, multi-service, Calendly-style), error codes cleanup, 33 e2e + 45 unit tests |
| 2026-04-15 | Tax/accounting settings, ARRIVED status, staff skill tabs, calendar status filter, booking audit log, DTO audit |
| 2026-04-16 | Customer auth (Google OAuth), customer portal (profile, bookings), 19 e2e tests |
| 2026-04-16 | TenantCustomer bridge, loyalty system (stamps + points), admin loyalty UI, customer loyalty dashboard, form validation audit |
| 2026-04-16 | UI polish: header glass effect, hero gradient, info pages layout (sidebar+tabs), admin footer (locale/theme), static pages, settings/account URL-based tabs, seed location fix, React Compiler lint fixes |
| 2026-04-16 | Guest vs auth booking: contact snapshot fields on Booking, no auto-merge guest→customer. Booking list: status dropdown, SearchSelect resource filter, DatePicker, view mode localStorage persist. Week view scroll. |
| 2026-04-16 | Security hardening: token versioning (User+Customer), changePassword/resetPassword invalidate sessions, helmet, sameSite strict, MaxLength password, admin guard rejects customer tokens, Bull Board timing-safe auth, response body no longer exposes tokens. Separate admin/customer refresh + 401 redirect. |
| 2026-04-17 | Payment Phase 0–4 foundation (DDD + Hexagonal + CQRS): domain + policies + Prisma persistence + dual-write outbox + FakeProvider + commands/queries + integration listeners + admin/public HTTP + PaymentDomainError filter + enum drift guards + tenant-scoped repos. 21 commits, 715 tests. |
| 2026-04-17 | Payment Phase 5: Bambora adapter (Worldline Connect — credentials, HMAC, http-client, errors, mapper, retry), webhook pipeline (HMAC-SHA256 verify + 5-min replay window + payment_webhook_inbox dedup + BullMQ payment-webhook processor), BullMQ root at AppModule + BullBoard, rawBody: true bootstrap, enum guards cleanup, admin controller tests. +102 tests. |
| 2026-04-17 | Deposit settings UI (booking-web): deposit enabled/type/value, input group style. |
| 2026-04-17 | Docs reorganization: split docs/ into product/, architecture/, flows/, rules/, operations/, progress/ subfolders + index README.md. |
| 2026-04-17 | Add development-rules.md §0 Engineering Mindset: senior-developer stance by default + zero-shortcut discipline for payment/money/credentials code (no TODO, no "fix later", observability ships with feature). |
| 2026-04-22 | Public booking E2E coverage — 4 tranches / 11 new Playwright tests: happy path + multi-service + unassigned vs assigned-only (Tranche 1), closed-day grey-out + business-hours slot cap + deposit redirect to Bambora (Tranche 2), blank-name + skill filter + invalid serviceId (Tranche 3), rebook via ?from= (Tranche 4). BookingPage / ServiceItem / ServicePicker gain data-testids; ownerApi Playwright fixture switched to worker-scoped to stop the login rate-limiter; changelog Timeline refactored to keep short entries in the table and push long entries to a new "Detailed Entries" section; new testing.md doc tracks per-file test counts across all repos. 13/13 public-booking Playwright green in 18.5s; lint 0/0; build clean. |
| 2026-04-23 | Role-based audit Phase 5 — Playwright E2E staff scenarios: 7 new tests (staff-role.spec.ts) covering STAFF signin via form, sidebar filter (only Dashboard/Bookings/Customers/Loyalty visible), OwnerOnlyGuard bounces (/admin/staff, /admin/settings, /admin/services all redirect to /admin), /admin/bookings stays accessible, and OWNER→logout→STAFF role switch keeping tenantId while flipping role + resourceId. Fixture refactored to storageState pattern: owner + staff each log in once per worker, every test loads saved cookies — kills the auth rate-limiter cascade that was bringing down the full suite. Two pre-existing spec fixes: payment-settings.spec.ts "Bambora" card assertions moved to getByRole('heading') (description text also contained "Bambora", tripping strict mode) + Merchant label updated to placeholder-based locator (form labels now use <span> not <label htmlFor>). Public-booking 1.3 unassigned marked test.fixme — genuine API regression where unassigned + first-slot time lands on BOOKING_OUTSIDE_BUSINESS_HOURS while 1.1 (same slot, staff assigned) passes; tracked for separate fix. Results: 24/25 E2E green + 1 fixme in 34s · lint 0/0 · build clean. Unblocks mobile scaffold start. |
| 2026-04-23 | Auth + profile UX polish (same day, follow-up to multi-tenant sign-in). (1) Tenant picker media grid — TenantPicker rendered as single-column list; swapped to grid-cols-1 sm:grid-cols-2 with max-w-2xl, avatar bumped to h-14 w-14 rounded-xl, tenant name to text-base font-semibold, and the user's role in that tenant now shown below the name (uppercase + tracking-wide) instead of the slug — owners/staff with the same email across salons immediately see which login they're picking. API: AuthService.login includes role: m.role on every entry of the AUTH_TENANT_REQUIRED.tenants[] payload; frontend TenantChoice.role required + normalizeTenants() drops rows missing role. (2) Admin Profile page — Profile card + Personal information card now share a row on lg+ (grid-cols-1 lg:grid-cols-2); Change password stays full-width below. (3) Dead code removal — deleted unused ChangePasswordForm.tsx (superseded by ChangePasswordCard but never un-imported). (4) Auth-refresh cookie loop hardening — POST /auth/refresh now clears auth cookies in the response on any failure (missing token / revoked / expired) so the browser stops re-sending a dead cookie on every F5, breaking the refresh → /me 401 → redirect → same cookie → 401 loop; AuthGuard + UserDropdown.handleSignOut + ChangePasswordCard.onSuccess switched from router.replace → window.location.href = '/admin/signin' to avoid the RSC round-trip fetching with the stale cookie before the redirect lands; AuthContext.fetchUser returns the resolved user, logout no longer pushes internally — callers own the redirect for explicit mid-session clear. Tests: API auth.service spec asserts role in tenants payload (1 updated assertion) · web SignInForm tests include role on 3 mocked tenant payloads (7 pass). Lint 0/0, build clean both repos. |
| 2026-04-23 | Admin UI polish tranche: (1) Team toolbar <select> → SearchSelect for style parity with other dropdowns (new feedback_no_native_select memory), toolbar widths pinned so the status filter no longer stretches to fill the card. (2) Global search box in AppHeader replaced with the current tenant name (useTenant) — truncates to … with title={tenant.name} tooltip, max-w-xs/xl:max-w-sm + lg:block responsive, removed the dead ⌘K focus listener. (3) Theme persistence fixed across reloads: inline <head> script sets html.dark from localStorage BEFORE React hydrates, ThemeContext reads via useSyncExternalStore(document.documentElement.classList) + one-shot mount reconcile effect covers CSP/HMR edges; AdminFooter Light/Dark buttons each target an explicit setTheme(next) instead of both calling toggleTheme; <html suppressHydrationWarning> silences the expected class-attribute diff. (4) Dark-mode modal separation fix: every modal surface (shared Modal wrapper + 12 hand-rolled dialogs — Confirm, Refund/Capture/Void/CollectRemaining, Account/Tax, BookingDetailModal, BookingDrawer inline confirm, BookingHistory, UserInfoCard, UserMetaCard) now border border-transparent dark:border-gray-600 dark:shadow-2xl — earlier attempts used dark:border alone which doesn't emit width in Tailwind v4 (border-color applied, border-width stayed at 0). Saved feedback_inspect_before_guess memory so future UI-regression debugging starts in DevTools Computed tab before cycling class variants. No new tests (presentation-only); lint 0/0, build clean. |
| 2026-04-25 | P1-11 follow-up · soften deposit-lead-time gate + admin booking-list bookId column. (1) Lead-time hard cap relaxed — owner feedback: an outright save block was too aggressive when depositEnabled && maxBookingDaysInAdvance > 7 (some salons want long lead times even at the cost of an occasional re-pay). Removed validateSettingsCombination's lead-time branch + DEPOSIT_LEAD_TIME_CAP_DAYS constant + TENANT_SETTINGS_DEPOSIT_LEAD_TIME_CONFLICT error code; FE schema's superRefine lead-time refinement gone too. Net safety net unchanged: OnPaymentSettledNegativeListener (PaymentExpired path) still cancels CONFIRMED bookings whose 7-day Bambora hold lapsed, with BOOKING_CANCELLED SMS firing automatically. BookingPolicyEditor now renders a yellow soft-warning banner under maxBookingDaysInAdvance (text moved to maxBookingDaysDepositWarning, the maxBookingDaysDepositMax key removed). Edge-case doc updated to flag the relax. (2) Admin /admin/bookings list — bookingId column — UUID first 8 chars (uppercase) shown in monospace as the leftmost column so phone-support / walk-in lookups don't have to expand a row. Extracted shortId helper from BookingTicket.tsx to lib/booking-display.ts as shortBookingId(id) so the same value lands on the public ticket + admin list + future audit views. New i18n key bookings.bookingId (en Booking ID / nb Avtale-ID). Empty + skeleton states bumped colSpan 7 → 8. Tests: net −7 (1372 → 1365 API) — 5 P1-11 lead-time validator cases + 3 P1-11 tenant.service.update cases removed; 1 new "lead-time > 7 ok" assertion added on each side (validator + service). Web 137 unchanged (no schema branch test was covering the relaxed rule). 1365 API pass · 137 web pass · lint 0/0 · build clean both repos. |
| 2026-04-26 | ADR-001 implemented: deposit default capture mode → INSTANT. Bug investigation on /b/:slug/bookings/:id/invoice?from=payment-return surfaced systemic issue: Pay button hiện 599 kr (full bill) thay vì 594 kr (outstanding) sau khi customer pay 5 kr deposit. Root cause: Bambora deposit ở status=AUTHORIZED, capturedAmount=0; deriveNextPayment chỉ sum capturedAmount → outstanding = full total. Strategic decision (ADR-001 in architecture/payment-architecture.md §3.1, supersedes initial MVP design): chuyển default capture mode từ MANUAL → INSTANT cho deposit. Lý do: (a) display ambiguity bug class (mỗi UI tự quyết AUTH-as-paid → bug như vừa rồi), (b) auth window 5-7 ngày thua booking window 2-4 tuần → buộc re-auth, (c) capture failure 5-15% giữa AUTH↔CAPTURE, (d) customer banking app "pending charge" gây confusion → chargeback. Industry alignment: Treatwell/Vagaro/GlossGenius/Phorest đều dùng instant capture; Booksy/Square dùng card-on-file; KHÔNG ai dùng auth+manual capture cho deposit nhỏ. Cost difference (~120 NOK/năm cho 100 booking/tháng) trivial vs engineering savings. Implementation: 1 line flip ở build-booking-event-payload.ts captureMode = 'AUTO' cho mọi intent (DEPOSIT + FULL_PAYMENT). Listener guards (captureMode === MANUAL) trong payment-integration.service.ts đã bao trọn cả 2 path → backward-compat tự nhiên cho legacy AUTHORIZED rows. Payment.authorize() + capture() API + integration listeners + AuthorizationExpiry cron giữ nguyên trong domain làm escape hatch (re-enable section §11.4 cho future no-show protection / large-deposit use case). FE committedAmount(p) (next-payment.ts) giữ AUTHORIZED branch cho legacy data với comment "pre ADR-001 only". Decision Log row D13 added. Flow 2 in payment-flow.md marked DEPRECATED-as-default. Hot-fix committedAmount helper trước đó (sum capturedAmount → bug hiện 599 kr) cũng đã đẩy trong cùng bundle này. Tests: 1 spec updated (build-booking-event-payload.spec.ts: "uses MANUAL captureMode for DEPOSIT" → "uses AUTO captureMode for DEPOSIT (ADR-001)"); existing MANUAL coverage giữ nguyên cho escape hatch. +2 web tests (next-payment.test.ts AUTHORIZED legacy cases). 1384 API pass · 147 web pass · lint 0/0 · build clean both repos. |
| 2026-04-26 | Invoice page (Stripe-style stable URL) + many UX polish · 2026-04-26 bundle. Redesigned customer payment flow around a long-lived invoice page so PSP-session expiry can no longer brick a checkout link. (1) /b/:slug/bookings/:id/invoice — new public page that pairs the existing BookingTicket (no QR) with PaymentLedger (one row per Payment intent, plain-language hint per row, status pill) + a single Pay button. Layout matches the booking-confirmation page so customers see the same data shape on both. New POST /public/.../bookings/:id/checkout-session body {intent} returns a fresh PSP session every call (Bambora invalidates URLs ahead of expiresAt; reusing one surfaces "Sesjonen har utløpt"). FE picks the right intent + amount via deriveNextPayment(bookingStatus, totalAmount, payments) covering 3 edge cases the user flagged: PENDING + deposit row → continue DEPOSIT amount; owner skipped deposit + jumped to COMPLETED → REMAINING_PAYMENT for full bill; owner toggle COMPLETED→PENDING → back to DEPOSIT. (2) InitiateRemainingPaymentInput.forceNewSession flag bypasses the dedup-by-intent guard AND the booking-status guard — public invoice page passes true so customer self-pay works pre-service; admin POST /admin/payments/remaining keeps both guards. (3) BookingPaymentSummary + BookingTicket "Paid" line now use new PublicBookingDetailDto.totalPaid field (sum of capturedAmount - refundedAmount across every settled row) instead of a single payment.amount — fixed the "Paid: 2 093 kr" mis-render where remaining-INITIATED amount was leaking through after deposit captured. (4) Admin DepositCheckoutModal + CollectRemainingModal QR/copy-bar now encode the invoice URL (/b/{slug}/bookings/{id}/invoice), not the raw Bambora URL — link stays stable when admin sends it via SMS, Pay button mints a fresh session on click. (5) PaymentReturnClient → thin redirect to /invoice?from=payment-return; banner outcome derived from payment poll. (6) BookingPage form keeps direct Bambora redirect via redirectToCheckout (customer just reviewed, redundant to step through invoice); cancelUrl landing now points at /invoice not /book (booking exists, no need to re-book). (7) BookingDrawer (admin) edit-mode wraps useBooking(id) to fetch fresh detail before mounting the form — list-cache items[].price snapshot can lag DB after a settled payment, which mismatched the backend's remaining-payment calc and surfaced as PAYMENT_REMAINING_AMOUNT_EXCEEDED. Replaces N+1 per-resource time-offs with single /resources/time-offs?from&to tenant-wide endpoint. (8) Full-list /all endpoints (GET /services/all + GET /resources/all?status) replace the silent-cap ?limit=100 callers across BookingDrawer, BookingList, BookingCalendar, StaffFormModal, ServiceFormModal, useSchedule, useDashboard. Centralised hooks useAllServices/useAllResources carry tenantId in queryKey for defense-in-depth. (9) CustomerSelect new reusable component (debounced server search via useCustomerSearch, auto-fetch single customer via useCustomer(id) so selected name renders even when out of the first page); BookingDrawer migrated. (10) SalonAvatar new shared component (uploaded logo → <Image> with ring; fallback → dark navy square with first initial; optional primaryColor for branded fallback). Used in BookingTicket header, salon hero, BookingPage header. (11) Admin booking list dropped Service column, renamed Deposit → "Payment", cell renders compact "Paid: X / Total" line (sum capturedAmount). (12) Public DTO PublicBookingDetailDto.tenant.logoUrl + PublicBookingPaymentDto.intent exposed; new GET /public/.../bookings/:id/payments (plural) returns full Payment history newest-first; BookingController.findAll adds payments[] summary per row via batched PaymentRepositoryPort.findAllByBookingIds. (13) Booking confirmation page trimmed: PaymentLedger removed, footer gains "View invoice" + "Back to salon" links, no inline ledger duplication. Tests: +12 API · +8 web vitest (next-payment unit tests covering all 3 edge cases, getPayments plural endpoint isolation, ensureCheckoutSession always-fresh-session + forceNewSession, totalPaid aggregation, findAllByBookingIds batch). API 1384 pass (1372 → 1384) · 137 web pass · lint 0/0 · build clean both repos. Migration: BookingTicketLabels lost the unused outstandingLabel. Memory: 8 new feedback notes captured. |
| 2026-04-25 | UX polish bundle (same day, follow-up to P1-12). (1) Public booking confirmation now a real route — /b/{slug}/bookings/{id} renders BookingTicket (copy-pasteable, F5-safe); BookingPage switched from inline success state to router.replace after create. New client component BookingConfirmedClient fetches via fetchPublicBooking + 60 s staleTime; tenant.branding.primaryColor pulled server-side for the spinner + Back-to-salon button. Page also doubles as the QR scan landing target. (2) BookingTicket shows on every booking, not just paid ones — same component used in the My Bookings modal + payment-return now also lands here, so customers always walk away with a screenshot of the date/time/services + scannable QR. New i18n bag publicBooking.booking.successTicket.* (en + nb) + confirmedNotFound{Title,Body}. Existing paymentReturn.success.{qrCaption,ticketHint} reworded to match ("Show this at the salon" + the longer staff-scan hint) so all 3 surfaces read the same. Bumped rounded-2xl → rounded-3xl and dropped BookingDetailModal's !rounded-none override (the override was clipping child rounded corners through overflow-y-auto). (3) Settings: deposit toggle escapable — P1-5 mutex previously locked both sides when either was on; an owner who deactivated their PSP couldn't turn deposit off. Switched to xDisabled = other && !this so OFF→ON is the only blocked direction. Re-grouped autoConfirm and depositEnabled under one separator section (they're a pair) so the "turn off X to enable this" hint sits next to its sibling. (4) Calendar UX — BookingBlock now carries an inset ring (ring-1 ring-inset ring-black/10 dark:ring-white/15) so back-to-back same-status blocks no longer merge into one wall. STAFF login pins their own column to position 0 in both day view (before Unassigned) AND week view; ResourceFilter accepts lockedResourceId so the STAFF user can't uncheck themselves (checkbox disabled + opacity-70, "Deselect all" preserves the locked row). Week view now applies hiddenResourceIds (was passing the full unfiltered list before). Week-view rows extracted to StaffRow so pinned + others share JSX. (5) Layout: theme bootstrap via next/script — replaced the raw <script dangerouslySetInnerHTML> in <head> (React 19 warned "scripts inside React components are never executed when rendering on the client") with <Script id="theme-bootstrap" strategy="beforeInteractive">. Same FOUC-prevention behaviour, no warning. (6) CheckInClient i18n fix — useTranslations('bookings.status') was pointing at a string literal ("Status" — column header), not a namespace; switched to top-level bookingStatus so tStatus(NO_SHOW) resolves. New memory feedback_i18n_namespace_collision so future MISSING_MESSAGE errors check JSON shape first. No new tests (presentation + UX wiring); 1372 API · 137 web · 66 e2e all green; lint 0/0; build clean both repos. |
| 2026-04-25 | In-app admin notification inbox (P1-12). New core/admin-notification module — Prisma table admin_notifications (per-recipient row, (tenantId, recipientUserId, createdAt desc) + (tenantId, recipientUserId, readAt) indexes), AdminNotificationRepository (list / unread-count / markRead / markAllRead / createForRecipients with auto-dedupe), AdminNotificationService with notifyOwners + notifyOwnersAndAssignedStaff (resolves staff IDs via Booking.resource.userId ∪ BookingItem.resource.userId, excludes actor + active-OWNER filter only — ADMIN deferred until login-as-tenant). Two thin listeners subscribe via EventBus: OnBookingAdminNotificationListener for BookingCreated / BookingCancelled / BookingNoShow (fan to OWNER + assigned STAFF), OnPaymentAdminNotificationListener for PaymentCaptured / PaymentRefunded / PaymentFailed PERMANENT only (fan to OWNER, skip TRANSIENT to avoid retry-noise). Both wrap calls in safeNotify so an inbox-write blip can't trigger outbox retries. REST: GET /admin/notifications (paginated, ?unread/?type filters), GET /admin/notifications/unread-count (bell badge), PATCH /admin/notifications/:id/read + PATCH /admin/notifications/read-all. All endpoints @Roles('OWNER','STAFF','ADMIN') + scope by (tenantId, currentUser.userId) so STAFF only ever sees their own inbox. Web: useAdminNotifications / useAdminUnreadCount (30s polling, refetch on focus) / mark mutations; NotificationDropdown rewritten from TailAdmin demo to real data with unread highlight + actor-aware i18n; full page at /admin/notifications with filter chips (All / Unread / per-type) + paginated list. New i18n bag adminNotif.* covers types/labels/categories/empty/markAllRead in nb + en using ICU select for nullable customerName + cancelledBy. New formatTimeAgo helper in lib/formatters.ts (Intl.RelativeTimeFormat — no date-fns dep). Tests: 40 unit (12 repo + 8 service + 6 booking listener + 7 payment listener + 7 controller) + 7 e2e (list, cross-user isolation, ?unread filter, unread-count, mark-read happy + foreign no-op, mark-all-read). 1372 API pass (+40, 1332→1372) · 137 web pass · 66 e2e (+7, 59→66) · lint 0/0 · build clean both repos. Deferred: SSE/WebSocket realtime (polling now), PaymentPartiallyRefunded inbox row, mobile push (waits on mobile scaffold), ADMIN role bell visibility (waits on login-as-tenant). |
| 2026-04-25 | Status-matrix P1-4 · transient vs permanent payment-failure classification + retry endpoint + email/SMS nudge. Closes the destructive-cancel UX where a single card decline (or 30-second provider blip) wiped a booking with no path to recover. API · domain: new PaymentFailureKind enum (TRANSIENT | PERMANENT) on PaymentFailedPayload. Payment.markFailed(code, message, kind, at) is the single emit point — kind required, lives only on the event payload (no schema migration; failed Payment is terminal so rehydration doesn't need it). Bambora webhook payment.rejected / payment.rejected_capture map to PERMANENT (provider-driven decline = card / fraud / expired / do-not-honor); TRANSIENT is reserved for future provider-timeout flows that don't yet exist (Bambora's adapter retries 5xx inside the call). API · listener split: OnPaymentSettledNegativeListener extended with retry-cap logic (PAYMENT_RETRY_CAP = 3, exported). PERMANENT + PENDING + failed-count < cap → keep PENDING (customer retries with different card); PERMANENT + PENDING + failed-count >= cap → cancel reason=PAYMENT_RETRY_EXHAUSTED; TRANSIENT + PENDING → never cancels (provider-health, not customer-card); CONFIRMED top-up retry race → skip; PaymentExpired path unchanged from P1-11. New repo dependency on PAYMENT_REPOSITORY to count prior FAILED rows per booking. API · projection: BOOKING_DEPOSIT_STATUS.RetryPending added to the const map; OnPaymentStateProjectionListener writes RETRY_PENDING on every PaymentFailed regardless of kind so the FE retry CTA appears consistently. The legacy PAYMENT_FAILED key stays in the map but no listener writes it now — once the retry-cap exhausts and the booking flips CANCELLED, depositStatus is informational and UI keys off booking.status. API · retry endpoint: POST /public/tenants/:slug/bookings/:id/payment/retry (@CustomerAuth()). Validates booking PENDING + depositStatus RETRY_PENDING + ownership + failed-count < cap; mints fresh Payment row via InitiatePaymentCommand with idempotencyKey = bk-${id}-retry-${attempt} reusing the original Money + intent + captureMode (no settings re-derivation); metadata { retryOf, attempt } for audit. The freshest Payment row by updatedAt becomes what GET .../payment hands the FE — pre-existing logic, unchanged. API · notification listener (OnPaymentFailedRetryNotificationListener): subscribes PaymentFailed (any kind), enqueues BOOKING_PAYMENT_RETRY SMS + email with deep-link ${PUBLIC_WEB_URL}/account/bookings?retry=<id>. Skips when booking already past PENDING (race with cancel listener), failed-count at cap (about-to-cancel), or no contact at all. Linked-account contact wins over snapshot. Queue failure swallowed (fire-and-forget). API · email channel: new EmailProvider port + LogEmailProvider default impl (logs [EMAIL → to] subject\nbody so dev/staging see what would have shipped). Real SMTP/SendGrid wiring deferred until vendor sign-off — EMAIL_PROVIDER env var is the switch. NotificationProcessor updated to fan-out SMS + email independently; new EMAIL_SUBJECTS table makes email channel opt-in per NotificationType so legacy SMS-only events don't accidentally spam inboxes. Web: account/BookingsSection shows yellow "Retry payment" button when depositStatus=RETRY_PENDING && status=PENDING; click → POST /payment/retry → redirect to new checkoutUrl via hard navigation. Email deep-link (?retry=<id>) auto-fires the mutation on landing and strips the param so a refresh doesn't churn (guarded by useRef to dedupe re-renders). 4 new i18n keys per locale (retryPayment, retryPaymentRedirecting, retryPaymentExhausted, retryPaymentFailed). api-client.CUSTOMER_AUTHED_PUBLIC_PATHS extended to cover /payment/retry + /cancel-preview so token-refresh works on both. Tests: 26 new API — 4 domain (markFailed kind required, TRANSIENT path, captured-state guard, code-required), 9 listener (PERMANENT below-cap keep, PERMANENT cap-reached cancel with reason, CONFIRMED skip, TRANSIENT never cancels regardless of count, ad-hoc bookingId, cross-tenant, race swallow, unexpected re-throw, expired path regressions stay green), 9 retry-endpoint (happy path, customer fallback, 404 wrong tenant, 403 wrong customer, 403 guest, 422 not-PENDING, 422 not-RETRY_PENDING, 422 no prior payment, 422 cap exhausted, latest-Payment retryOf metadata), 5 processor (email-when-subject, SMS+email fan-out, no-email skip, no-subject-type skip, regression coverage). Updated webhook spec asserts PERMANENT kind on rejection. 1332 API pass (+26, 1306→1332) · 137 web pass unchanged · lint 0/0 · build clean both repos. Deferred: guest retry (no JWT) — needs magic-link via email; real SMTP/SendGrid impl; server-side automatic TRANSIENT retry without customer click. |
| 2026-04-25 | Status-matrix P1-11 · Bambora 7-day auth-hold cap + auto-cancel CONFIRMED on PaymentExpired. Closes the silent-failure case where a booking >7 days out went CONFIRMED on the deposit auth, then quietly lost the hold before service. Two-pronged fix per the spec recommendation. API · settings hard cap: validateSettingsCombination (originally introduced for P1-5) now also rejects depositEnabled=true && maxBookingDaysInAdvance > 7 with TENANT_SETTINGS_DEPOSIT_LEAD_TIME_CONFLICT. The cap lives in a named constant DEPOSIT_LEAD_TIME_CAP_DAYS = 7 in tenant-settings.validation.ts so when we move to a provider with longer holds the bump is one line. API · listener: OnPaymentSettledNegativeListener extended — PaymentExpired now also cancels CONFIRMED bookings (PENDING was already handled). The auth hold is gone, so the salon would deliver service unpaid; we cancel via updateStatus(CANCELLED, role: SYSTEM, { reason: 'AUTHORIZATION_EXPIRED' }). SYSTEM bypasses both isValidTransition (uses isAdminTransition) and the cancellation-window guard, so no force=true needed. PaymentFailed keeps PENDING-only behaviour (a CONFIRMED booking with a later failed top-up retry must keep its prior good auth). ARRIVED/IN_PROGRESS/COMPLETED/CANCELLED skipped — admin already acted. Resulting BookingCancelled event fires the existing BOOKING_CANCELLED SMS so the customer is notified without any new listener. API · audit reason for SYSTEM: BookingService.updateStatus now writes options.reason to the note audit column when the performer is SYSTEM (previously only forced admin overrides recorded a reason); the STATUS_CHANGE action stays unchanged. Web · settings UI: bookingPolicySchema.superRefine mirrors the API rule so the inline error maxBookingDaysDepositMax appears before save; BookingPolicyEditor swaps the soft warning banner for an error-tone message that explicitly says "lower this to 7 or less, or turn off deposit". New error-code translation TENANT_SETTINGS_DEPOSIT_LEAD_TIME_CONFLICT (en + nb). Tests: 10 new — 5 validator/tenant-service spec cases (boundary 7, < 7, default 30 with deposit off, reject 8 + 30, partial-patch merging), 5 listener spec cases (PaymentExpired + CONFIRMED → cancel with reason, ARRIVED skip, COMPLETED skip, plus undefined reason arg added to existing PENDING assertions). 1306 API pass (+12, 1294→1306) · 137 web pass unchanged · lint 0/0 · build clean both repos. Re-auth-link UX (option C of the original spec) deferred — the hard cap closes the misconfig path; if a tenant somehow lands in the bad state, auto-cancel + customer rebook covers it. |
| 2026-04-24 | Status-matrix P1-3 · phone-booking IN_PERSON derivation. Follow-up to P0-3 (walk-in path). BookingService.create now passes paymentMode: 'IN_PERSON' to buildCreatedEvent whenever dto.source === BookingSource.PHONE — staff-on-behalf-of-customer bookings where the customer isn't at a device, so PSP redirect never reaches them and the Payment session gets stuck INITIATED. OnBookingCreated listener already short-circuits on IN_PERSON (P0-3 wiring), no further change. ADMIN source kept on the PSP path — admins may still send payment-link SMS to the customer; a future flag can let them opt into IN_PERSON when needed. Schema column deferred: value only flows through the event payload, persisting on Booking adds no business value today. 3 new tests — PHONE → IN_PERSON emitted, ONLINE → undefined (default), ADMIN → undefined. 1294 API pass (+3, 1291→1294) · 137 web pass unchanged · lint 0/0 · build clean. Closes case #3 in 01-create.md; the remaining stuck-INITIATED path is covered by P1-11 (7-day Bambora auth expiry). |
| 2026-04-24 | Status-matrix P1-8 · cancel refund preview. Admin + customer now see what happens to the money before confirming a cancel: full refund of NOK X / auth hold released / deposit forfeited / nothing to refund. API: new pure helper buildCancellationPreview(input) layered on top of the existing decideCancellationRefund policy — returns { decision, refundAmount, forfeitAmount, voidAmount, hoursUntilStart, withinWindow, cancellationWindowHours, currency }. Mirrors the FULL_REFUND → VOID defensive fallback in PaymentIntegrationService.onBookingCancelled (preview stays truthful even when a PARTIALLY_REFUNDED row hits the outstanding=0 edge). BookingService.previewCancellation(id, tenantId, performer) loads booking + settings + picks the first non-terminal Payment via findByBookingId, derives cancelledBy from performer.role. Two endpoints: admin GET /bookings/:id/cancel-preview (STAFF/OWNER/ADMIN, STAFF scoping via existing assertStaffCanAccess) and public GET /public/tenants/:slug/bookings/:id/cancel-preview (@CustomerAuth(), same ownership check as POST cancel — forged bookingId against wrong slug gets 404, guest bookings + cross-customer get 403 BOOKING_NOT_IN_CUSTOMER_SCOPE). Web: new CancelPreviewDialog wrapper that picks admin vs public query based on the optional publicSlug prop; admin BookingList + BookingDrawer intercept CANCELLED status-change (other transitions untouched) → open dialog → on confirm fire updateStatus.mutate; if the server returns BOOKING_CANCELLATION_TOO_LATE the existing ForceOverrideModal still fires for admins, so preview + force flow compose without stepping on each other. Customer portal account/BookingsSection replaces the generic ConfirmDialog with CancelPreviewDialog; OutOfWindowDialog still handles the 422 branch. Currency formatted from preview.currency (tenant source of truth) via Intl.NumberFormat('nb-NO') so the dialog works in customer portal where admin useCurrency has no tenantId. 11 new i18n keys per locale (en + nb) under bookings.cancelPreview.*. Tests: 23 new — 11 on cancellation-refund-policy.spec.ts (new buildCancellationPreview block: customer VOID/FORFEIT/NO_ACTION, CAPTURED/PARTIALLY_REFUNDED amounts, SALON past-window FULL_REFUND, terminal statuses, defensive FULL_REFUND→VOID degradation), 7 on booking.service.spec.ts (no-payment NOT_APPLICABLE, SALON AUTHORIZED → VOID, SALON CAPTURED minus refunded, CUSTOMER out-of-window FORFEIT, terminal-payment skip, STAFF scoping allow/deny), 1 on booking.controller.spec.ts (admin endpoint wiring), 4 on public-booking.controller.spec.ts (happy path + 404 + 403 cross-customer + 403 guest). 1291 API pass (+24, 1267→1291) · 137 web pass unchanged · lint 0/0 · build clean both repos. OpenAPI spec + TypeScript types regenerated. |
| 2026-04-24 | Build fix for 4 GB production VPS — iteration 2: env-flag opt-out + husky pre-push gate. The typescript.ignoreBuildErrors switch was flipped from "always on" to process.env.BUILD_SKIP_TYPECHECK === '1', so local dev + CI build with the full safety net and only the memory-tight server trims the check. NODE_OPTIONS dropped from the build script — it's now an opt-in second-line fallback documented in .env.example. New .husky/pre-push hook runs yarn lint && yarn typecheck && yarn test (~12 s, 137 tests) so the server opt-out never lands a type error in prod. husky 9.1.7 added as devDep; prepare: husky wires the hook up on yarn install. Default build 14.4 s, skip-TS build 10.5 s with "Skipping validation of types" banner. |
| 2026-04-24 | Build fix for 4 GB production VPS — iteration 1: hardcoded ignoreBuildErrors: true + NODE_OPTIONS='--max-old-space-size=3072' in build script. Superseded the same day by iteration 2 (env-flag gate). |
| 2026-04-24 | Status-matrix P1-1 · customer self-cancel endpoint. New POST /public/tenants/:slug/bookings/:id/cancel gated by @CustomerAuth() — tenant scoped via slug (404 when booking belongs to another salon), ownership check booking.customerId === customerUser.customerId (guest bookings + mismatches get 403 BOOKING_NOT_IN_CUSTOMER_SCOPE), then delegates to BookingService.updateStatus with { role: 'CUSTOMER', userId: customerId } so the existing cancellation-window guard runs (!isSystem && !isAdmin branch) and BookingCancelled emits cancelledBy=CUSTOMER. Web: api-client taught to treat /public/tenants/.../bookings/.../cancel as a customer-authed public path (new CUSTOMER_AUTHED_PUBLIC_PATHS pattern list — still resolves as public for routing but triggers ensureValidCustomerToken + retry-with-customer-refresh on 401). account/BookingsSection renders a red "Cancel" CTA on PENDING/CONFIRMED/ARRIVED rows, gated behind ConfirmDialog; on success invalidates ['customer','bookings'] so the list re-fetches with the new status. 6 new i18n keys (en + nb). Tests: 6 new unit tests on PublicBookingController (happy path, guest booking, owner mismatch, tenant not found, window-guard passthrough, slug not found). 1233 API pass (+6) · 137 web pass · lint 0/0 · build clean both repos. Out-of-window UX (P1-2 "Contact salon" CTA) still open. |
| 2026-04-24 | Status-matrix P1-5 · autoConfirm + depositEnabled combo guard. The combo was a silent foot-gun — autoConfirm=true flipped a booking to CONFIRMED before the deposit payment verified, and OnPaymentSettledNegativeListener then short-circuited on status !== PENDING when the payment subsequently failed, leaving the booking CONFIRMED without money held. Fix per the spec's option 1 (block at write time, deterministic). API: shared helper tenant-settings.validation.ts::validateSettingsCombination wired into TenantService.create + .update + OnboardingService.saveStep, always validates the merged state (current row + patch) so a one-field PATCH that would end up with both true still throws 422 TENANT_SETTINGS_AUTOCONFIRM_DEPOSIT_CONFLICT. Web: SwitchField gains a disabled prop (greyed + ignores clicks + swapped cursor-not-allowed); BookingPolicyEditor feeds each switch's enabled state from the other's current value via useWatch, and the description beneath each switch swaps to an explanation of why it's locked ("Turn off Require deposit to enable this") so the owner never has to guess. New i18n keys + error translation. Tests: 8 new — 6 pure-helper unit tests (both-false / one-each / partial-patch / both-true rejection × 2 assertions) + 3 service-level cases (update reject with existing autoConfirm=true, reject both-true in one patch, allow disabling one side of a legacy row). 1267 API pass (+8, 1259→1267) · lint 0/0 · build clean both repos. |
| 2026-04-24 | Status-matrix P1-10 · refund / partial-refund / void customer notifications. OnPaymentNotificationListener (in core/booking/) subscribes PaymentRefunded, PaymentPartiallyRefunded, PaymentVoided and enqueues a NotificationService job with a Norwegian SMS template carrying the salon name, booking date/time, and (for refund events) the amount in major units via toLocaleString('nb-NO'). Phone resolution prefers the linked Customer row (auth bookings) and falls back to the booking snapshot customerPhone column (guest bookings); notifications drop silently with a debug log when neither is present. Cross-tenant safety via findFirst({ where: { id, tenantId: event.tenantId } }) — a forged event for another salon returns null and is skipped. Queue failures are swallowed (fire-and-forget) so the NotificationService being down doesn't trigger outbox retries for work that already completed. New NotificationType union members BOOKING_REFUNDED / BOOKING_PARTIALLY_REFUNDED / BOOKING_VOIDED + NotificationPayload.data.{amount,cumulativeAmount,currency} optional fields + formatMoney helper in notification.constants.ts. Tests: 13 new — 3 happy-path one-per-event, 3 new template render cases (money formatting, cumulative, void-without-money), null-bookingId skip, cross-tenant guard, phone fallback, no-phone drop, queue-failure soft-fail, unrelated-event no-op, bus-wiring smoke. 1259 API pass (+13, 1246→1259) · lint 0/0 · build clean. Email channel deferred (SMS-only for now); P2-7 NoShow reuses this template pattern. |
| 2026-04-24 | Status-matrix P1-9 · depositStatus projection listener. Booking row now carries a read-model reflection of the tied Payment's lifecycle so admin UI can render "Deposit PAID / AUTHORIZED / REFUNDED / …" without crossing aggregates. Schema: new nullable Booking.depositStatus TEXT (enum stays TEXT under P1-9, P2-8 will tighten to Prisma enum later). Migration 20260424085654 bundles the column add + a backfill from the latest DEPOSIT-intent Payment per booking via DISTINCT ON (booking_id) — fresh DBs and prod both land consistent. Listener: OnPaymentStateProjectionListener in core/booking/ subscribes all 8 Payment events (Initiated/Authorized/Captured/PartiallyRefunded/Refunded/Voided/Failed/Expired) and maps per the table in docs/flows/status-matrix/05-payment-driven.md §4 (PENDING / AUTHORIZED / PAID / PARTIALLY_REFUNDED / REFUNDED / VOIDED / PAYMENT_FAILED / EXPIRED). Write goes through updateMany with where: { id, tenantId, NOT: { depositStatus } } — one statement combines the cross-tenant guard + the "skip if unchanged" optimisation so outbox re-delivery / webhook replay don't churn updatedAt. Ad-hoc payments (bookingId=null) are skipped. Event payloads enriched: PaymentCaptured, PaymentRefunded, PaymentPartiallyRefunded, PaymentVoided now carry bookingId so the listener can resolve the aggregate without a Payment lookup (Authorized / Failed / Expired / Initiated already had it). Tests: 13 new unit tests — table-driven coverage for each of the 8 events, subscription wiring, null-bookingId skip, cross-tenant guard, idempotent re-delivery, unrelated-event no-op. 1246 API pass (+13, 1233→1246) · 137 web pass unchanged · lint 0/0 · build clean both repos. Admin UI badge + P1-10 refund notifications unblocked. |
| 2026-04-24 | Status-matrix P1-2 · out-of-window cancel dialog. Follow-up to P1-1 — before this, customers hitting BOOKING_CANCELLATION_TOO_LATE got a red toast and a dead end. New OutOfWindowDialog.tsx turns that into a guided screen: warning icon + policy message with cancellationHours pulled from the public tenant endpoint (/public/tenants/:slug, lazy-fetched only when the dialog opens, staleTime 5 min), a copyable booking reference card (salon, booking ID, appointment time) so the customer can screenshot when calling the salon, primary CTA linking to /b/{slug} for salon info, secondary Close. BookingsSection onError branches on ApiError.code === 'BOOKING_CANCELLATION_TOO_LATE' → open dialog; any other error still routes through the toast. State refactor: cancelling now holds the full CustomerBooking (was {slug,id}) so the dialog has everything it needs without a re-fetch. 8 new i18n keys under customerAuth.outOfWindow.* (en + nb). Deferred: tel:/mailto: CTAs — Tenant schema currently has no contactPhone/contactEmail column (only OWNER User.phone exists), will follow up once onboarding captures salon contact. Tests: 137 web pass unchanged · lint 0/0 · build clean. |
| 2026-04-24 | Seed consolidation: seed-multistore.ts folded into seed.ts as section 13 so yarn seed boots with the full multi-tenant login fixture (multistore@gmail.com OWNER at studio-nordic + owner2, STAFF at owner3 + owner4 with linked Resource + Mon-Fri schedule). Extra tenants use getDefaultSettings('beauty') + DEFAULT_BRANDING to stay compliant with the no-fallback-settings rule. Standalone seed-multistore.ts deleted. Lint + build clean. |
| 2026-04-24 | Tenant picker v2 + multi-tenant login hardening. UX (booking-web) TenantPicker rewritten to group tenants by role (Salons you own / Salons you work at), each group framed in its own rounded-2xl tinted section (brand indigo for OWNER, blue-light sky for STAFF), 56×56 gradient-tinted avatars, name/slug stacked, unified brand-accent hover (-translate-y-0.5 lift + ring-2 + bg contrast — earlier attempt used group-matched tint which collapsed into the section gradient in dark mode), neutral gray count chip shared across groups, Users → Briefcase icon for STAFF, header icons go flat (no 40px filled chip) per user feedback. 3 new i18n keys: chooseTenantGroupOwner/Staff/Other (en + nb). API (booking-api) AuthService.login bcrypt loop parallelised — for (const candidate of users) { await bcrypt.compare(...) } → Promise.all(users.map(...)). Native bcrypt runs on the libuv thread pool so this is real parallelism, cutting multi-tenant login latency from O(N × ~100ms) to ~one compare regardless of how many salons the email belongs to. AuthController.login moved off the shared AUTH_THROTTLE (10/60s/IP) onto a dedicated LOGIN_THROTTLE = 5/60s/IP — login is the only bcrypt-heavy hot path and the main brute-force surface; register/refresh stay at 10/60s. Customer /auth/customer/google unchanged (OAuth, no bcrypt path). Tests: 87/87 auth (API unit + e2e) pass · 7/7 web SignInForm.test.tsx pass. Lint 0/0, build clean both repos. |
| 2026-04-23 | Edit-staff follow-ups: role change (OWNER/ADMIN only, self-demotion blocked), Login Access polish + required Role select, Commission+Color 2-col layout, Email column on Team list, filter by status on Team list. Backend: PATCH /resources/:id now forwards login.role → bumps tokenVersion + refuses when performer.userId === resource.userId (STAFF_ROLE_SELF_CHANGE_NOT_ALLOWED); ResourceQueryDto gains `status: 'active' |
| 2026-04-23 | Edit-staff login panel (admin can reset password + edit phone in the same drawer). ResourceService.update gains a login block: resource without a linked User + {email, password, role} → creates + links the User in a single $transaction (reuses the Phase 4 create-with-login pattern); resource with a linked User + {password} → hashes + bumps tokenVersion so every stale JWT the staff still holds is rejected on the next /auth/me; + {phone} diff → updates with tenant-scoped phone-conflict check (new excludeUserId arg on assertNoUserConflict so re-saving without edits never trips STAFF_PHONE_CONFLICT against self). Email and role are immutable on the existing-user path — admin must delete+recreate the staff to reshape identity. findById + findAllByTenant now include user: { id, email, phone, role, isActive } (password hash explicitly excluded). New error codes STAFF_LOGIN_PASSWORD_REQUIRED, STAFF_LOGIN_ROLE_REQUIRED. Frontend StaffFormModal branches by staff.user: linked User → "Login access" panel (readonly email + role dl-grid, editable PhoneField prefilled from user, Change-password switch that reveals a password field when toggled); no linked User → the existing "Create login account" form (now shown in edit mode too, previously hidden). Zod schema changePassword flag gates the password-length-6 check so unrelated edits don't fail validation. Submit builder diffs phone and only attaches login.phone when it actually changed; login.password only when the Change-password switch is on. i18n resources.loginAccessTitle/loginAccessHint/changePassword/changePasswordHint/newPassword (en + nb). Tests: +8 API (resource.service): add-login-on-update happy path + missing password/role rejection + password-reset + phone update + phone-unchanged no-op + email/role-ignored guard + phone-conflict rejection; +3 web (StaffFormModal): add-login branch in edit, Login-Access panel render, password-reset submit, no-login-object on pure resource edit. 1204 API pass (+8) / 134 web pass (+3). Lint 0/0, build clean, staff-role E2E still 7/7. |
| 2026-04-23 | Multi-tenant sign-in picker (AUTH_TENANT_REQUIRED UX). AuthService.login now verifies the submitted password against every User record matching the email BEFORE disclosing tenant membership (stops email-enumeration via AUTH_TENANT_REQUIRED that previously leaked "email X is registered at salon A/B/C" on any wrong password). Match=0 collapses to AUTH_INVALID_CREDENTIALS; match=1 auto-logs-in regardless of how many tenants the email exists at; match≥2 + no tenantSlug throws AUTH_TENANT_REQUIRED with details.tenants: [{slug, name, logoUrl}] sourced from tenant.settings.branding.logoUrl. ApiError + ApiResponse gained an optional context: Record<string, unknown> field so error handlers can carry structured payload beyond the standard FieldError[] shape; HttpExceptionFilterGlobal.extractContext forwards any keys on the thrown object beyond the Nest envelope (statusCode/message/error/details) as context. Frontend: SignInForm holds an in-memory pickerState = {tenants, email, password} — on AUTH_TENANT_REQUIRED it swaps the form for a new TenantPicker component (logo/name/slug cards, ArrowLeft back button, per-card spinner during submission); clicking a card calls login(email, password, tenantSlug) a second time, success lands on /admin normally, failure clears the picker. Password is never written to storage — F5 at the picker drops back to the form (rare case, acceptable). Cookie set after successful picker pick means F5 after auth works unchanged (JWT carries the chosen tenantId). i18n: auth.chooseTenantTitle/Subtitle/Back (en + nb). Tests: +3 API (password matches 0/1/≥2 tenants) · +3 web (picker renders / click calls login w/ slug / Back returns to form) → 1196 API pass (+2 new - 0 removed, note: 1 test enhanced with tenants assertion) · 131 web pass (+3). lint 0/0 both repos; build clean. |
| 2026-04-22 | Tenant Onboarding wizard (7 steps full-page, Tenant.onboardedAt gate + OnboardingGuard + monotonic stepper) + foundational form-infra fixes: Zod v4 .min(n, "key") syntax (v3 { message } was silently ignored), FormField migrated to useController (React Compiler was eliding formState.errors proxy subscription → inline errors invisible, setValue→DOM lost), city/postal/country disabled across Onboarding + Settings with consistent gray styling, search dropdown reuses handleMapClick (nominatim forward search is sparse), deposit-requires-payment cross-check warnings (admin BookingPolicyEditor amber Alert + customer BookingPage red banner + disabled submit). |
Detailed Entries
Long changelog entries from 2026-04-17 onwards are tracked as individual subsections to keep the Timeline table readable. Entries are ordered by date ascending.
2026-04-17 — Payment Phase 6 WIP
Payment Phase 6 WIP — extract shared primitives (domain-event, event-bus, outbox port + in-memory, uuid-v7, clock ports/impls) out of payment/shared/ into neutral src/shared/ (events, ids, clock); 24 consuming files re-pathed. Prisma migration add_outbox_last_attempt_at adds nullable last_attempt_at + composite index for backoff-aware polling. Booking context will now share these primitives without depending on Payment. 48 suites / 439 tests still green.
2026-04-17 — Payment Phase 6 WIP
Payment Phase 6 WIP — extend OutboxRepositoryPort: append returns generated UUID v7 IDs (enables enqueue-after-commit), new findById for processor re-hydration, listStuck(beforeAt, limit) for janitor backoff filter, markFailed(id, error, attemptedAt) records lastAttemptAt, deletePublishedOlderThan(cutoff) for 30-day retention. In-memory + Prisma impls updated, 16 new tests (20 in-memory + expanded Prisma spec). 455 tests green.
2026-04-17 — Payment Phase 6 — Outbox BullMQ pipeline COMPLETE
Payment Phase 6 — Outbox BullMQ pipeline COMPLETE. New OutboxModule wires a hybrid publisher: (1) OutboxQueue.enqueuePublish(id) hot path called after dual-write commits, (2) repeatable janitor job scans stuck rows every 30s and re-enqueues (bypasses rows at max attempts — dead-letter), (3) repeatable cleanup job deletes published rows older than 30 days. OutboxPublisherService re-hydrates DomainEvent from row (eventId = outbox.id) and dispatches to EventBus, marking published/failed idempotently. OutboxMetrics adds Prometheus counters (payment_outbox_published_total{event_type,tenant_id}, _retries_total, _dead_letter_total) + gauge (payment_outbox_unpublished); BullBoard registers the outbox-publisher queue. Registry is injectable so tests isolate cross-contamination. PaymentModule now re-exports OutboxModule — Booking will depend on the neutral module directly. 84 suites / 848 tests green.
2026-04-17 — Payment Phase 6 resilience
Payment Phase 6 resilience — add 'error' event listeners on BullMQ Queues (OutboxQueue, PaymentWebhookController) + @OnWorkerEvent('error') on both processors. Before: a transient Redis blip would emit an unhandled 'error' event and crash the Node process in production. Now: logged-and-continue. OutboxQueue.onModuleDestroy closes the queue explicitly for clean SIGTERM/SIGINT shutdown (verified: dev server exits instantly on Ctrl+C, no stack trace). Module spec points at the real Docker Redis (localhost:6389) so BullMQ lifecycle runs for-real instead of against a fake port. 848/848 tests still green.
2026-04-18 — Authorization expiry cron
Authorization expiry cron — production-readiness piece for Bambora's 7-day authorization hold. New repeatable BullMQ job payment-expiry:sweep (15-min cadence) scans Payment rows with status ∈ {INITIATED, AUTHORIZED} and expiresAt ≤ now(). For each: if AUTHORIZED + has providerTransactionId + adapter advertises supportsVoid + active PaymentConfig present, make a best-effort provider.void({ reason: "AUTHORIZATION_EXPIRED", idempotencyKey: "expire-{paymentId}-{uuid}" }) — failure does NOT block the domain transition. Always calls payment.markExpired(now) → event emitted via existing outbox → EventBus → listeners. Per-payment try/catch isolates failures (race → InvalidStateTransitionError → recorded as skipped:INVALID_STATE; repository.save failure → skipped:SAVE_FAILED; batch continues). Idempotent at the query level — findExpirable filters non-terminal statuses, so a retried sweep only sees payments that still need work. New PaymentRepositoryPort.findExpirable(asOf, limit) is a cross-tenant system scan (documented exception to the tenant-scope rule — not exposed to tenant callers). Prometheus metrics: payment_expiry_swept_total{provider_key,from_status}, payment_expiry_void_failed_total{provider_key}, payment_expiry_skipped_total{reason} — registry is DI-injected for test isolation (follows OutboxMetrics pattern). Queue wiring mirrors OutboxQueue: queue.on('error') listener prevents Node crashes on Redis blip, onModuleDestroy closes queue for clean SIGTERM, scheduler registration in try/catch so transient Redis failures don't block app startup. BullBoardModule.forFeature registers payment-expiry alongside the webhook queue. 9 service tests (happy paths for AUTHORIZED+void, INITIATED no-void, void failure best-effort, inactive config skip, unsupported capability skip, race isolation, save failure isolation, clock respect, empty batch), 6 queue tests, 3 processor tests, 5 metrics tests, 2 Prisma repo tests, 1 PaymentModule wiring test. 92 suites / 926 tests green (+26). E2E still 57/57.
2026-04-18 — E2E green — fix 12 preexisting failures flagged after Phase 6B
E2E green — fix 12 preexisting failures flagged after Phase 6B. customer-auth (8): JWT test helper now fetches customer.tokenVersion and includes v in payload so the CustomerJwtAuthGuard tokenVersion check passes; POST /auth/customer/refresh test asserts HttpOnly Set-Cookie headers instead of expecting accessToken/refreshToken in body (endpoint returns { expiresAt } only for XSS protection). public-booking (4): tests realigned to the post-1ec327c guest flow where contact info is snapshotted on the Booking row only — guest booking must NOT auto-create a Customer record, multiple guest bookings with same phone do not dedupe, customerEmail-only booking snapshots email without creating Customer, customerName may be null (no "Guest" server-side default). Test helper rename should create booking with customer auto-create → should snapshot guest customer contact on booking; should reuse existing customer by phone → should allow multiple guest bookings with same phone (no dedupe for guests); should default customer name to Guest when not provided → should allow booking without customerName (null snapshot). Full suite: 900 unit + 57 e2e all green (0 failures, 0 skipped). Also added .DS_Store to booking-api, booking-mobile, docs/ gitignores.
2026-04-18 — Track D1 — Provider switch: Worldline Direct → Bambora Europe Checkout (Classic)
Payment Phase 7 Track D1 — Provider switch: Worldline Direct → Bambora Europe Checkout (Classic). Discovered mid-track that the existing "bambora/" adapter was coded against Worldline Direct API (preprod.worldline-solutions.com) but Norwegian SMB merchants sign up for Bambora Europe Checkout, a distinct product — Worldline doesn't onboard directly in NO, which is why they acquired Bambora in 2020. Split cleanly into two adapters: (1) providers/worldline-direct/ (git mv from bambora/ + rename BamboraAdapter → WorldlineDirectAdapter + BamboraCredentials → WorldlineDirectCredentials + mapBamboraErrorResponse → mapWorldlineDirectErrorResponse + BAMBORA_*BASE_URL → WORLDLINE*BASE_URL + adapter.key = ProviderKey.WORLDLINE) registered under new ProviderKey.WORLDLINE, kept for future enterprise migration but hidden from UI as "Coming soon"; (2) new providers/bambora/ implementing Bambora Classic: Basic-auth(accessToken:secretToken) in Authorization header, 4 base URLs shared between test and production (api.v1.checkout.bambora.com + transaction-v1 + merchant-v1 + login-v1), test-vs-production determined by merchant-number T/P prefix (server-side), MD5 callback signature (compute + constant-time verify, hash field stops concat per PHP spec), meta.result-based success check (Bambora returns 200 with meta.result=false on business failures — adapter maps those to ProviderError), endpoint map: createSession → POST /checkout, capture → POST /transactions/{id}/capture, refund → POST /transactions/{id}/credit, void → POST /transactions/{id}/delete, fetchStatus → GET merchant/transactions/{id}, verifyWebhook → parse query string + MD5 check, healthCheck → GET login/merchant/functionpermissionsandfeatures. Prisma enum PaymentProvider gains WORLDLINE via additive ALTER TYPE ADD VALUE migration (DB had no rows yet — safe repurpose). Enum-drift guard test auto-picks up WORLDLINE via dynamic Object.values. Adapter file sizes: 326 LOC adapter + 66 credentials + 73 signature + 69 errors + 84 mapper + 4 endpoint consts. Test count: +72 (12 credentials incl. base64 Authorization header + 9 MD5 signature + 7 errors incl. insufficient-funds codes 1220/1221/1230/1235/1240 + 10 http-client + 12 mapper + 7 retry copy + 15 adapter contract). Frontend adjusts in the same branch: credentials form rewritten to 4 fields (merchantNumber optional T/P + accessToken + secretToken + md5Key) + live MerchantModeBadge that flips Test (amber) / Production (green) as owner types. No more useSandbox toggle anywhere — single source of truth is the T/P prefix. i18n keys renamed (nb + en): form.bambora.{merchantNumber, accessToken, secretToken, md5Key, testModeLabel, productionModeLabel} + removed Worldline-shaped keys (merchantId/apiKeyId/secretApiKey/webhookKeyId/webhookSecret/useSandbox). E2E integration spec env vars swapped to E2E_BAMBORA{MERCHANT_NUMBER?, ACCESS_TOKEN, SECRET_TOKEN, MD5_KEY} — auto-skip when missing. Provider list on /admin/settings?tab=payment shows Bambora (enabled) + Vipps MobilePay (Coming soon) + Worldline enterprise (Coming soon). HTTP client + retry utility duplicated into bambora/ for now — follow-up: lift to shared infrastructure/http/. Results: 999 API unit (was 927, +72 Bambora) · 46 web Vitest (was 42, +4 metadata test) · 4 Playwright tests listed · lint 0/0 · yarn build clean in both repos.
2026-04-18 — Track D1 — Admin UI: Provider Config (WIP)
Payment Phase 7 Track D1 — Admin UI: Provider Config (WIP) — first booking-web Payment Context screen. Backend: PaymentConfigDto converted from TS interface → class with @ApiProperty decorators + @ApiOkResponse/ApiCreatedResponse on every /admin/payment-configs endpoint; regenerated OpenAPI spec + api.generated.ts so the frontend consumes a fully-typed response shape (previously content?: never). Frontend test infrastructure bootstrapped from zero: Vitest 4 + @testing-library/react + jsdom (scripts test, test:watch) and Playwright 1.59 + chromium (scripts test:e2e — skips @integration, test:e2e:integration — only @integration, test:e2e:ui); shared createQueryWrapper() helper in src/test-utils/react-query.tsx; ESLint overrides react-hooks/rules-of-hooks for Playwright use fixtures; eslint globalIgnores test-results/ + playwright-report/. Schema layer: lib/payment/bambora-schema.ts — Zod form schema with 5 trimmed-required credential fields + optional displayName (≤100 chars) + useSandbox boolean default true; deriveBamboraCredentials maps the single toggle to both credentials.environment ('sandbox' | 'production') and PaymentConfig.isTest so Owner only sees one control; provider-metadata.ts constants order Bambora first, Vipps gated as disabled + "Coming soon". API client: hooks/usePaymentConfigs.ts exports usePaymentConfigList / useCreatePaymentConfig / useUpdatePaymentConfig / useRotatePaymentCredentials / useActivatePaymentConfig / useDeactivatePaymentConfig / useHealthCheckPaymentConfig — every mutation invalidates PAYMENT_CONFIGS_KEY = ['payment-configs'] and routes through useFormMutation for toast + error-code translation. Components in components/settings/payment/: HealthCheckBadge (OK/FAILED/not-yet with hover timestamp), ProviderCard (status badge + CTA: Connect when no config, Manage when configured; Vipps always "Coming soon" + disabled), BamboraConfigForm (FormProvider + react-hook-form + zodResolver; FormField for text fields, new PasswordField for secrets with Eye/EyeOff toggle + localized aria-labels, SwitchField for sandbox toggle; Create vs Rotate modes via showDisplayName/showSandboxToggle props), ProviderConfigDrawer (Wrapper/Inner reset-on-open pattern via key, switches to Bambora form; Vipps placeholder), ConnectedProviderActions (Verify/Activate/Deactivate/Rotate buttons, disabled while mutating). PaymentSettings orchestrates: fetch list, 2-column grid, open drawer on card click (create if no config, rotate if existing), auto-trigger health-check after create/rotate success. FormField + PasswordField updated to emit htmlFor/id labels for a11y + getByLabelText test ergonomics (no regression — no existing tests relied on missing labels). SettingsContent tab list grew to 8: general/booking/businessHours/branding/location/tax/payment/about with CreditCard icon, URL ?tab=payment; i18n keys under settings.payment.* covering providers, status, health check, actions, form field labels + hints, success messages; nb + en parity. E2E: e2e/payment-settings.spec.ts (3 smoke cases — renders cards, Vipps disabled with Coming soon, Bambora card opens drawer) and e2e/payment-settings-integration.spec.ts (@integration tag; auto-skips when E2E_BAMBORA_{MERCHANT_ID,API_KEY_ID,SECRET_API_KEY,WEBHOOK_KEY_ID,WEBHOOK_SECRET} envs missing; fills real Worldline preprod creds, waits for auto-verify badge, activates + deactivates, confirms state via /api/admin/payment-configs API). Playwright fixture logs in as seed OWNER (owner1@gmail.com/123456). Scope intentionally narrow for D1: only Bambora provider + verify API key flow; no payment list, no refund/void UI, no booking-detail integration, no delete config (deactivate covers pausing). Results: lint 0/0, build passes, 42 Vitest + 3 Playwright smoke + 1 Playwright integration tests.
2026-04-18 — Track B
Payment Phase 6 Track B COMPLETE — Booking context now publishes domain events through the outbox → EventBus → listeners pipeline. Source-of-truth event catalog moved from Payment to core/booking/domain/events/ (payload interfaces + stable string constants). Pure payload builders handle deposit rounding/clamping + ISO serialization (16 tests). Prisma migration adds processed_for_tenant_customer + processed_for_loyalty idempotency flags on bookings. BookingService.{create, updateStatus, walkIn} refactored to prisma.$transaction dual-write (booking row + domainEventOutbox.createMany commit atomically) and enqueue publish after commit — Redis unreachable is not fatal, janitor re-enqueues. Clock port injected for testable occurredAt. Inline tenantCustomerService.onBookingCompleted + loyaltyService.autoStamp/autoEarnPoints calls removed; LoyaltyService dependency dropped from BookingModule. Two new EventBus subscribers replace them: OnBookingCompletedTenantCustomerListener (upsert TC stats — atomic Postgres upsert + guarded updateMany for lastVisit to co-exist with Loyalty's upsert) and OnBookingCompletedLoyaltyListener (resolve tc.id via upsert, delegate to LoyaltyService). Both use CAS claim-first idempotency via the new flags and roll flags back on failure so outbox redelivery retries cleanly. URL convention in BookingCreatedPayload: returnUrl = {PUBLIC_WEB_URL}/b/{slug}/bookings/{id}, cancelUrl = {PUBLIC_WEB_URL}/b/{slug}, webhookUrl = {API_BASE_URL}/api/webhooks/payments (provider adapters append provider+tenantId). Lint cleanup: 11 errors + 63 warnings on the branch → 0/0 (e2e res.body.data typing, Prisma Where/InputJsonValue instead of as any, typed Request & { user/customerUser? } in auth decorators + guards, ms.StringValue cast for JWT expiresIn). New e2e test/booking-outbox.e2e-spec.ts drives the full pipeline synchronously (BullMQ short-circuited) — 5 cases cover outbox row shape + URLs, publish marks row published, Completed transitions trigger TC + Loyalty DB effects, idempotent re-publish, guest bookings leave flags false. 900 unit tests + 5 e2e green. 12 preexisting e2e failures (customer-auth 8, public-booking 4) present on branch before Phase 6B — flagged for a follow-up PR.
2026-04-18 — Track C1 — Public deposit plumbing (backend)
Payment Phase 7 Track C1 — Public deposit plumbing (backend). Four sub-steps, all TDD. C1.1: InitiatePaymentHandler now persists CreateSessionResult.redirectUrl into Payment.metadata.checkoutUrl — previously the provider's checkout URL was only returned once to the command caller and lost, which blocked mobile-backgrounded/refresh resume. Existing caller-supplied metadata is preserved via spread. +2 handler tests (persists checkoutUrl + preserves caller-supplied metadata). C1.2: resolveInitialStatus(settings, { depositRequired? }) — new options arg; when depositRequired=true we force PENDING regardless of autoConfirm. Rationale: staff must not see a green CONFIRMED card before the PSP actually holds the deposit; C2 OnPaymentAuthorized listener is what flips Pending → Confirmed once the hold lands. Deposit math extracted into computeDepositAmount(totalAmount, policy) (typed to a narrow DepositPolicy interface satisfied by both TenantSettings and the payload-builder input), used by both BookingService.create (for status resolution) and buildBookingCreatedPayload (for the outbound event). BookingService.create now sums resolvedItems[].price BEFORE the tx to know depositAmount, then passes { depositRequired: depositAmount > 0 } to resolveInitialStatus. +11 new helper tests (4 × resolveInitialStatus + 7 × computeDepositAmount including clamp-to-total guards). C1.3: new PublicBookingPaymentController at GET /public/tenants/:slug/bookings/:bookingId/payment returning { id, status, amount, capturedAmount, currency, checkoutUrl, expiresAt, updatedAt } or null when the onBookingCreated listener hasn't fired yet (async via outbox → EventBus). Resolves slug → tenantId via TenantService.findBySlug for tenant isolation (a caller cannot read another tenant's payment with a guessed bookingId). Multi-row bookings (retry after void) return the freshest by updatedAt. Empty bookingId normalised to 404. +6 controller tests. PaymentModule imports TenantModule to satisfy the DI. C1.4: POST /public/tenants/:slug/bookings response gains requiresPayment: boolean + paymentPollUrl: string \| null so the FE knows whether to redirect to the PSP. paymentPollUrl points at the new C1.3 endpoint. Unit-level spec mock needed items: [{ price }] shim (previously mockBooking was minimal); +2 e2e cases (deposit enabled → PENDING + poll URL; deposit disabled → CONFIRMED + null). Results: 101 suites / 1019 unit + 3 suites / 59 e2e all green · lint 0/0 · yarn build clean. Next: Track C2 (Bambora webhook → markAuthorized → OnPaymentAuthorized listener flips booking Pending → Confirmed).
2026-04-18 — Track C3 + C4 (verified + built)
Payment Phase 7 Track C3 + C4 (verified + built). C3 was pre-existing from Phase 6: PaymentIntegrationService already subscribed to the booking lifecycle — onBookingCompleted → CapturePaymentCommand (MANUAL + AUTHORIZED); onBookingCancelled → decideCancellationRefund policy → Void / Forfeit (capture) / FullRefund / no-op based on cancel window + cancelledBy; onBookingNoShow → capture as no-show fee. 10 integration-service tests cover every branch. Only the reverse direction (payment → booking) was missing, which C2.1/C2.2 filled. C4.3 customer booking-form deposit preview: public GET /public/tenants/:slug now surfaces depositEnabled/depositType/depositValue via sanitizeSettings (the three are useless individually so they ship as a triple). New lib/payment/deposit-calc.ts mirrors the backend helper so the UI preview matches the API charge — a rule change must land in both (7 vitest covering percentage/fixed/clamp edge cases). BookingPage: amber notice "A deposit of {amount} will be held on your card when you confirm" appears when depositAmount > 0; submit CTA swaps to "Continue to payment · {amount}" and the spinner text changes to "Redirecting to secure checkout…" while redirectToCheckout works its poll-until-checkoutUrl loop. i18n book.{continueToPayment,depositNotice,redirecting} nb + en. C4.1 admin booking-drawer payment summary: new BookingPaymentSummary component inside BookingDrawer (edit mode only) shows Total / Deposit + live status badge / Paid (sum of capturedAmount − refundedAmount across retries) / Remaining (total − paid, clamped to ≥0), plus a failure banner when the latest Payment is FAILED. Powered by useBookingPayments(bookingId) → GET /admin/payments/by-booking/:bookingId (enabled:false when no bookingId). BookingDrawer imports useCurrency() alongside useFormatMoney() to hand the currency code to the summary. FE Payment type mirrored into types/payment.ts pending a downstream OpenAPI regen. i18n bookingPayment.{title,total,deposit,paid,remaining,status.*} nb + en. C4.2 deferred — admin booking-list deposit-badge column needs the backend to fold paymentStatus into the booking-list DTO to avoid N+1 per-row fetches; logged on the roadmap. Results: 1036 unit + 59 e2e · 71 vitest (64 → 71, +7 deposit-calc) · lint 0/0 · both builds clean.
2026-04-18 — Track C2 FE — public deposit redirect + return landing pages
Payment Phase 7 Track C2 FE — public deposit redirect + return landing pages. Backend: BookingService.buildBookingUrls now routes returnUrl to /b/{slug}/bookings/{id}/payment/return (poll-until-status) and cancelUrl to /b/{slug}/bookings/{id}/payment/cancelled (user-cancelled landing); the outbox e2e test still passes because it asserts with toContain, only the unit spec's URL regex needed tightening. Frontend: shared lib/payment/public-payment-api.ts exports fetchBookingPayment(slug, bookingId) hitting the C1.3 endpoint, classifyOutcome(status) (pending/success/failed/cancelled), pollForCheckoutUrl(slug, bookingId, { intervalMs=500, timeoutMs=15000, signal }) with transient-error tolerance, and a redirectToCheckout wrapper that does window.location.href = url. New route /b/[slug]/bookings/[id]/payment/return mounts a client component polling every 2s (30s cap) and renders tone cards: success → deposit secured + "back to salon"; failed → retry CTA; cancelled → "you cancelled" + retry; timeout → "status pending, check later"; spinner while INITIATED/null. Polling survives transient HTTP errors — only the timeout guard ends the loop so the PSP's 10–20s tail case doesn't abort prematurely. Sister route /b/[slug]/bookings/[id]/payment/cancelled is a static "you cancelled" page (no polling — PSP routed there on customer click-cancel; the domain-side expiry cron handles any dangling auth later). BookingPage.onSubmit is now async-aware of the requiresPayment flag: when true, it awaits redirectToCheckout (browser navigates away); on PaymentCheckoutTimeoutError it surfaces book.errorPaymentTimeout so the customer gets a clean retry path instead of a dead-spinning form. i18n keys: paymentReturn.{pending,success,failed,cancelled,timeout,common} + book.errorPaymentTimeout, nb + en parity. Tests: +6 vitest covering classifyOutcome + isTerminalStatus. Results: 64 vitest green · 1036 unit + 59 e2e backend green · lint 0/0 · both builds clean. Track C2 complete end-to-end — webhook → Payment.authorize → OnPaymentAuthorized listener → booking CONFIRMED → customer landing page sees "deposit secured" card. Next: C3 (capture on Completed, void on Cancelled/NoShow).
2026-04-18 — Track C2 backend — Payment ↔ Booking lifecycle wiring
Payment Phase 7 Track C2 backend — Payment ↔ Booking lifecycle wiring. Two new EventBus subscribers live under core/booking/: OnPaymentAuthorizedListener (subscribes to PAYMENT_EVENT_TYPES.Authorized → flips booking PENDING → CONFIRMED via BookingService.updateStatus); OnPaymentSettledNegativeListener (subscribes to .Failed + .Expired → flips booking PENDING → CANCELLED). Both resolve the booking via bookingId now carried on the enriched payloads — PaymentAuthorizedPayload, PaymentFailedPayload, PaymentExpiredPayload each gain bookingId: string \| null and Payment.{authorize, markFailed, markExpired} read it off this._bookingId when emitting. Idempotency: each listener refuses to act on non-PENDING bookings so outbox redelivery is a no-op; unknown-booking events log + skip; cross-tenant event.tenantId !== booking.tenantId refuses + logs. Race handling: concurrent transitions (admin cancels while the webhook lands) can race — the loser's updateStatus throws INVALID_STATUS_TRANSITION, which the listener catches and treats as already-handled (re-queueing would loop forever). Unknown errors re-throw so the outbox retries them. SYSTEM performer: updateStatus gets a narrow performer.role === 'SYSTEM' bypass for the customer-protection cancellation-window validation — event-driven cancels from payment failures must always fire (refusing would strand the booking). System-role transitions use the admin transition matrix so PENDING → CANCELLED + PENDING → CONFIRMED always allowed regardless of the configured 24-hour cancellation rule. Audit log still records performedByRole: 'SYSTEM'. Tests: +19 unit (9 OnPaymentAuthorized + 10 OnPaymentSettledNegative covering happy path, ad-hoc payment, cross-tenant, non-PENDING skip, race swallow, unexpected rethrow) + 1 domain assertion (Payment.authorize payload shape). Results: 103 suites / 1036 unit + 3 suites / 59 e2e all green · lint 0/0 · yarn build clean. Next: FE C2.3 (/b/[slug]/bookings/[id]/payment/{success,failed,cancelled} landing pages + poll-until-status) and C2.4 (post-POST redirect from the booking form to checkoutUrl).
2026-04-20 — Track D1 — Bambora Classic webhook → Payment.authorize end-to-end working (live test)
Payment Phase 7 Track D1 — Bambora Classic webhook → Payment.authorize end-to-end working (live test). Three sequential fixes unblocked the deposit flow; diagnosed with dev-only forensic logging that was removed after root cause found. (1) GET endpoint: PaymentWebhookController only had @Post, Bambora Classic dispatches callback as GET per Worldline Online Checkout docs — every Bambora retry got 404/405 and payment stuck at INITIATED. Added @Get(':provider/:tenantId') alongside @Post; GET reads raw query string from req.url (preserves Bambora's MD5 signing order), POST reads body; shared process() runs provider lookup → verify → inbox → queue. +2 tests. (2) MD5 mismatch: callback landed and was rejected with signature mismatch. Computed the expected hash from the concat + stored key and discovered the owner's MD5 key in DB was 9 chars where Bambora signs with 10 — a truncated-paste in the admin form. No code bug; owner re-entered the full key via Rotate credentials and verify passed on the next retry. Retained minimal per-webhook logging (Incoming GET webhook: provider=X tenantId=Y payloadLen=Z + Webhook verified: / Webhook verify FAILED:) for future triage. (3) Webhook processor shape mismatch: ProcessWebhookInboxService was coded against Worldline Direct nested payload (payload.payment.id, eventType payment.authorized), Bambora Classic ships a flat query-string (txnid, orderid) with no event-type field. Processor treated Bambora as "unhandled" → payment stayed INITIATED even with verified callback. Fixed with: (a) Bambora adapter createSession.providerSessionId = merchant order reference (same value sent as order.id + returned in callback orderid), enabling findByProviderSessionId(orderid) lookup without an extra API call; Bambora's internal session token stays inside redirectUrl and isn't needed for downstream ops (capture/void/refund/status all use txnid). (b) Bambora adapter verifyWebhook.eventType = 'payment.authorized' default — Bambora only fires callback on successful auth per docs. (c) ProcessWebhookInboxService.applyEvent reads providerTransactionId from payload.payment?.id ?? payload.txnid, and when findByProviderRef(txnid) misses falls back to findByProviderSessionId(orderid) — covers both Worldline and Bambora shapes. (d) transitionAggregate for payment.authorized now resolves txnid from either shape and returns false if missing (removed the unsafe non-null assertion). Verified end-to-end live: owner completes Bambora test checkout → callback GET arrives → MD5 verify passes → ProcessWebhookInboxService finds Payment by orderid → Payment.authorize(txnid) → PaymentAuthorized event → OnPaymentAuthorizedListener flips booking PENDING → CONFIRMED. 505/507 payment tests green (2 skipped, pre-existing). Note on backward-compat: payments created before this commit have providerSessionId = Bambora token (not orderid) so their pending callbacks will not be matched by findByProviderSessionId — new bookings only.
2026-04-19 — Track D1 — Bambora Classic spec conformance + provider-aware public CTA + hydration fix
Payment Phase 7 Track D1 — Bambora Classic spec conformance + provider-aware public CTA + hydration fix. Adapter fixes aligned with the Worldline Online Checkout v1 docs (vault Bambora/): (1) endpoint POST /checkout → POST /sessions — /checkout was Worldline Direct carryover; (2) url.decline → url.cancel per spec; (3) removed url.immediateredirecttoaccept: false — the field is integer (seconds, default 0), sending boolean false caused Bambora 40400 / 50000 Serialization error: 'false' cannot be parsed as Int32; (4) language: 'nb-NO' moved from top-level → paymentwindow.language where the spec expects it; (5) order.ordernumber → order.id (20-char merchant reference). 14/14 adapter specs green after the rewrite. Public tenant API gains settings.paymentProvider (BAMBORA | null) derived from the first PaymentConfig with isActive=true — PublicBookingModule imports PaymentModule for PAYMENT_CONFIG_REPOSITORY. Booking form CTA consumes the new field via provider-metadata.ts: button flips to "Pay with Bambora · 500 kr" with a "Secured by Bambora" footer line (ShieldCheck icon), falls back to "Continue to payment" when no provider configured — future Vipps/Stripe enable is a one-liner in metadata. Hydration mismatch fix: BookingPage + DateStrip were calling todayInZone(new Date()) client-side, which races with SSR clock around midnight and with any dev-server fetch cache producing drift. Moved initialDate = todayInZone(tenant.settings.timezone) into the server component (page.tsx) and threaded it down as a prop — server and client now agree on "today" deterministically. Public fetch cache: fetchPublic flipped from next.revalidate: 60 → cache: 'no-store'; tenant settings / services / availability are real-time edit targets, a 60s ISR window was the root cause of SSR rendering paymentProvider: null against the cached payload while a fresh client render hit the updated API. Polling safety: pollForCheckoutUrl now detects status ∈ {FAILED, EXPIRED} and throws PaymentCheckoutFailedError instead of spamming the poll endpoint until the 15s timeout — BookingPage surfaces book.errorPaymentFailed (nb + en) on this error. Redirect race fix: redirectToCheckout was throwing new Error('Redirect did not happen') to satisfy Promise<never>, which briefly flashed a red error toast during the navigation tick; replaced with new Promise<never>(() => {}) — the browser unloads the page and the await blocks indefinitely. Tests: 14 bambora adapter + 25 public-booking controller (prev +1 for paymentProvider assertion) green. Known external blocker surfaced during testing: Bambora test merchant returned 40401: The requested payment type or currency is not supported for NOK — merchant agreement needs NOK enabled in the Bambora backoffice, or the tenant must switch to DKK/EUR for testing. Next: D2 (payment list admin UI + booking-detail deposit badge column).
2026-04-18 — Track D1 — Admin UI polish pass (2026-04-18)
Payment Phase 7 Track D1 — Admin UI polish pass (2026-04-18). ProviderCard: (1) health badge moved from left-of-title to the right-hand slot and relabeled "Connect failed!" (en) / "Tilkobling mislyktes!" (nb) instead of the ambiguous "Failed" / "Feilet" — owners needed a clear signal that the provider rejected the credentials, not the local form; (2) Test/Production mode badge now gated behind lastHealthCheckStatus === 'OK' — before verification we have no proof the T/P prefix claim is real, so surfacing it was misleading; (3) Verify / Manage / Connect / "Coming soon" buttons shrunk to a compact !px-2.5 !py-1.5 !text-xs form with h-3.5 w-3.5 icons to match the lighter card aesthetic in the reference design. PaymentSettings flow: handleBamboraCreate rewritten to async/mutateAsync — create no longer closes the drawer on success; it awaits health-check and only closes when OK. On FAILED the drawer swaps into manage mode with the just-created configId preserved, so the owner retries through the rotate path instead of 409-ing a duplicate POST /payment-configs. handleManageSubmit similarly waits on health-check before closing when a rotate happened. activateMutation.onSuccess now auto-fires healthCheckMutation — a reactivated provider's secrets may have been rotated or expired while deactivated, so a stale OK from before the toggle-off cannot be trusted. isSubmitting on both drawers includes healthCheckMutation.isPending so the Save button keeps its spinner through verify. Drawer chrome (ProviderConfigDrawer, EditConfigDrawer, BamboraConfigForm) restructured to flex max-h-[90vh] flex-col → body flex-1 min-h-0 overflow-y-auto, footer shrink-0 border-t — the Cancel/Save bar is now sticky at the bottom regardless of scroll, and Cancel is disabled while a mutation is in flight. Tests updated: ProviderCard.test.tsx (+2 cases to cover mode-badge gating before-OK / on-FAILED, 17 total), HealthCheckBadge.test.tsx (text assertion flip). Results: 58 web Vitest green, lint 0/0, build clean.
2026-04-20 — Post-D2 UX polish. Backend
Post-D2 UX polish. Backend — bookingId prefix filter (ListPaymentsQuery): admin UI shows an 8-char UUID prefix in the bookingId column, but pasting that prefix into the filter returned empty because both PrismaPaymentRepository.listByTenant and InMemoryPaymentRepository.listByTenant did exact-match on bookingId. Switched to startsWith (Prisma where.bookingId = { startsWith }; in-memory r.bookingId?.startsWith(prefix)). Full UUIDs still match since a string starts-with itself. +2 query tests: "filters by bookingId prefix" + "filters by bookingId exact (full UUID still matches via startsWith)". Backend — booking no-op Save guard (BookingService.update): owners who open the booking drawer and reflexively hit Save were generating UPDATED audit rows even when nothing had changed, because the items array is always sent on update and the old guard logged {items: 'replaced'} unconditionally. New itemsEqual(existing, resolved) helper deep-compares length + per-item (serviceId, resourceId, startTime in ms) after sort-by-sortOrder; when identical, items: 'replaced' stays out of changes and the service short-circuits with return existing before touching prisma.booking.update — avoids both the updatedAt bump and the noise audit row. Also added the missing customerId diff to the scalar compare block. +1 service test "should skip update + audit log when nothing changed (no-op Save)"; updated 3 existing tests to submit 2 items (so the diff actually fires) + added a 2nd findFirst mock for the second-item conflict check. FE mirrors: BookingDrawer.onSubmit in edit mode checks formState.isDirty — when false, calls forceClose() without dispatching the mutation so we don't even hit the API with a no-op payload. Shared DatePicker overhaul (components/form/date-picker.tsx): (a) altInput: true + altFormat: 'd/m/Y' so every consumer shows dd/mm/yyyy while the wire format stays Y-m-d; altInputClass inlined to keep the field style consistent with the other form inputs. (b) Compact input (h-11 → h-10 + pl-3 pr-10). (c) Ant-Design-style mode: 'range': adds showMonths: 2 + locale.rangeSeparator: ' → ' so the popup is two months side-by-side and the closed input reads 20/04/2026 → 25/04/2026 instead of … to …. (d) Inject a "Today" footer button via onReady for single-date mode (skipped in range — setDate(today) would collapse the range). (e) Hover-to-clear: icon button stays a non-clickable Calendar until the picker has a value; on hover it swaps to X and the click runs instance.clear() → consumer's onChange fires with [] so state resets. (f) Partial-range revert: onClose with 1 selected date rolls back to lastValidRangeRef (last complete range) via setDate(..., triggerChange=false) — a half-picked range is an intermediate state, not a filter value. (g) onChange in range mode swallows the intermediate [start] fire so consumers only ever see final 2-item ranges or explicit [] clears. (h) Icon changed from project CalenderIcon (SVG with empty bottom space in viewBox) to lucide Calendar at h-4 w-4 — fixes the "icon clipped at bottom" visual. (i) Button shell absolute right-0 top-0 h-10 w-10 flex items-center justify-center border-0 bg-transparent — pointer-events-none when empty lets flatpickr open on input click; interactive once a value exists. globals.css flatpickr tweaks: rounded-md default day cells (not circle); inRange uses bg-brand-100 (stronger than brand-50) + rounded-none so connected in-range cells form a continuous band; startRange/endRange stay rounded-md + solid brand-500 fill; compact p-3 + smaller header margins; removed duplicate .flatpickr-calendar / .flatpickr-weekdays rules that were overriding the new compact values. PaymentList: replaced two <input type="date"> From/To fields with a single DatePicker mode="range" at w-64; handleRangeChange only apples the filter on 2-date arrays (intermediate [start] is swallowed by the picker; [] resets both ends). Label Period / Periode, placeholder Start date — End date / Startdato — Sluttdato. Cross-page open — PaymentDetailDrawer → BookingDrawer: replaced the booking-id <Link href=/admin/bookings?bookingId=…> with a button that fires onViewBooking(id) → PaymentsContent calls useBooking(id) (new hook, fetch-on-open via enabled: !!id) → renders a nested BookingDrawer with the freshly-loaded booking. Closing the booking drawer leaves the payment drawer on screen so the owner keeps payment context. Added CopyButton next to the id (icon-only, 1.5s check-flash on success, stopPropagation so it doesn't trigger parent click). BookingHistory diff modal rewrite: was single-column "show only changed fields"; now full-snapshot side-by-side table. Header row "Field / Before / After"; per-field rows classified as CHANGED (2 tinted cells: red-50/60 strikethrough left, emerald-50/60 bold right), UNCHANGED (single muted cell spanning both Before/After columns, pulled from live booking prop so owner sees surrounding context), or NOT_SET (filtered out). Separate amber items: 'replaced' row since audit doesn't store old/new item arrays. Fields: status, startTime, endTime, resourceId, customerId, notes, isPaid, source. Values resolved via i18n (tStatus, SOURCE_LABEL_KEYS), resourceMap, and new resolveCustomer(id) callback — matches live booking.customer.name for the current id, falls back to 8-char UUID for swapped-out customers (no extra fetch). Prop signature changed from (bookingId, resources) → (booking, resources). +i18n bookings.{diffField, diffBefore, diffAfter, historyNoDiff, historyItemsReplaced, fieldCustomer} (nb + en). Shared formatDateTimeInZone(iso, tz) + createDateTimeFormatterInZone(tz) in lib/timezone.ts — canonical dd/mm/yyyy HH:mm date-time format (en-GB + hour12: false + formatToParts assembly) for admin tables and audit timelines. Replaces duplicated Intl.DateTimeFormat('nb-NO', {...}) in PaymentList + PaymentDetailDrawer. 6 new timezone.test.ts cases (Europe/Oslo DST, UTC, midnight normalise, single-digit pad). Results: 1047 API unit (+3) + 57 e2e · 100 web vitest (+6) · lint 0/0 · both builds clean.
2026-04-20 — Track D2 — Admin payments list + detail drawer + refund/void/capture dialogs
Payment Phase 7 Track D2 — Admin payments list + detail drawer + refund/void/capture dialogs. Frontend-only; the five /admin/payments* endpoints were already shipped in Phase 5 waiting for a UI. Hooks (usePayments.ts): usePayments(filter) with typed query-string builder (status + provider + bookingId + fromDate + toDate + limit + offset, each omitted when empty), usePayment(id) gated on id via enabled: !!id, three mutation hooks (useRefundPayment, useVoidPayment, useCapturePayment) carrying matching RefundPaymentResult / VoidPaymentResult / CapturePaymentResult types mirrored from the backend commands. Each mutation captures the { id, bookingId } pair on input and invalidates PAYMENTS_KEY, PAYMENT_KEY(id), BOOKING_PAYMENTS_KEY(bookingId), and ['bookings'] on success — no reliance on the response shape (backend returns command results, not full Payment DTOs). generateIdempotencyKey() wraps crypto.randomUUID() (36-char v4, well above the backend minLength: 16). Sidebar adds "Betalinger" / "Payments" with CreditCard icon at /admin/payments, matched with sidebar.payments i18n (nb + en). List page (/admin/payments) = filter toolbar (status, provider, bookingId search with 300ms debounce, from/to date range, Clear-filters CTA) + table columns [createdAt, provider, intent, status, amount, captured, refunded, booking] + Pagination component (limit options 10/20/50/100, default 20). Timestamps format via cached Intl.DateTimeFormat in salon timezone. PaymentDetailDrawer (framer-motion slide-in with backdrop, AnimatePresence) reads the selected payment via usePayment(id), renders four sections: meta (id/intent/provider/booking link), amounts (total/captured/refunded/remaining — pending-toned), provider references (txn + session), timeline (created/authorized/captured/expires/updated), plus a failure panel when FAILED or failureMessage present. Sticky action bar at the bottom shows Capture / Refund / Void buttons gated by state machine: Refund only when status ∈ {CAPTURED, PARTIALLY_REFUNDED} and providerTransactionId is set; Void only when status === AUTHORIZED; Capture only when status === AUTHORIZED AND captureMode === MANUAL. Action bar hidden entirely when user.role !== 'OWNER' (backend @Roles('OWNER') on refund/void/capture) — STAFF/ADMIN get read-only access. RefundDialog: two-step flow (form → ConfirmDialog danger variant). Zod schema (refund-schema.ts) — amount is positive integer ≤ capturedAmount−refundedAmount, reason trimmed min 1 max 500. MoneyField pre-fills with max refundable; remaining hint below. Reason textarea routes errors through useValidationMessage so keys like required render as translated copy. Idempotency key generated once per dialog mount (retries of the same submission reuse it; close/re-open mints a new one). VoidDialog: single-step modal with optional reason textarea — backend accepts null reason, UI trims empty strings before sending. CaptureDialog: two-step flow like Refund; Zod schema (capture-schema.ts) amount is nullable (null ⇒ full capture) with same positive/exceeds-max bounds. All three dialogs wire useFormMutation → success toast + error-code translation; on success the drawer and dialog both close. i18n (nb + en parity): payments.* (title, subtitle, filter.{anyStatus, anyProvider, bookingId*, fromDate, toDate, clear}, columns., intent.{DEPOSIT, FULL_PAYMENT, CANCELLATION_FEE, NO_SHOW_FEE}, captureMode.{AUTO, MANUAL}, empty, viewBooking, detail., actions., refund., void., capture.); validation.{positive, refundExceedsMax, captureExceedsMax}; 12 payment domain error codes under errors.PAYMENT_* (state transition, refund exceeded, capture exceeded, invalid amount, invalid idempotency key, invalid provider ref, provider error, provider invalid creds, provider insufficient funds, provider timeout, provider unsupported, not found). Tests: +23 vitest (10 hook tests covering query-string builder, empty-filter short-circuit, default unwrap, disabled-when-null id, refund/void/capture POST shape + invalidation; 8 refund-schema cases covering zero/negative/over-cap/exact-cap/empty/whitespace/non-integer; 5 capture-schema cases covering null/partial/zero/over-cap/exact-cap) + 2 Playwright smoke (page heading + filter controls render; sidebar shows Payments link). Results: 94 vitest (was 71, +23) · lint 0/0 · yarn build clean · backend untouched (1044 unit + 57 e2e still green). Scope intentionally excludes bulk actions, payment-level notes, and CSV export — deferred to D2.1+.
2026-04-20 — Track C4.2 — Admin booking-list deposit badge + FE api-url refactor
Payment Phase 7 Track C4.2 — Admin booking-list deposit badge + FE api-url refactor. Backend: PaymentRepositoryPort extended with findLatestStatusByBookingIds(bookingIds, tenantId): Promise<Map<string, PaymentStatus>> — batched (one query per page, no N+1), tenant-scoped, returns the latest Payment per booking by createdAt DESC so a retry-after-FAILED naturally wins and shows the current state. Implemented on PrismaPaymentRepository (bookingId IN + in-memory dedupe; the composite @@index([bookingId]) keeps the scan trivial) and InMemoryPaymentRepository (sort-then-dedupe mirror). BookingModule imports PaymentModule to gain access to PAYMENT_REPOSITORY; BookingController.findAll is now async, calls findLatestStatusByBookingIds on the page + calendar-mode result and decorates each booking with paymentStatus: PaymentStatus | null. process-webhook-inbox.service.spec mock repo extended with the new method + the missing findExpirable stub (pre-existing port gap). Frontend: Booking type in types/booking.ts gains paymentStatus?: PaymentStatus | null; new shared components/payment/PaymentStatusBadge extracted from BookingPaymentSummary (removed its inline copy — same Record<PaymentStatus, classes> map, same i18n key under bookingPayment.status.*). BookingList renders a new "Depositum/Deposit" column that uses the badge when paymentStatus is present and an em-dash otherwise — no fallback to a default status (absent = no Payment row yet = correct). Zero N+1 calls — the FE list request triggers exactly one additional findLatestStatusByBookingIds. FE api-url refactor (bonus): booking-api.ts had been calling http://localhost:3010/api/... directly (NEXT_PUBLIC_API_URL), which was cross-origin to the browser and stripped the customerAccessToken cookie (sameSite=strict) — every authenticated customer booking was silently recorded as guest. Created lib/api-url.ts with two exports: SERVER_API_URL (absolute, from env — for src/proxy.ts middleware + server-component public-api.ts) and CLIENT_API_URL = "/api" (relative → Next.js rewrite, same-origin, cookies travel). Refactored booking-api.ts, public-payment-api.ts, public-api.ts, proxy.ts to use the shared constants — no more 4× duplicated env-default expressions. Also fixed two pre-existing FE test errors: usePaymentConfigs.test.tsx missing merchantNumber: in fakeConfig fixture (the PaymentConfigDto schema added the field in D1); bambora-schema.test.ts validForm retyped from BamboraFormInput → BamboraFormValues since deriveBamboraCredentials runs after Zod parse (output type has displayName: string via .default(), not the optional input type). Results: 1044 API unit (+5) + 57 e2e · 71 web vitest · lint 0/0 (both) · tsc clean (both) · both builds green. Scope: Track C4 closed; D2 (admin payment list + refund/void UI) and beyond deferred.
2026-04-20 — Payment shared HTTP infrastructure lift
Payment shared HTTP infrastructure lift. http-client.ts + retry.ts were duplicated in providers/bambora/ + providers/worldline-direct/ (identical content, 127 LOC × 2) — a known tech-debt flagged since the Worldline split. Moved canonical copy to src/core/payment/infrastructure/http/{http-client.ts,http-client.spec.ts,retry.ts,retry.spec.ts}, updated 6 callers (providers/bambora/{adapter.ts,adapter.spec.ts,adapter.integration.spec.ts}, providers/worldline-direct/{adapter.ts,adapter.spec.ts}, infrastructure/provider/provider-bootstrap.ts) to import from ../../http/. provider-bootstrap.ts collapsed BamboraFetchHttpClient + WorldlineFetchHttpClient aliased imports into one FetchHttpClient. 4 duplicate spec files git rm'd from worldline-direct/. Zero real test coverage lost — tests were identical on both sides; deduped count 1073 → 1060 API unit. Lint 0/0, build clean. Benefit: next provider (Stripe / Vipps / Nets) reuses the same HTTP client + retry semantics out-of-the-box.
2026-04-21 — Track D3 — capture trigger move + maxBookingDaysInAdvance setting
Payment Phase 7 Track D3 — capture trigger move + maxBookingDaysInAdvance setting. Industry-standard fix for MANUAL capture flow: previously onBookingConfirmed captured MANUAL+AUTHORIZED payments, but PaymentAuthorized event flips booking PENDING→CONFIRMED within seconds of authorize — so Void window was effectively zero and MANUAL was indistinguishable from AUTO. Matched Booksy / Timely / Vagaro / Phorest pattern: capture at ARRIVED (primary) + COMPLETED (fallback when staff skip arrival tap since state machine allows CONFIRMED→IN_PROGRESS directly). Changes: added BOOKING_INTEGRATION_EVENTS.Arrived + BookingArrivedPayload + buildBookingArrivedPayload; BookingService.buildStatusTransitionEvent emits Arrived on transition; PaymentIntegrationService swaps Confirmed subscription → Arrived, keeps Completed as safety net; 4 integration-service tests cover arrived-captures / confirmed-no-op / completed-fallback / AUTO-no-op. Deposit now truly held through the service window — cancel in cancellation window = Void (cheap) instead of Refund (fee on both ways). Lead-time cap: new TenantSettings.maxBookingDaysInAdvance (default 30, range 1–365) enforced in BookingService.create AND BookingService.update via validateBookingLeadTime() → throws BOOKING_TOO_FAR_IN_ADVANCE. Admin does NOT get an override per explicit product decision — salon services rarely booked >30 days ahead; hard cap also sidesteps Bambora's 7-day authorization hold. Public endpoint GET /public/tenants/:slug exposes the cap via sanitizeSettings so FE pre-validates. BookingPage.handleSubmit guards with errorTooFarInAdvance (nb + en); admin BookingDrawer.DateField.max clamps at now + cap. Settings form surfaces a warning when depositEnabled && cap > 7: "card authorizations only hold for 7 days — bookings farther out may lose their deposit hold". Industry defaults realigned: BEAUTY_SETTINGS + BARBERSHOP_SETTINGS currency USD → NOK, new tenants get maxBookingDaysInAdvance: 30 out of the box; prisma/seed.ts updated too. TenantSettingsDto gains the new field with @IsInt @Min(1) @Max(365) — the forbidNonWhitelisted validator would otherwise reject the PATCH. OpenAPI + api.generated.ts regenerated. +5 helper tests covering inside-cap / past-cap / exact-boundary / past-booking / legacy-missing-setting. No schema/migration — settings are JSONB.
2026-04-21 — Admin UI polish
Admin UI polish — unify all <select> to SearchSelect across BookingList, PaymentList, and Settings pages (General / Booking / Accounting). Each page had a mix of native <select> and SearchSelect, so chevrons, border-radius, focus rings and heights didn't line up between filter rows. All now render through SearchSelect (wrapped in react-hook-form Controller where applicable). Required-by-zod fields (Currency, Timezone, bookingMode, depositType, VAT rate) pass required prop so the clear-X button is hidden + auto-asterisk appears; optional filters (booking status / resource, payment status / provider) keep the X clear button. BookingList sortable + Created column: new "Created" column rendered via formatDateTimeInZone in booking timezone; sortable headers on startTime + createdAt with 3-state toggle (null → asc → desc → null) persisted to localStorage. Backend: BookingQueryDto inherits sortBy/sortOrder from PaginationDto (narrow-override triggered TS2612 under strictPropertyInitialization), BookingService.findAllByTenant guards with ALLOWED_SORT_FIELDS = ['startTime', 'createdAt'] whitelist, calendar mode (dateTo set) ignores sort params and always startTime asc. +1 controller-spec arg, +0 service-spec changes. Header dropdown fix: "Edit profile" link pointed at /profile but the admin layout hosts the page at /admin/profile — updated href.
2026-04-20 — Track D1 hotfix — deposit was silently captured server-side on Bambora
Payment Phase 7 Track D1 hotfix — deposit was silently captured server-side on Bambora. Two intertwined bugs made every deposit go straight from INITIATED to the money-moved state on Bambora's side while our DB still showed AUTHORIZED: owner clicking Void hit 134: No approved Authorize available for Delete, and the balance mismatched if they waited. Root cause A: buildBookingCreatedPayload hardcoded captureMode: 'AUTO' at core/booking/domain/events/build-booking-event-payload.ts:54 — every onBookingCreated path routed into Bambora with the AUTO capture flag regardless of intent. The MANUAL branch inside PaymentIntegrationService + provider adapters was literally unreachable from this flow. Fixed by deriving captureMode from intent: intent === 'DEPOSIT' ? 'MANUAL' : 'AUTO' so deposit bookings hold (capture later on Confirmed/Completed via listener), full-prepay bookings (future path when depositAmount === totalAmount) keep AUTO. Root cause B: Bambora Classic's /checkout/sessions treats the presence of instantcaptureamount (not its value) as "capture on authorize". providers/bambora/adapter.ts:createSession was sending instantcaptureamount: 0 for MANUAL (thinking 0 = no-op), which silently captured the full authorized amount server-side — verified live against the T-merchant sandbox on 2026-04-20. Fixed by omitting the field entirely when captureMode !== AUTO. Added comment with the incident date so the next engineer doesn't retry the 0-means-off trick. FE — Payment detail AMOUNTS section redesign (components/payments/PaymentDetailDrawer.tsx): (a) new On hold row (tone pending, brand-600) rendering amount − capturedAmount when status === AUTHORIZED, with sub-hint "Reserved on the card" — owners no longer have to remember what Amount 5 kr / Captured 0 kr means. (b) Zero captured/refunded now render — (muted gray) instead of 0 kr, matching the Stripe/Adyen convention: "—" = "never happened", "0 kr" would look like an active zero-value. (c) Old refund-leftover row relabeled from the confusing Up to X kr refundable → X kr duplication to a clean Refundable → X kr (still only shown when capturedAmount > 0). (d) AmountRow component extended with optional hint slot + new 'muted' tone. i18n keys payments.detail.{held, heldHint, refundable} added to nb + en. Tests updated: build-booking-event-payload.spec.ts replaces single "AUTO captureMode by default" assertion with two scenarios (FULL_PAYMENT → AUTO, DEPOSIT → MANUAL); bambora/adapter.spec.ts MANUAL case now asserts expect(body).not.toHaveProperty('instantcaptureamount') (the original toBe(0) was exactly the bug we just fixed). 92 suites / 92 tests green across the four touched files. Results: hotfix only — no schema/migration, no API contract change. Owner-observed behavior: fresh deposit bookings now truly hold on Bambora + Void in the drawer succeeds; pre-fix payments still show the diverged state and need Refund (Bambora captured them already).
2026-04-20 — Track E1 — Remaining payment via in-salon QR
Payment Phase 7 Track E1 — Remaining payment via in-salon QR. Fills the last gap in the payment lifecycle: after the deposit-held flow (C1–C4 + D1–D2), owner can now collect booking.total − captured on the spot by generating a QR the customer scans. Chose the QR pattern because it's the Nordic default (Vipps/BankID), avoids SMS fees + wrong phone numbers, and the customer is already at the salon at checkout time. Enum: PaymentIntent.REMAINING_PAYMENT added to domain + Prisma via additive ALTER TYPE ADD VALUE migration (safe — no enum-drift guard changes needed since it uses Object.values). New port + adapter: core/payment/domain/ports/booking-lookup.port.ts (BookingLookupPort.getSummary(bookingId, tenantId) → BookingSummary | null returning status, totalAmount, currency, pre-built return/cancel/webhook URLs, customer snapshot). PrismaBookingLookupAdapter reads Booking + items + tenant.settings in a single findFirst with select so Payment context never reaches into Booking internals — keeps the bounded context clean. URLs mirror BookingService.buildBookingUrls (PUBLIC_WEB_URL + API_BASE_URL env). Command + handler (InitiateRemainingPaymentCommand + Handler): (1) guards booking.status ∈ {ARRIVED, IN_PROGRESS, COMPLETED} → PAYMENT_INVALID_BOOKING_STATE; (2) computes paid = Σ Payment.capturedAmount − Σ Payment.refundedAmount across all rows for the booking, skipping FAILED / VOIDED / EXPIRED since those never touched the customer's money — mirrors what BookingPaymentSummary shows the owner; (3) remaining = booking.totalAmount − paid, rejects remaining ≤ 0 with PAYMENT_NO_REMAINING_AMOUNT; (4) resolves amount = command.amount ?? remaining, clamps 0 < amount ≤ remaining (InvalidAmountError / PAYMENT_REMAINING_AMOUNT_EXCEEDED); (5) idempotency-by-intent: finds any unexpired INITIATED REMAINING_PAYMENT for this booking via findByBookingId and reuses the metadata.checkoutUrl so owner reopening the modal doesn't stack zombie Payment rows; (6) delegates to InitiatePaymentHandler with intent=REMAINING_PAYMENT, captureMode=AUTO — remaining charges don't hold + capture separately, money arrives in one step. Four domain errors added (BookingNotFoundForPaymentError, InvalidBookingStateForRemainingPaymentError, NoRemainingAmountError, RemainingAmountExceededError) each carrying a stable code property that the existing PaymentDomainErrorFilter maps to HTTP 404/409/409/422 respectively. Admin endpoint POST /admin/payments/remaining (Roles: OWNER, STAFF) with InitiateRemainingPaymentDto (bookingId + optional amount + idempotencyKey ≥ 16 chars). Wired through PaymentModule (new bookingLookupProvider, PrismaBookingLookupAdapter + handler registered) and PaymentHandlersBootstrap.onModuleInit. Frontend: qrcode.react@4.2.0 added (~3 KB). usePayment(id, { pollIntervalMs }) extended with a terminal-aware refetchInterval that stops when status ∈ {CAPTURED, AUTHORIZED, FAILED, VOIDED, EXPIRED, REFUNDED, PARTIALLY_REFUNDED} so the React Query poll doesn't spin forever. useInitiateRemainingPayment(opts) mutation invalidates PAYMENTS_KEY, PAYMENT_KEY(id), BOOKING_PAYMENTS_KEY(bookingId), ['bookings'] on success. CollectRemainingModal (3-step FSM: input → qr → success) derives step via useMemo on {session, polledPayment} so React Compiler is happy — no setState-in-effect lint violation. Step input shows total/paid/remaining breakdown + editable MoneyField (default = full remaining) with Zod schema buildCollectRemainingSchema(maxRemaining) (positive int ≤ remaining). Step qr renders 240px QRCodeCanvas with the redirectUrl from the initiate call, a countdown timer derived from expiresAt via useCountdown(iso) (state holds now, label derives purely — Compiler-safe), Loader2 spinner + "Venter på at kunden betaler…", and error banner when polling sees FAILED/EXPIRED. Step success auto-closes 2s after detection via single setTimeout-in-effect (guarded). Modal close ≠ void — idempotency-by-intent reuses the Payment next open; owner voids explicitly from Payment detail drawer if needed. BookingPaymentSummary takes new bookingStatus prop and renders a brand-500 "Krev resterende · X kr" button below the summary when remaining > 0 && status ∈ {ARRIVED, IN_PROGRESS, COMPLETED}. i18n (nb + en parity): collectRemaining.* (ctaLabel, title, total, paid, remaining, amountLabel, amountHint, continueToCheckout, qrTitle, qrHint, waiting, expiresIn, paymentFailed, paymentExpired, successTitle, successMessage, sessionCreated); validation.remainingExceedsMax; 4 new error codes under errors.PAYMENT_BOOKING_NOT_FOUND / PAYMENT_INVALID_BOOKING_STATE / PAYMENT_NO_REMAINING_AMOUNT / PAYMENT_REMAINING_AMOUNT_EXCEEDED. Tests: +18 API (16 handler cases covering happy / explicit amount / remaining math with partial refunds / skip FAILED·VOIDED·EXPIRED / idempotency-by-intent reuse + expired-skip / idempotency key replay / BookingNotFound / cross-tenant null / PENDING guard / CANCELLED guard / all 3 allowed statuses / NoRemaining / AmountExceeds / InvalidAmount / URLs from lookup; 2 controller POST shape) + 9 web Vitest (7 collect-remaining-schema + 2 useInitiateRemainingPayment hook). Results: 1065 API unit (+18) + 57 e2e · 109 web vitest (+9) · lint 0/0 (both) · builds clean (both) · OpenAPI + api.generated.ts regenerated. Payment lifecycle is now end-to-end: deposit → confirm → arrive → remaining → completed. Track L (loyalty discount applied to booking/payment) deferred to backlog.
2026-04-21 — Track L2 — computeLoyaltyDiscount pure helper (+19 tests, TDD RED→GREEN)
Track L2 — computeLoyaltyDiscount pure helper (+19 tests, TDD RED→GREEN). New file core/loyalty/compute-loyalty-discount.ts turns a loyalty reward + booking items into a concrete discount amount without touching Prisma — caller (L3 BookingService.create) loads LoyaltyCard + items and passes plain data. Signature: computeLoyaltyDiscount(input): {discountAmount, freeServiceItemId?, eligibleSubtotal}. Rules: FREE_SERVICE auto-picks the sole eligible item; when >1 eligible requires selectedServiceItemId (throws LOYALTY_SERVICE_PICK_REQUIRED — no auto-pick most-expensive, respects user intent for mixed bookings like cắt tóc + ráy tai). applicableServiceIds narrows the candidate set; a pick outside the set throws LOYALTY_PICKED_ITEM_NOT_ELIGIBLE. DISCOUNT_AMOUNT subtracts fixed øre, clamped to min(eligibleSubtotal, rawTotal) so total can never go negative. DISCOUNT_PERCENT rounds eligibleSubtotal × value / 100 then clamps — accepts >100 without throwing so owners can configure premium-tier "110% off" edge cases. Scoped rewards (non-empty applicableServiceIds) apply to the matching-items subtotal only; unscoped apply to rawTotal. Error codes follow LOYALTY_ prefix convention: LOYALTY_NO_ITEMS, LOYALTY_NO_APPLICABLE_ITEMS, LOYALTY_INVALID_REWARD_VALUE, LOYALTY_PICKED_ITEM_NOT_FOUND, LOYALTY_PICKED_ITEM_NOT_ELIGIBLE, LOYALTY_SERVICE_PICK_REQUIRED. 19 unit tests cover all 3 RewardTypes × {scoped, unscoped} × {auto-pick, picker, clamp, reject} plus 4 guards. 1088 API unit + 57 e2e · lint 0/0 · build clean. Next: L3 (wire helper into BookingService.create transactional path + redemption lifecycle).
2026-04-21 — Track L1 — loyalty discount schema groundwork (Track L1/6)
Track L1 — loyalty discount schema groundwork (Track L1/6). Prisma migration 20260421082757_add_loyalty_discount_fields preps DB for the end-to-end loyalty-discount flow (L2–L6 coming). Changes: (a) bookings gains discount_amount INTEGER NULL + applied_redemption_id TEXT NULL UNIQUE with FK → loyalty_redemptions(id) ON DELETE SET NULL — unique means one redemption row backs at most one booking; (b) new enum LoyaltyRedemptionStatus { RESERVED, CONSUMED, CANCELLED }; (c) loyalty_redemptions gains status (default CONSUMED so legacy admin-created rows implicitly sit in the terminal state), redeemed_at TIMESTAMP(3) NULL, cancelled_at TIMESTAMP(3) NULL + index on status; (d) backfill UPDATE loyalty_redemptions SET redeemed_at = created_at WHERE redeemed_at IS NULL so pre-L1 rows look as if they had always tracked consumption time. Schema-side: Booking.appliedRedemption relation (via named BookingAppliedRedemption) + reverse LoyaltyRedemption.appliedToBooking; Booking.loyaltyRedemptions (legacy loose FK) kept for audit trail. LoyaltyService.redeemStampCard explicitly sets status: 'CONSUMED', redeemedAt: new Date() on the admin-manual path — comment clarifies that the RESERVED → CONSUMED / CANCELLED state machine only activates for the customer-initiated path wired in L3. Design choices locked with user: (1) FREE_SERVICE on multi-service bookings requires customer to pick which service to apply free (no auto-pick most-expensive — respects customer intent), (2) deposit % computed off payableTotal = rawTotal − discountAmount not raw total (fair to customer, matches Stripe/Booksy default), (3) guest bookings blocked from redeem (no TenantCustomer → no loyalty identity). No behaviour change, no API contract change, zero new tests — pure data model prep. 1070/1072 unit (2 skipped pre-existing) · build clean · lint 0/0.
2026-04-21 — Auth-expiry → booking-cancel audit (no code change, +1 contract test)
Auth-expiry → booking-cancel audit (no code change, +1 contract test). D1-hotfix follow-up queue item (2) was "add OnPaymentExpiredListener to sync booking on auth expiry". Audit of existing code found it had already shipped in Track C2 backend (2026-04-18): OnPaymentSettledNegativeListener.onModuleInit() subscribes BOTH PAYMENT_EVENT_TYPES.Failed AND PAYMENT_EVENT_TYPES.Expired, with full cross-tenant guard + PENDING-only transition + INVALID_STATUS_TRANSITION swallow for concurrent transitions + 8 unit tests (including explicit PaymentExpired PENDING→CANCELLED and skip-CANCELLED-terminal). Cron side (AuthorizationExpiryService.sweepAndExpire) calls payment.markExpired(now) which records a PaymentExpired domain event into the aggregate; PrismaPaymentRepository.save() dual-writes it into domain_event_outbox inside the same transaction; the outbox publisher (janitor every 30s + hot-path enqueue) dispatches it to the EventBus → listener fires. One invariant wasn't locked by tests: nothing asserted the sweep emits the event with bookingId + tenantId populated — if makeAuthorized factory or markExpired() ever stopped threading bookingId, the listener would silently no-op on if (!payload.bookingId) return and every stuck PENDING booking would persist. Added one contract test to authorization-expiry.service.spec.ts (“emits PaymentExpired event carrying bookingId + tenantId (listener contract)”) that calls payment.pullPendingEvents() after sweep and asserts the envelope + payload shape. Features doc updated — follow-up queue items (1)/(2)/(3) from D1 hotfix are now all marked shipped. Results: 1069 API unit (+1) · no web changes · no schema / migration / API contract change.
2026-04-23 — Role-based audit Phase 4 — Add staff with login + auth multi-tenant + TanStack cache leak fix
Feature chính: OWNER/ADMIN từ /admin/staff có thể tick "Create login account" và nhập email/phone + password (≥6) + role (OWNER|STAFF) trong cùng form Add staff — backend wraps User.create + Resource.create vào cùng $transaction (resource.service.ts), email/phone check conflict trước tx để surface STAFF_EMAIL_CONFLICT / STAFF_PHONE_CONFLICT trước khi bcrypt. DTO mới CreateResourceLoginDto nested optional — onboarding wizard không break vì payload cũ (không có login) vẫn hoạt động. Form web bỏ section khi ở edit mode (reset-password từ form để sau). Hardening song song: (a) Cache leak cross-tenant — AuthContext.login/register/logout giờ gọi queryClient.clear() để wipe TanStack cache; trước đó logout owner1 rồi login owner2 vẫn thấy customer của owner1 cho tới khi F5 (query keys ['customers']/['staff']/['bookings'] không chứa tenantId, QueryClient là singleton). (b) Auth login multi-tenant — auth.service.ts đổi findFirst({email}) → findMany({email, tenantId?}) với optional tenantSlug trong LoginDto; throw AUTH_TENANT_REQUIRED khi email trùng ở nhiều tenant và không truyền slug (trước đó chọn ngẫu nhiên — potential data leak). (c) SignUp/SignIn refactor — 2 form này đang xài useState thủ công + banner đỏ chung chung errors.VALIDATION_ERROR khi lỗi; refactor sang react-hook-form + Zod + FormField/PasswordField inline errors, map AUTH_USER_EXISTS → setError('email') và AUTH_INVALID_CREDENTIALS → setError('password'), fallback toast cho unknown errors (bỏ banner luôn); password min 6 sync với backend MinLength(6) + đã có ở reset-password/ChangePassword. i18n key mới: validation.passwordTooShort, validation.loginRequiresEmailOrPhone, auth.emailAlreadyExists/invalidCredentials/accountInactive, resources.hasLoginAccount/loginAccountHint/loginEmail/loginPhone/loginPassword/loginRole/loginRoleStaff/loginRoleOwner, 4 error codes (STAFF_LOGIN_REQUIRES_EMAIL_OR_PHONE, STAFF_EMAIL_CONFLICT, STAFF_PHONE_CONFLICT, AUTH_TENANT_REQUIRED) cả en + nb. Tests: +9 API (+6 resource.service.create login flows + 3 auth.service.login multi-tenant) · +16 web (3 AuthContext cache clear + 4 SignInForm + 4 SignUpForm + 6 StaffFormModal login section + 1 existing SignUpForm empty check covered by the 4 above — tất cả đều pass với 128 total). role-matrix POST /resources row cập nhật với payload login. Chưa làm: frontend "Salon slug" field khi nhận AUTH_TENANT_REQUIRED (Phase 2 — hiện chỉ cần API trả đúng code); Layer 2 hardening (thêm tenantId vào query keys) defer. Results: booking-api lint/build/test 0/0/0 · 1194 unit pass (+9) · booking-web lint/build/test 0/0/0 · 128 pass (+16) · OpenAPI + api.generated.ts regenerated · features.md + role-matrix.md cập nhật.
2026-04-23 — Role-based audit Phase 3 — Web UI for STAFF (sidebar + calendar + drawer)
Pair with Phase 2 API harden (same day). Opens /admin to STAFF without exposing management surfaces or letting them interact with bookings cross-resource. Mobile role-switch app can now lean on a known-good web surface for parity. Backend follow-up — /auth/me + /auth/profile return resourceId so the web knows the performer's linked Resource (null for OWNER/ADMIN, set for STAFF); spec updated and openapi.json regenerated. Web AuthContext — User.resourceId: string | null field; AuthGuard now bounces role-insufficient users to /admin (dashboard root) rather than the sign-in page — users already logged in shouldn't be thrown back to login when they type an OWNER-only URL. Dashboard layout — allowedRoles opens {"ADMIN", "OWNER", "STAFF"}. OwnerOnlyGuard — thin new wrapper (= AuthGuard with ["ADMIN", "OWNER"]) dropped onto the 5 OWNER-only page.tsx files (Settings, Payments, Services, Staff, Work schedule). Dashboard-level guard lets STAFF in; per-page guard catches direct-URL navigation to management surfaces. AppSidebar — NavItem.allowedRoles? + NavSubItem.allowedRoles? optional fields, filterNavByRole(items, role) helper drops forbidden items and any parent whose sub-items are all hidden. STAFF ends up with just Dashboard, Bookings, Customers, Loyalty; Services, Resources (staffList/workSchedule), Payments, Settings all gone. BookingCalendar — one-shot STAFF default filter: on first load, all resource columns except the staffer's own hide themselves via calendar:staffDefaultsApplied localStorage flag. User can unhide colleagues later for self-pick coverage context — the API still scopes bookings to own+unassigned (role-matrix §2.4), so other columns stay empty; this is purely UX noise reduction. resources = resourcesData?.data ?? [] wrapped in useMemo to satisfy React Compiler's exhaustive-deps rule that now applies to the new effect's deps. BookingDrawer — useAuth() consolidated (duplicate call removed), isStaff / staffResourceId derived. handleAddService forces resourceId = staffResourceId when STAFF adds a service, bypassing the "last item resourceId → defaults → empty" fallback that made sense for OWNER. getResourceOptions() filters to [self] only for STAFF so the dropdown never offers a value the API would reject — mirrors BookingService.create / update / walkIn guards from Phase 2. Results: booking-web yarn lint 0/0 · yarn build clean · pending Playwright smoke test with STAFF account · docs (features.md) updated with Phase 3 done + Phase 4 E2E listed as remaining work.
2026-04-23 — Role-based audit Phase 2 — API harden for STAFF scoping
Mobile-prep audit sprint: STAFF resource-scoping shipped across booking-api. Phase 1 (matrix draft) landed in prior commit; Phase 2 closes the API surface so mobile can start without role-regression thrash. RequestUser.resourceId — added a 4th field populated by JwtAuthGuard from DB every request (extended the existing User.findUnique select with resource: { select: { id: true } }); no token-version bump required, no forced re-login, stays in sync if OWNER reassigns STAFF → Resource. BookingService scoping — Performer now carries resourceId?, and a new isStaffPerformer helper gates every read/write path: findAllByTenant filters resourceId IN [own, null] (and short-circuits to IN [] if STAFF queries someone else's column so API surfaces a consistent empty list instead of an unscoped one), findById throws ForbiddenException('BOOKING_NOT_IN_STAFF_SCOPE') for out-of-scope ids (403 not 404 — the row exists, just not in the staffer's slice), create rejects any dto.resourceId or items[].resourceId pointing elsewhere (BOOKING_ITEM_RESOURCE_NOT_ALLOWED_FOR_STAFF), walkIn rejects forged resourceId (WALK_IN_RESOURCE_NOT_ALLOWED_FOR_STAFF), update blocks reassignment to a different staff (BOOKING_REASSIGN_NOT_ALLOWED_FOR_STAFF) and item-level cross-reassignment, updateStatus piggybacks on findById so status transitions inherit the same 403, selfPick refuses if caller passes a resourceId ≠ performer.resourceId (SELF_PICK_RESOURCE_MUST_BE_SELF). BookingController wires user.resourceId into performer on every endpoint including getAuditLog (new) — audit service has no performer concept so the controller gates via bookingService.findById before calling auditService.findByBooking. TenantCustomerService.update — rewrote the data: dto as any smell into an explicit whitelist (notes/tags/metadata); metrics fields (visitCount/totalSpent/firstVisit/lastVisit) are now structurally inaccessible even if a future DTO extension adds them. ResourceService time-off — ResourcePerformer interface added, createTimeOff/updateTimeOff/deleteTimeOff enforce performer.resourceId === :id and block edit/delete when timeOff.isApproved === true (TIME_OFF_EDIT_LOCKED_AFTER_APPROVAL / TIME_OFF_DELETE_LOCKED_AFTER_APPROVAL). Controller decorators moved from @Roles('OWNER','ADMIN') to @Roles('STAFF','OWNER','ADMIN') so STAFF self-service reaches the guard. PaymentController (admin) — circular-import-safe (injected PrismaService, not BookingService) assertStaffOwnsBooking(tenantId, bookingId, user) helper gates getOne (scoped by payment.bookingId lookup), byBooking, and initiateRemaining; remaining @Roles extended to include ADMIN for consistency. UploadController — POST opened to @Roles('STAFF','OWNER','ADMIN') (avatar self-upload); DELETE kept OWNER/ADMIN (no ownership metadata on the URL, avoiding arbitrary-file-deletion risk). @Roles() tường minh audit — all admin controllers verified to have explicit role lists per matrix §1.3 convention (no implicit-hierarchy reliance). Tests: +14 — booking.service.spec.ts gets a new findById block (own/unassigned/foreign-403/OWNER bypass), a STAFF scoping — findAllByTenant describe (3 scenarios incl. cross-query empty-set), and a STAFF scoping — mutations describe (5 scenarios: create/items/update-reassign/updateStatus/walkIn). resource.service.spec.ts adds timeOff to the prisma mock + 5 STAFF self-scoping tests. tenant-customer.service.spec.ts asserts visitCount/totalSpent get silently dropped. payment.controller.spec.ts adds prisma DI + staffUser/ownerUser fixtures + 3 STAFF 403 scenarios. Existing tests updated for the new controller signatures (mockUser.resourceId: null, mockPerformer helper). Results: 1185/1187 tests pass (2 pre-existing skipped) · yarn lint clean · yarn build clean · 0 new TS errors (26 pre-existing untouched) · features.md + role-matrix.md updated · docs/progress/features.md row now 🟡 API harden done — web + E2E pending · still blocks mobile start until Phase 3 (web layer + E2E) ships.
2026-04-22 — Tenant Onboarding wizard + form-infra fixes
Tenant Onboarding wizard (7 steps) + foundational form-infra fixes. Shipped Epic 1's onboarding wizard end-to-end plus two form-library fixes that were blocking inline validation under React 19 + React Compiler. Onboarding — /admin/onboarding full-page wizard with left stepper + main content + sticky footer, 7 steps: Welcome (industry confirm), Salon info (org/name/phone/email + locale + address with AddressSearch + Nominatim reverse-geocode + LocationMap), Business hours (per-day slots with copy-to-all prompt), Services (industry preset tick/xoá/thêm), Staff (≥1 required), Booking policy (SKIPPABLE — industry default), Review & Launch. Backend: Tenant.onboardedAt: DateTime? (null = pending) + onboardingStep: String? cursor, migration backfills onboardedAt = createdAt for existing tenants. Three API endpoints /api/tenants/me/onboarding (GET state) + /step (PATCH save, reuses settings service) + /complete (POST finalize, validates ≥1 Resource, ≥1 Service, businessHours 7 days). OnboardingGuard middleware: OWNER + onboardedAt === null → redirect /admin/onboarding. After launch, redirect /admin and /admin/onboarding bounces back to dashboard. Auto-apply business hours → staff WorkingHours on step save. Tenant signup auto-provisions 3 tax rates (0/15/25%) + matching accounting codes (Norway salon defaults). Stepper completedSteps bug — backend onboardingStep cursor regresses when user hits Review, clicks Edit on a prior step, re-saves it (server moves cursor to step-after-edited, losing ✓ for later steps). Client now tracks a monotonic maxReachedIdx state that only grows, so stepper ✓ never regresses during in-session edits. Zod v4 syntax fix — schema used v3 { message: "key" } params, silently ignored by Zod v4 .min()/.email() which expect plain string or { error: "key" }. Inline error messages fell back to default English "Too small..." instead of resolving i18n keys. Fixed across SalonInfoStep schema; saved memory feedback_zod_v4_syntax.md so future sessions don't repeat. React-Hook-Form + React Compiler subscription fix — three chained bugs: (1) inline errors not rendering after submit (destructured formState: { errors } from useFormContext — Compiler elides the proxy getter), (2) errors not clearing on valid type (useFormState({ control, name }) name-filter only fires on value change, misses error-clear event), (3) autofill via setValue not updating DOM (register-based uncontrolled inputs lose ref integrity when FormField re-renders on every form state change). Migrated FormField + PhoneField to useController → controlled inputs driven by field.value, guaranteed reactive fieldState.error. Memory feedback_rhf_react_compiler.md documents all 3 symptoms + canonical pattern. Address UX — city/postalCode/country fields disabled (force fill via search or map click) across Onboarding + Settings LocationSection, plus consistent gray disabled styling (bg-gray-100 border-gray-200) across 8 form components (FormField, PhoneField, MoneyField, DateField, SearchSelect, TimeField, PasswordField, InputField, TextArea). Search dropdown result often sparse (county-only) — instead of adding county fallback parsing, handleSearchSelect now extracts lat/lng and triggers handleMapClick → reverseGeocode path, single code path for both entry modes (memory feedback_reuse_existing_flow.md). Deposit requires active payment cross-check — admin BookingPolicyEditor shows amber Alert warning ("Configure payments") when depositEnabled && !hasActivePaymentConfig; customer BookingPage disables "Continue to payment" button + shows red warning banner when requiresPayment && !settings.paymentProvider so customers don't waste time on a broken checkout. i18n — 8 new keys (en + nb): onboarding wizard strings, validation error keys (nameTooShort/addressRequired/cityRequired/postalCodeRequired/invalidEmail/etc), settings.depositNoPaymentTitle/Message/Link, booking.depositUnavailable. Results: no API changes · booking-web build clean (0 lint, 0 tsc) · 19 new files, 20 modified · memory updated with 3 new feedback notes.
2026-04-24 — Status-matrix P0 combined PR + test-type hygiene
Force cancel + deposit guard + walk-in IN_PERSON (P0-1/P0-2/P0-3). Closes the three production blockers called out in docs/flows/status-matrix/gaps-and-plan.md with a single combined PR across booking-api and booking-web.
Backend (booking-api, 9 files, +540/-12). BookingService.updateStatus(id, tenant, status, performer, options?) now takes an options: { force?, reason? } bag. OWNER/ADMIN can bypass two guards, both of which land as distinct audit actions so support can answer "why did the salon skip policy?" without decoding JSON: (a) CANCELLED outside cancellationHours — normal path still 422s for STAFF and for admins without force; isAdmin + force + trim(reason).length > 0 bypasses the window and writes STATUS_CHANGE_FORCED with note = reason; STAFF passing force=true is outright rejected with BOOKING_FORCE_NOT_ALLOWED_FOR_ROLE to keep the escape hatch scope obvious in audit trails. (b) PENDING → CONFIRMED with settings.depositEnabled — BookingService now injects PaymentRepositoryPort (module-level import of PaymentModule already existed) and rejects the transition unless at least one Payment row for that booking is AUTHORIZED or CAPTURED; OWNER can force with reason → audit CONFIRMED_WITHOUT_DEPOSIT. payableTotal = 0 (full loyalty discount) short-circuits the guard before any Payment read — this keeps P2-9 (future) simple while not blocking free bookings today. BookingCreatedPayload gains optional paymentMode: 'ONLINE' | 'IN_PERSON' (omitted = ONLINE, so every existing consumer keeps working); BookingService.walkIn() stamps IN_PERSON; PaymentIntegrationService.onBookingCreated skips InitiatePaymentCommand when paymentMode === 'IN_PERSON' so walk-ins don't spin up a PSP session the customer can't complete at the counter. UpdateBookingStatusDto { force?, reason? } added to DTOs; controller forwards the body. +17 unit tests (force cancel 7 cases including whitespace-reason + STAFF-force rejection + SYSTEM bypass + in-window-force-inert; deposit guard 8 cases including CAPTURED-path, discount-zero short-circuit, OWNER-force with reason, SYSTEM bypass; walk-in IN_PERSON event emit; payment integration IN_PERSON skip + ONLINE default). Total 1210 → 1227.
Frontend (booking-web, 5 modified + 2 new, +293/-13). useUpdateBookingStatus now takes { force?, reason? } and uses raw useMutation so the caller picks toast vs modal on error (keeps useFormMutation semantics unchanged for everyone else). New useForceOverridableStatus hook wraps it: tracks lastInputRef, detects the two overridable error codes (BOOKING_CANCELLATION_TOO_LATE, BOOKING_DEPOSIT_REQUIRED), stores override: { input, kind } when admin hits the guard, and exposes submitOverride(reason) / dismissOverride(). A force-retry failing falls through to toast (no modal loop). New ForceOverrideModal component is wrapper/inner split: when closed it unmounts so the next open resets reason state without setState in useEffect (React Compiler happy). Both BookingDrawer and BookingList swap to the new hook + render the modal. i18n: 3 error codes (BOOKING_DEPOSIT_REQUIRED, BOOKING_FORCE_REASON_REQUIRED, BOOKING_FORCE_NOT_ALLOWED_FOR_ROLE) + 8 modal strings (en + nb). 137 tests still pass, Next build clean, lint 0/0.
Test-type hygiene (bonus). yarn tsc --noEmit surfaced 26 pre-existing TS errors across 8 spec files that yarn test silently ignored because tsconfig.json had isolatedModules: true, putting ts-jest in transpile-only mode. Fixed: auth.controller.spec (missing expiresAt in mockTokens), customer.{controller,service}.spec (dropped tenantId args since Customer went global), loyalty.{controller,service}.spec (switched string literals to LoyaltyCardTypeDto / RewardTypeDto enums + numeric requiredVisits), payment-webhook.controller.spec (Parameters<> → ConstructorParameters<> + explicit FakeProvider type so verifyCalls stays visible after the setup helper), service.{controller,service}.spec (dropped stray undefined arg + added accountingAccount to prisma mock type). Added ts-jest transformer options { diagnostics: true, isolatedModules: false } to package.json's jest block so the runner now catches type drift the same as the standalone tsc gate — closes the "tests pass but types fail" gap.
Results: 1227 API unit (+17 P0) · 137 web unit · yarn tsc --noEmit 0 errors (was 26) · nest build + Next build clean · lint 0/0 · three repos committed separately (booking-api chore/tests + feat, booking-web feat, booking-system docs).
2026-04-21 — Customer-portal end-to-end polish
Customer-portal end-to-end polish — booking ticket, rebook, My Bookings, inline validation, error-code i18n, past-time guard. Shipped one tranche covering eight user-facing improvements on the booking flow. 1. Pagination (/customer/me/bookings) — CustomerPortalService.getBookings now takes PaginationDto, returns {data, meta} via shared paginateParams/paginateResult; CustomerPortalController.getBookings wires @Query() pagination. BookingsSection.tsx adds page+limit state, placeholderData: keepPreviousData so the list doesn't collapse to skeletons between pages, Pagination component reused from admin. +3 unit + 1 e2e test ("honors page + limit query params"), existing empty-state assertions updated for the envelope. 2. Sort by createdAt — swapped orderBy: { startTime: 'desc' } → { createdAt: 'desc' } so the most-recently booked appointment is always on top (receipt-style), matching what customers expect from "My bookings". 3. Public booking detail endpoint (GET /public/tenants/:slug/bookings/:id) returns id/status/startTime/endTime/customerName/notes/items (service+resource+duration+price)/tenant (name+slug+timezone+address) + payment snapshot. No auth (bookingId is a UUID — sufficient entropy), tenant-scoped so a leaked id can't probe other salons. 4 unit + 2 e2e tests (detail + 404 per-tenant + 404 per-slug). 4. Booking success ticket + QR — PaymentReturnClient extracted into a shared components/bookings/BookingTicket (screenshot-friendly: salon → booking id + date/time → services (name·staff·duration·price) → total/paid → QR). QR payload is the canonical public URL ${origin}/b/:slug/bookings/:id (was /admin/bookings/:id) — mobile staff app parses the pattern to open in-app; a route redirect at /b/[slug]/bookings/[id] forwards a generic camera scan to /payment/return so customers get the ticket instead of a dead end. When logged-in, "My bookings" secondary button appears above "Back to salon". 5. My Bookings modal + Book again — card gets dedicated buttons: outline "View" (Eye icon) opens BookingDetailModal which fetches via publicApi.getBooking and renders the same BookingTicket headerless; solid brand "Book again" (CalendarPlus icon) links to /b/:slug/book?from=<bookingId>. Rebook flow rewritten server-side — was packing serviceId/resourceId/notes into the query string which doesn't scale past a few services. book/page.tsx now reads ?from=<id>, calls publicApi.getBooking + publicApi.getResources, validates each item (drops service if deactivated, clears resourceId if staff removed — NOT by skill filter, so the customer sees "their" staff selected and fails at availability rather than silently reverting), then passes preSelectedItems: BookingItemData[] to BookingPage. Multi-service bookings clone all items in one URL. Card also surfaces Appointment + Created at times side-by-side using formatDateTimeInZone (same dd/mm/yyyy HH:mm format as Payments) so customers can tell booking time from booking record time. 6. Past-time booking guard — validateStartTimeNotInPast(startTime, now) added to booking-settings.helper.ts, called in BookingService.create for all roles (admins with legitimate past walk-ins use the dedicated /walk-in endpoint that starts IN_PROGRESS). Error code BOOKING_START_TIME_IN_PAST. AvailabilityService also filters slot.getTime() <= now in the slot loop — UI no longer shows slots the user couldn't book anyway. AvailabilityService now takes @Inject(CLOCK) so tests can pin time. +4 helper unit + 2 service unit ("reject past, reject now-edge"), availability spec gets a pinned 2026-04-07T00:00Z clock. 7. Inline validation + error-code audit — BookingPage.handleSubmit replaced the single bottom banner with per-item itemErrors: Record<index, {staff?, time?}>; ServiceItem forwards into the existing SearchSelect.error prop (red border + message). Errors auto-clear on field change + re-index correctly when the customer removes a service between others. booking-api.ts upgraded to throw a typed PublicBookingApiError {code, message, status} so useErrorMessage can look up the backend code; the hook itself went duck-typed (accepts any error with .code: string) so non-ApiError sources translate too. 38 missing error-code keys added to errors.* (en + nb): all CUSTOMER_AUTH_* + CUSTOMER_PORTAL_*, all LOYALTY_*, PAYMENT_CONFIG_* / PAYMENT_CURRENCY_* / PAYMENT_NO_ACTIVE_CONFIG / PAYMENT_WEBHOOK_VERIFICATION_FAILED, SCHEDULE_OVERRIDE_NOT_FOUND, TENANT_CUSTOMER_NOT_FOUND, REQUIRED_VISITS_MISSING, UNASSIGNED_BOOKING_STAFF, AUTH_TOKEN_REVOKED. Raw backend codes no longer leak to customers. 8. Admin check-in page + Google-email protection — /admin/bookings/[id] is a standalone route outside the (dashboard) group so its own AuthGuard can allow STAFF (dashboard layout is ADMIN+OWNER only); minimalist page shows customer + services + date/time and a prominent "Mark as arrived" button (success green). canMarkArrived(booking) helper returns {ok, reason?} with structure ready for future time-window gating (TOO_EARLY, TOO_LATE) but MVP only checks status === 'CONFIRMED'. Customer profile form: Customer.googleId surfaced as isGoogle: boolean on /auth/customer/me + /customer/me (raw googleId never leaks); ProfileSection disables the email input + shows "Email is managed by your Google account." hint when isGoogle; updateProfile silently drops dto.email for Google customers (no 400 — prevents stale forms breaking the update). Also dozens of smaller UX polish items landed in the same pass: QR card overflow-hidden fix (2 góc dưới bo tròn), mobile padding pass on the ticket + account sidebar, max-w-xl+pt-4 pb-10 wrapper on mobile, formatDateTimeInZone applied to BookingsSection for consistency with Payments, back-button bg-gray-100 default, CustomerHeader dropdown Profile/My Bookings deep-links with correct tab param, services row in ticket shows price too. Results: 1144 API unit (+4 past-time helper, +3 customer-portal isGoogle, …) · 63 e2e (+3 public-booking detail) · OpenAPI + api.generated.ts regenerated · lint 0/0 · both builds clean.