Staging (GitLab) × Production (Azure) → StagingProdClone branch, scoped to /api
MERGED — both kept: every hunk was additive on at least one side (new feature/field/check) with nothing genuinely
incompatible — both branches' logic now runs together.
PRODUCTION-led / STAGING-led: that file's conflicts were mostly/entirely one side adding something the other
side simply didn't have yet — kept the addition, nothing was thrown away.
JUDGMENT CALL: at least one hunk had two mutually exclusive implementations (same export/behavior,
different content) that could not both exist — a specific side was picked and the reasoning is documented inline.
REGENERATED: package-lock.json — hand-merging a lockfile is unsafe; it was regenerated via
npm install against the resolved package.json instead.
| File | Conflicts | Resolution | One-line summary |
|---|---|---|---|
.gitignore | 2 | MERGED | Union of every rule from both sides, deduped; kept Production's .env negations (load-bearing) + Staging's Firebase-key exclusions. |
package-lock.json | 1 | REGENERATED | Rebuilt via npm install against resolved package.json. |
package.json | 2 | MERGED | Kept Production's scripts + Staging's test:push + newer TypeScript patch; added postinstall to auto-regenerate Prisma Client on deploy. |
schema.prisma | 5 | PRODUCTION-led | All 5 hunks were Production-only additions (WhatsApp models, canPolicyManual, decisionToken). |
server.ts | 6 | MERGED | Both sides' route mounts kept; Staging's multi-origin CORS + Production's /api/uploads alias. |
middleware/auth.ts | 5 | MERGED | Production's suspension/revocation hardening kept + Staging's requireAnyAuth restored. |
lib/publicUrl.ts | 1 | PRODUCTION-led | Kept the /api/uploads-promoting rewrite logic. |
lib/push.ts | 1 | MERGED | Staging's CHECKPOINT support + Production's OWNER fallback clause combined. |
lib/webPush.ts | 1 | STAGING-led | Staging's version was a strict superset (adds CHECKPOINT + broader APPROVER match). |
lib/templates.ts | 3 | JUDGMENT CALL | Same export names, different bodies — kept Production's HTML templates (needed for decision-token flow). |
lib/reminderCron.ts | 4 | MERGED | Staging's broader email-OR-mobile filter + Production's WhatsApp reminder firing. |
routes/automationTimings.routes.ts | 2 | PRODUCTION-led | requireFlag('canManageSettings') gate kept. |
routes/departments.routes.ts | 2 | PRODUCTION-led | Same — permission gate kept on writes. |
routes/emailAutomation.routes.ts | 2 | PRODUCTION-led | Same — permission gate kept on writes. |
routes/emailTemplates.routes.ts | 3 | MERGED | Permission gate + Production's extra render/restore-defaults routes. |
routes/visitors.routes.ts | 5 | MERGED | Production's requireFlag gates + Staging's requireAnyAuth on device routes. |
routes/walkInQR.routes.ts | 2 | PRODUCTION-led | Permission gate kept on writes. |
controllers/admin.controller.ts | 5 | PRODUCTION-led | All 5 hunks were the additive canPolicyManual flag. |
controllers/auth.controller.ts | 4 | PRODUCTION-led | Workspace-suspension gate + deactivated-account guard (privilege-escalation fix). |
controllers/devices.controller.ts | 4 | STAGING-led | Strict superset — checkpoint device support + smarter per-principal upsert dedup. |
controllers/emailAutomation.controller.ts | 2 | PRODUCTION-led | approverRequestEnabled field kept. |
controllers/walkInQR.controller.ts | 3 | MERGED | Staging's localhost-safe QR URL logic + Production's org-name-on-poster addition. |
controllers/approver.controller.ts | 9 | MERGED | Staging's push-notification payloads + Production's WhatsApp invite dispatch. |
controllers/emailTemplates.controller.ts | 9 | PRODUCTION-led | Staging's side was a strict subset — Production kept wholesale. |
controllers/visitor-scanner.controller.ts | 10 | MERGED | Production's hardening/WhatsApp + Staging's push data & new arrival-notify loop. |
controllers/visitors.controller.ts | 35 | JUDGMENT CALL | Largest file — 27 additive, 8 restored-from-staging (push payloads), 1 documented bug-fix pick, 1 template-design judgment call. |
scripts/seed.ts | 30 | JUDGMENT CALL | Staging's dataset used as base (strict superset) + one Production safety line grafted in. |
scripts/smoke.ts | 8 | JUDGMENT CALL | Rebuilt on Staging's version (matches actual seed data) + 2 Production behavior fixes patched in. |
api/)api/dist/, web/dist/, api/uploads/); .env.* with explicit !.env.example / !.env.mobile negations to force-track those two files; narrow .claude/settings.local.json ignore (rest of .claude/ stays trackable); api/src/prisma/generated/.dist/, build/); *-firebase-adminsdk-*.json / *-service-account*.json (credential-leak protection); blanket .claude/ ignore; pnpm-debug.log*, logs/, *.zip, *.bak; Prisma client paths under node_modules/; apps/api/uploads/ (stale path — this repo has no apps/ directory).web/.env.mobile is currently a real, tracked, modified file in this
repo (visible in git status). Production's .env.* pattern would ignore it were it not
for the explicit !.env.mobile negation directly beneath it — dropping that negation in favor of
Staging's narrower .env.local/.env.*.local patterns would have been "safe" on its own,
but only by accident (it doesn't test the negation-ordering issue at all). Kept Production's negations to be
certain nothing currently tracked gets silently hidden.
.env.* + !.env.example/!.env.mobile negations (load-bearing for currently-tracked files) and added Staging's more specific .env.local/.env.*.local alongside them.*-firebase-adminsdk-*.json/*-service-account*.json patterns — this codebase uses firebase-admin for push notifications (see src/lib/push.ts), so this is a real credential-leak guard Production was missing..claude/settings.local.json-only ignore rather than Staging's blanket .claude/ — no files under .claude/ are currently tracked either way, but the narrower rule is the more deliberate choice and doesn't block the team from later committing shared Claude Code config (skills/commands), which a blanket ignore would silently prevent.pnpm-debug.log*, logs/, *.zip, *.bak, and Prisma-under-node_modules patterns — all harmless additions.Test: run git status after this change and confirm web/.env.mobile still shows as tracked/modified (not silently ignored), and that no node_modules/dist/log files show up as untracked.
package.json first, then ran npm install --package-lock-only
to regenerate a clean, consistent lockfile matching the merged dependency set.
Test: run npm install fresh from this branch and confirm no peer-dependency warnings beyond pre-existing ones.
--exec, TypeScript ^6.0.2.--exec, added test:push script, TypeScript ^6.0.3.test:push script
(needs src/scripts/test-push.ts, which merged in cleanly with no conflicts). Took the newer
TypeScript patch version ^6.0.3 from Staging (pure version bump, safe).
postinstall script, so a fresh npm install on the
deploy server never re-ran prisma generate. The deployed build picked up a stale
generated Prisma Client (from before the merge added/changed models) and npm run build
failed with TS2339: Property 'visitor' does not exist on type ...PrismaClient...
across account.controller.ts, delete-admin-visitors.ts,
and delete-owner-visitors.ts — all three files whose transactional
(prisma.$transaction) code paths touch many models at once, so a stale client's
missing methods surfaced there first. This was not a merge conflict or code bug — confirmed locally by
deleting node_modules/.prisma + node_modules/@prisma/client
and re-running npm install from scratch: build passes with zero errors once the
client is regenerated from the merged schema. Added
"postinstall": "npm run prisma:generate" to package.json so this can't
recur on any future install/deploy.
Test: npm run test:push should run without a "file not found" error.
WhatsAppTemplate, WhatsAppAutomation models, Owner.whatsAppTemplates/whatsAppAutomation relations, Admin.canPolicyManual, EmailAutomation.approverRequestEnabled, Visitor.decisionToken.Test: run a migration / prisma db push against a scratch DB and confirm the new columns/tables appear without dropping any existing Staging-only data shape.
/uploads and /api/uploads (reverse-proxy alias); simple single-string CORS origin.FRONTEND_URL can list several origins); serves uploads only under /uploads.Test: hit /api/uploads/<file> and /uploads/<file> both resolve; set FRONTEND_URL to a comma-separated list locally and confirm CORS accepts all of them.
requireApproverAuth, requireVisitorCheckpointAuth, requireScanAccess all made async and hardened with workspace-suspension checks + checkpoint-revocation checks (a deleted/disabled checkpoint's token dies on next request).requireAnyAuth — permissive auth accepting owner/admin/approver/checkpoint tokens, used only for the harmless device-registration endpoint.git show each side) instead of hand-splicing the
merge-marker soup. Kept Production's async hardening on all 3 functions in full, and re-inserted Staging's
requireAnyAuth function (which Production had none of) right after requireApproverAuth.
Test: (1) disable a checkpoint and confirm its scanner session gets a clean 401 on next request; (2) suspend a workspace and confirm approver/checkpoint/scan sessions are all locked out; (3) register a push device while logged in as a checkpoint identity.
/uploads/... paths to /api/uploads/... in JSON responses (needed for the reverse-proxy alias)./uploads/... only, no /api promotion./api/uploads alias decision already made in server.ts.Test: fetch a visitor record whose photoUrl is a legacy relative path and confirm the JSON response rewrites it to an absolute /api/uploads/... URL.
adminId === ownerId.approverId: checkpointId); APPROVER matches both approverId and adminId.Test: trigger a push-worthy event (visitor approval) for a CHECKPOINT-role session and confirm a push notification actually arrives on a registered device.
approverId: null; APPROVER matches both adminId and legacy approverId; adds real CHECKPOINT web-push support.Test: subscribe a browser to Web Push as a checkpoint identity and confirm a browser notification fires on a scan event.
DEFAULT_VISITOR_INVITE / CHECK_IN_CONFIRMATION / VISITOR_REMINDER as rich HTML "info-box" cards with individually-rendered fields; plus a 4th template DEFAULT_APPROVER_REQUEST with Approve/Reject button HTML and approve_url/reject_url template variables (needed by the decision-token email flow).{{visit_details}} sentinel block instead of individual fields. No approver-request template — Staging has no decision-token flow.DEFAULT_VISITOR_INVITE
(etc.) with genuinely different literal string content — JavaScript can't have two exports of the same name,
so one body had to be chosen.
DEFAULT_APPROVER_REQUEST +
approve_url/reject_url, which the already-merged decision-token approval-email
feature depends on). Folded Staging's visit_details sentinel back into
KNOWN_VISITOR_INVITE_KEYS so it's still a valid, insertable variable for any hand-customized
template that uses it — no capability was silently removed, just the seeded default copy.
Test: open Settings → Email Templates for a fresh workspace and confirm the default INVITE/CHECK-IN/REMINDER bodies render the HTML info-box style, and that APPROVER_REQUEST has working Approve/Reject buttons in preview.
sendVisitReminder); candidate query requires email present.email OR mobile present; fires both email and WhatsApp reminders (sendVisitReminder + sendVisitReminderWA) via Promise.allSettled.Promise.allSettled so one channel failing doesn't block the other.Test: seed a visitor with only a mobile number (no email) whose visit is ~24h out and confirm the reminder cron still picks them up and fires a WhatsApp message.
requireFlag('canManageSettings') gating on the PATCH route (staging only had requireAuth). Kept the permission gate — real security hardening, no staging-only logic lost.Test: a sub-admin without canManageSettings should get 403 on PATCH; owner always succeeds.
requireFlag('canManageSettings').Test: same 403-for-unflagged-sub-admin check on POST/PUT/DELETE department endpoints.
requireFlag('canManageSettings').Test: same 403 check on PATCH email-automation settings.
GET /:type/render-for-visitor/:visitorId and POST /:type/restore-defaults, plus requireFlag gating on save/restore.Test: restore-defaults requires canManageSettings; render-for-visitor returns the fully-rendered HTML for a real visitor id.
requireFlag('canManageSettings'/'canManageApprovers') gates on reasons/approvers/checkpoints writes; device register/unregister use plain requireAuth.requireAnyAuth so checkpoint sessions can register for push too.requireFlag gates and switched device register/unregister to Staging's requireAnyAuth — both needed simultaneously (permission hardening + checkpoint push support).Test: full pass — sub-admin permission 403s on reasons/approvers/checkpoints writes, AND a checkpoint-role login can successfully call /devices/register.
requireFlag('canManageSettings') gate on create/update/delete of walk-in QR posters.Test: 403 for unflagged sub-admin on walk-in QR create/update/delete.
createAdmin/updateAdmin: the canPolicyManual permission flag (parent-capped inheritance included). Kept in every spot.Test: create a sub-admin, verify canPolicyManual is settable and capped at the parent admin's value when a sub-admin creates a receptionist.
workspaceSuspendedForEmail() helper + suspension checks in both sendOtp and verifyOtp; adds a deactivated-account guard that blocks OTP verification for a disabled Admin/Approver email instead of silently creating a new Owner account.Test: (1) suspend a workspace, confirm OTP send/verify both 403; (2) deactivate an admin, confirm their email now gets 403 at verify-otp instead of creating a new owner account.
approverId column); finds and updates the existing row for a principal first, avoiding duplicate device rows.Test: register the same device token twice for the same user and confirm only one DeviceToken row exists afterward (no duplicates).
approverRequestEnabled field in the update payload — matches the schema field already merged.Test: toggle "Approval request emails" in Settings and confirm it persists.
publicAppUrl() just checks for an http(s):// prefix (would happily bake a localhost origin into a printed QR poster). Public form response adds orgName for the "Welcome to {org}" header.publicAppUrl() explicitly rejects localhost/127.0.0.1/::1 origins and handles comma-separated Origin headers. No orgName.publicAppUrl() (real safety fix — a dev/localhost URL should never end up on a printed poster) and kept Production's orgName addition on the public walk-in form.Test: create a walk-in QR from a localhost dev session and confirm the encoded URL falls back to FRONTEND_URL rather than encoding localhost; scan a poster and confirm the form shows "Welcome to {workspace name}".
approverCreateVisitor.push: {'{'} data: {'{'}...{'}'} {'}'} payload fields to recordNotification calls across 4 functions (approveVisitorRequest/approveWalkIn/rejectWalkIn/rejectVisitorRequest), including two entire OWNER notification blocks Production had dropped.Test: approve/reject a walk-in request as an approver and confirm both a bell notification AND a push notification reach the owner and the reception checkpoint.
APPROVER_REQUEST template-type support, a sanitizeColor() helper for style overrides, and two whole extra exported functions (restoreEmailTemplateDefaults, renderEmailTemplateForVisitor). No manual line-by-line merging was needed; Production was kept verbatim throughout.Test: restoreEmailTemplateDefaults and renderEmailTemplateForVisitor both work end-to-end from the Email Templates settings page.
push data fields to OWNER/CHECKPOINT notifications, plus a brand-new "notify the assigned approver/admin on arrival" loop for plain auto-check-ins (previously no ping existed for that path) and the same in walk-in-arrived / walk-in-request flows.Test: scan in a visitor with an assigned host (no approval required) and confirm the assigned host now gets a bell + push notification that the visitor arrived — this is new behavior post-merge, worth a dedicated test pass.
APPROVER_REQUEST automation kind + the decision-token approve/reject-by-email flow (Staging has none of this).TemplateStyle + extractStyle() — per-template header/info-box color customization.isHtmlFragment() detection so rich-text-editor bodies render instead of getting HTML-escaped and mangled.canManageVisitors/canAddVisitors) added to create/bulk-create/update/delete visitor.canPolicyManual gating in ensurePolicyAllowed.createVisitor.The approverByEmail Map in bulkCreateVisitors: Production's explicit new Map<string,string>(...) typing traces to a real prior commit (21aea56d "Type approverByEmail Map explicitly to fix server build"). Staging's untyped version was the pre-fix state that failed the build. Kept Production's typed version.
On these 8 hunks, Production's side had either deleted the notification code entirely or omitted the push payload field — which would have silently made Staging's mobile-push feature non-functional at these call sites, even though push.ts/webPush.ts/events.ts were already merged to support it. All 8 were restored:
checkoutVisitor — restored the OWNER bell notification on checkout (Production had dropped it).approveVisitorRequest — added push to the CHECKPOINT notification; restored an entire missing OWNER notification (with push) on walk-in approval.rejectVisitorRequest — same pattern: restored OWNER bell/push, added push to CHECKPOINT notification.approveVisitorScan / rejectVisitorScan — added the missing push field to APPROVER and CHECKPOINT notifications (4 hunks total across both functions).visitors.routes.ts,
sendVisitReminder (used by reminderCron.ts), and the dynamically-imported
sendVisitorInvite/sendCheckInConfirmation/sendApproverRequest/renderInviteHtmlPreview
(used by other controllers) are present and correctly typed. tsc --noEmit and npm run build both pass clean.
Test priority for this file (highest-risk file in the merge):
1. Checkout a visitor → owner gets a bell notification.
2. Approve/reject a walk-in request and a live scan → owner/approver/checkpoint all get bell + push.
3. Bulk-create visitors with an approver-email column → confirm no server error (the Map typing fix).
4. Open Email Templates → preview an invite with a custom header color → confirm it renders.
5. Preview an invite with a rich-text (HTML) body from the editor → confirm tags render instead of showing as escaped text.
6. Confirm the QR block in a sent invite email shows just the QR image (no duplicate label/Pass-ID row) — flag if you actually wanted the fuller card design back.
alice@example.com/bob@example.com purely as legacy Approver rows (no matching Admin row) — neither side actually seeds the "Admin with isApprover=true" shape that some Production code comments elsewhere describe as the "post-merge" model. This is a pre-existing inconsistency between the codebase's auth-flow comments and its own seed data, not something introduced by this merge.
wipe() was missing: nulling the admin self-referential FK
(createdByAdminId) before bulk-deleting admins, which prevents a foreign-key
violation on re-seed once any receptionist-type admin exists. Used Staging's full script as the base, and
grafted in Production's one defensive addition: an explicit visitorEditLog.deleteMany()
at the start of wipe() for extra idempotency safety on re-runs.
Test: run npm run seed twice in a row against the same DB and confirm the second run completes cleanly with no FK-violation errors (validates the admin self-ref fix). Then spot-check the new scenario types: a CHECKED_OUT visitor, a DAILY-frequency visitor, and a pre-approval visitor all appear correctly in the visitor list.
role=ADMIN and exercises the /visitors/admin-me + /visitors/:id/approve-scan + /visitors/requests/:id/approve endpoints; asserts a re-scan returns 200 + alreadyArrived; asserts Carol (inactive) gets blocked outright.role=APPROVER and exercises the legacy /approver/me + /approver/visitors/:id/approve + /approver/requests/:id/approve endpoints; asserts a re-scan returns 400; treats Carol's login as "known fallback behavior, don't fail the run" (pre-fix expectation).seed.ts (above) seeds Alice/Bob as legacy Approver rows only, Production's own smoke assertions (role=ADMIN, /visitors/admin-me) would actually fail against the real merged seed data — her login genuinely returns role=APPROVER. This traces back to the same pre-existing seed/comment inconsistency noted for seed.ts, not a mistake introduced here.
200 + alreadyArrived: true (confirmed live in the resolved visitor-scanner.controller.ts).auth.controller.ts).Test: run npm run seed && npm run smoke end-to-end against a fresh DB and confirm all checks pass — this is the fastest way to validate the whole merge in one shot.
Delete node_modules/ entirely and run a clean npm install
(or the deploy platform's equivalent — e.g. a fresh container build, not a cached layer). Confirm the
postinstall hook logs a "Generated Prisma Client" line, then run
npm run build and confirm zero TS2339 errors. If your deploy
pipeline caches node_modules between builds, make sure the cache key includes
package-lock.json and schema.prisma, or invalidate it
manually after this merge — a warm cache from before the merge will reintroduce the stale-client build failure.
Fresh DB → npm run prisma:push → npm run seed → npm run dev → npm run smoke. All smoke checks should pass.
Suspend a workspace from the platform console → confirm OTP send/verify, requireAuth, requireScanAccess, requireApproverAuth all reject with 403. Deactivate an admin → confirm their email can no longer verify-otp (no silent new-owner creation). Test canManageSettings/canManageApprovers/canPolicyManual gating on the relevant sub-admin write endpoints.
Register a device/web-push subscription as a CHECKPOINT-role login. Trigger an approval, rejection, checkout, and auto-arrival event and confirm push notifications land for OWNER/APPROVER/CHECKPOINT recipients as appropriate — this is the area with the most restored (previously-dropped) Staging logic.
Enable WhatsApp automation for a workspace, create a visitor, confirm a WhatsApp invite fires alongside the email. Trigger an approval-request email and click through the Approve/Reject button links (decision-token public routes) end-to-end.
Check default templates render as HTML info-cards. Set a custom header color on a template and confirm it applies. Paste rich HTML from the editor into a body and confirm it isn't escaped. Confirm the QR block in a sent invite shows only the QR image (documented judgment call — flag if this isn't the desired design).
Generate a walk-in QR poster from a localhost dev session — confirm it does not bake a localhost URL into the poster. Scan a poster and confirm the public form shows the workspace's org name.