RS Release Notes

StatusReleasesTicketsProjectsOps Dashboard

Production releases and updates to the RentSolutions platform.

v2026.08.11 Latest

Tuesday, August 11, 2026

4 new 6 fixes 14 total
Update App

Release: staging → main (2026-08-12) — production onto edge.rent, per-company branding, gzip + boot reliability

#3020
Cumulative origin/staging → origin/main release. 61 commits, 46 PRs, 98 files.

Head: #3019 (Pricing report-card copy). Opened from release/2026-08-12 rather than staging directly — the repo auto-deletes the head branch on merge, and pointing that at staging would delete staging.

Merge BE #2977 first. This release depends on POST /api/user/session-handoff and on the company slug being returned at login. Deploying this side first means a login that redirects a user to a company host with nothing to redeem on the far side.

---

🌐 The headline: production moves onto edge.rent

#2952 serves the app from per-company subdomains and lets companies rename their web address. #2963 points production builds at the edge.rent zone.

This side flips the moment it deploys. deploy-main.yml:48 sets VITE_COMPANY_SUBDOMAIN_ROOT: edge.rent at build time — compiled into the bundle, not read at runtime — so from the first deploy a production login places the user on {slug}.edge.rent. There is no runtime switch on this side.

And neither does the backend land inert. I initially wrote that BE #2977 would deploy dormant because COMPANY_SUBDOMAIN_ROOT gates it; then I checked the boxes. It is already set to edge.rent on both rentsolution-prod-be and rentsolution-prod-worker, verified by SSM. So the BE merge moves every generated link at the same time.

Neither half is staged. Treat this as one cutover in two merges, not a progressive rollout. If you want it staged, unset COMPANY_SUBDOMAIN_ROOT on both backend boxes before merging BE #2977 — that keeps links on FRONTEND_URL while this side ho…
Update API

Release: staging → main (2026-08-12) — production onto edge.rent, vendor compliance + AppFolio sync, CMA/HouseCanary scoring

#2977
Cumulative origin/staging → origin/main release. 86 commits, 65 PRs, 245 files, 13 migrations.

Head: #2975 (HouseCanary rental AVM fallback). Opened from release/2026-08-12 rather than staging directly — the repo auto-deletes the head branch on merge, and pointing that at staging would delete staging.

---

🌐 The headline: production moves onto edge.rent

#2896 serves the app at per-company subdomains and takes every generated link off the single global FRONTEND_URL. #2940 adds a single-use session handoff so a redirected user stays signed in across the origin hop (auth is localStorage, which is per-origin), #2945 returns the company slug at login, #2962 tells the handoff caller which company host to land on, #2965 stops a failing slug lookup from failing a mint that already succeeded, and #2954/#2959 point staff notification links and lead-url merge fields at the company's own host.

Prod infrastructure verified before opening this:

| check | result |
|---|---|
| *.edge.rent wildcard DNS | 34.193.132.2 — resolves for an arbitrary slug |
| TLS on an arbitrary slug | ssl_verify_result=0, HTTP 200 |
| app.edge.rent | HTTP 200 |
| notify.edge.rent SendGrid CNAMEs | unchanged, still u39339008.wl009.sendgrid.net |
| apex MX (Google Workspace) | unchanged, 1 smtp.google.com |

🚨 Read this before merging: this merge IS the link cutover

I wrote this section the wrong way round first, then checked the boxes. Correcting it here rather than quietly.

COMPANY_SUBDOMAIN_ROOT=edge.rent is ALREADY SET on production — …
New App

Compose in-flight and post-answer offer guards, add regression tests

#3012
Fixes a regression introduced by #3010 (live on main), and closes a second hole #3010 never covered.

Behaviourally verified in real Chromium with a fake camera and a real second peer over loopback ICE — 44 runs across four variants: base (pre-fix), old (#3009), new (#3010, current main), next (this PR). Numbers below are measured, not reasoned.

The two windows

Duplicate offers reach the agent because the server replays the cached offer per gs:join and three agent components share one socket. They arrive in two different windows, and every previous fix covered only one:

CONCURRENT — inside the getMediaStream() await (a real camera takes 200-1500 ms; the duplicates land 4 ms apart).
SEQUENTIAL — after the answer is out, e.g. a socket reconnect gives a fresh server socket with an empty dedupe map, so the cached offer (2 min TTL) is replayed to a still-connected call.

Measured

Original bug — identical offer burst, gumDelay=300

| | pcs | closed | getUserMedia | answers | agent | prospect |
|---|---|---|---|---|---|---|
| base x3 | 3 | 0 | 3 | 3 | connecting | connected |
| old x3 | 3 | 0 | 3 | 3 | connecting | connected |
| new x3 | 1 | 0 | 1 | 1 | connected | connected |
| next x3 | 1 | 0 | 1 | 1 | connected | connected |

old (#3009) measured identical to no fix at all — its guard was written after the answer emitted, long after every copy passed it.

Late replay into a healthy CONNECTED call (S5 +1.5s, S6 +4s = the socket-reconnect path)
Fix API

Announce agent-joined only on a genuine arrival

#2971
Removes the second duplicate-offer generator, which #2969/#2970 left in place.

Why

gs:agent-joined is emitted on every gs:join. Three agent components share one socket and each emit gs:join, so the prospect receives three arrival announcements and answers each with a verbatim re-offer (useVoiceCall.js reofferOnPeerJoin re-emits lastOfferRef unchanged).

#2970 deduped the *cached-offer replay* channel, but not this one. So the agent still gets up to three identical offers — and the spread between them is not the "few milliseconds" the earlier PRs assumed: between the join log line and the emit, each handler awaits a GuidedSession.findOne, plus resolveAgentAvatar (a User.findByPk, a Media.findOne and an S3 presign). Several hundred ms of spread on a loaded box is ordinary — comfortably enough to land after the agent's answer, where the client-side in-flight lock has already released.

Change

Capture room membership before socket.join(room) and announce only on a genuine arrival.

Safe for presence semantics: a reconnecting client is always a new server-side socket with empty rooms, so a real re-join still announces. Only the redundant same-socket re-joins are suppressed.

Verification

node --check passes; eslint 0 errors (2 pre-existing warnings untouched).

Not verified end-to-end — needs two authenticated peers on prod signalling.

Related, still open

The replay payload carries no sessionId, so on a shared agent socket a replay for session B is handled by the component bound to session A. One-line hardening p…
New App

Compose in-flight and post-answer offer guards, add regression tests

#3011
Fixes a regression I introduced in #3010, which is live on main now. Machine-verified: the new test below fails against current main and passes here.

What #3010 got wrong

#3010 replaced main's persistent answered-SDP guard with an in-flight lock. Those cover disjoint windows, and are not alternatives:

CONCURRENT duplicates — copies racing inside the getMediaStream() await. #3010's lock fixes these. That part works.
SEQUENTIAL duplicates — a copy arriving *after* the answer. #3010 deleted the only guard covering this, and the deleted comment described exactly what now happens: re-answering *"would close a CONNECTED pc and emit a second answer the prospect discards (its signalingState is already 'stable')"*.

So a post-answer duplicate now tears down a live call. That is worse than the original bug — one-way video became a hard-dead call. Live trigger: AgentVoiceCall.jsx:45-48 re-emits gs:join whenever the socket reference changes, with no joined guard (the sibling components have one).

Proof, running the new tests against origin/main:

✓ collapses CONCURRENT duplicates …
× ignores a SEQUENTIAL duplicate once the connection is up -> expected true to be false
✓ still answers a verbatim re-offer when NOT connected
✓ releases the lock when answering throws

expected true to be false is pc.closed — production closes the connected peer connection.

This change

1. Compose both guards. In-flight Set for concurrent copies; answeredOfferSdpRef for post-answer copies. The post-answer guard only fires when pcRef.current.connectionState …
Fix App

Lock concurrent duplicate offers instead of deduping after the answer

#3010
Follow-up to #3009. #3009 does not actually fix the bug — this does.

Why #3009 misses

Its guard requires pcRef.current to be set AND the SDP to match answeredOfferSdpRef. Both only become true after getMediaStream(), createPeerConnection() and createAnswer() have all completed. The duplicates arrive *before* that, in the window the guard isn't armed yet.

From the prod log in #2969 (session 0959154b, socket dKPNiBOA…):

16:18:04.851  Agent joined signaling room
16:18:04.855 Agent joined signaling room <- 4 ms later


t=0 ms: replay #1 -> handleOffer A -> pcRef null, ref null -> guard correctly skipped -> suspends on getMediaStream() (camera: 300 ms - 2 s)
t=4 ms: replay #2 -> handleOffer B -> pcRef still null, ref still null -> guard does not fire

callStateRef compounds it: it is synced in a useEffect, so right after setCallState("connecting") it still reads "idle" for the whole media window, which disarms the guard's own callState clause.

The real failure, which neither PR fixed

Because B snapshotted const existing = pcRef.current before A had assigned it, B's if (existing) is false — so A's peer connection is never closed. Two live pcs; pcRef ends on B's.

pc A is connected and streaming the agent's camera -> the prospect sees the agent fine
pcRef.current !== pc stale-pc guards then drop A's ontrack and onconnectionstate…
Fix API

Replay each cached offer at most once per socket instead of once per room

#2970
Follow-up to #2969, fixing a regression that PR introduced.

The regression

#2969 gated the cached-offer replay on alreadyInRoom (room membership captured before socket.join). That stops the duplicate storm, but a socket never leaves the room — there is no socket.leave() anywhere in this file — and useSocket() hands out one long-lived socket for the entire admin session. So after the first join, alreadyInRoom is permanently true and the replay can fire at most once EVER per socket+session.

Concrete losses:

Second call in the same session: call ends, the prospect calls back and offers first, the agent clicks Join -> gs:join -> alreadyInRoom true -> no replay. Recovery falls entirely to the prospect's reofferOnPeerJoin, which only fires while its pc is still have-local-offer.
EditShowingCard.jsx:110 mounts ProspectActivity for any virtual showing opened from the showings/history list. An agent who merely *browsed* a past showing has already joined that room and silently consumed the single replay that session will ever get.

#2969's body said "a real new agent socket is unaffected" — true only for a socket's *first* join, which the description didn't make clear.

This change

Key the dedupe on the offer SDP per socket (socket.gsReplayedOffers) instead of room membership:

duplicate gs:joins within milliseconds carry the same cached offer -> replayed once (the storm stays fixed)
a genuinely new call caches a new offer -> replay fires again (the safety net comes back)

Still bounded by the e…
Improvement App

Extract the embed-token query into a hook

#3008
Addresses the review feedback on #3007, which merged before the fix landed.

What

New src/hooks/common/useDocuEditToken.js holding the React Query call, with a docuEditKeys factory instead of a hardcoded key string — matching useDocumentTemplates and emailIntegrationKeys.
FormDocumentConsole.jsx consumes the hook.

Why

React Query belongs in a hook rather than the page. The hook additionally throws when the response carries no token, so the query surfaces a real error state instead of caching the service's failure envelope as if it were data.

No behaviour change to the token rotation schedule or the iframe handshake.
Fix App

Ignore duplicate offer replay that wedges the agent on Connecting

#3009
Frontend half of the virtual-showing "Connecting… forever" fix. Backend pair: rentsolution-backend#2969.

Symptom

Prospect (phone) shows the agent's video and looks fine. Agent (desktop) shows black + a permanent Connecting… badge. One-way, every time.

Root cause

The server replays the cached prospect offer on every gs:join. Three agent-side components share one socket and each emit gs:joinAgentVoiceCall.jsx:29, LiveTranscript.jsx:19, ProspectActivity.jsx:64 — so the same offer arrives up to 3× mid-call (confirmed in prod logs: 3 joins, same socketId, ms apart).

On each replay handleOffer runs. With identical SDP there is no glaremakingOfferRef is false and signalingState is stable — so offerCollision is false, perfect negotiation (19e71a08c) doesn't intercept, and control reaches the branch that does:

if (existing) { existing.close(); pcRef.current = null; }
pc = createPeerConnection(stream);


That closes a connected peer connection and emits a second answer, which the prospect discards (handleAnswer only applies it when signalingState !== "stable"). The agent is stranded on a fresh pc nobody completes; the prospect keeps rendering media from the orphaned one — hence strictly one-way.

The existing peer-join recovery can't save it either: it only re-offers when the prospect is have-local-offer and not connected, and here the prospect *is* connected.

Change

Track the SDP of the offer we last answered (answeredOfferSdpRef) and ignore a repeat of it while a live pc exists and the call isn…
Fix API

Only replay the cached prospect offer to a newly-joined agent socket

#2969
Fixes the virtual-showing live call wedging on "Connecting…" on the agent side while the prospect sees the agent fine.

Symptom

Prospect (phone) renders the agent's video and looks healthy. Agent (desktop) shows a black remote pane and a permanent Connecting… badge. Strictly one-way — reported by Sri, then reproduced by Max.

Root cause

Three agent-side components share one socket and each emit gs:join:

AgentVoiceCall.jsx:29
LiveTranscript.jsx:19
ProspectActivity.jsx:64

Prod log for session 0959154b, same socketId dKPNiBOA…:

16:17:54.471  Agent joined signaling room
16:18:04.851 Agent joined signaling room
16:18:04.855 Agent joined signaling room


gs:join replays the cached prospect offer to the joining socket. That replay is correct for a genuinely new agent (the visitor offers before anyone is in the room), but here it re-delivers the same offer to a socket already mid-call. The agent's handleOffer then closes its live RTCPeerConnection, rebuilds, and emits a second answer — which the prospect discards, because after the first answer its signalingState is already stable. The agent is left holding a peer connection nobody will ever complete, while the prospect keeps rendering media from the orphaned one.

Perfect negotiation (19e71a08c) does not cover this: a duplicate offer with identical SDP produces no glare (makingOffer false, state stable), so offerCollision is false and control falls straight through to the rebuild branch.

Introduced by 593b529b5
New App

Pass a signed company-context token to the embedded editor

#3007
What

Fetches a short-lived signed company-context token from the backend and hands it to the embedded DocuEdit iframe — on boot via the URL, then rotated over the existing __DOC_EDIT__ postMessage channel.

Why

DocuEdit has no authentication of its own and scopes every query off the context the parent supplies. That context was previously unsigned and could be edited in devtools to read another company's templates and documents, so DocuEdit now requires a signed token from EDGE.

Notes

Rotates a minute before expiry, so the editor never runs on a stale token.
Answers requestCompanyContext so DocuEdit can recover from a token that expires mid-edit instead of failing the user's action.
Holds the iframe unmounted until the first token exists — DocuEdit fails closed without one and would otherwise render an empty editor.
Freezes the iframe src after first render. Rebuilding it on each rotation would remount the editor and discard unsaved work.

Deploy ordering

Depends on backend #2968 — that must merge and deploy first. This PR is inert without the /api/process/company/document-editor-token endpoint. COMPANY_TOKEN_SECRET must also be set to the same value on the backend boxes and on DocuEdit before the DocuEdit side is deployed.
New API

Mint signed company-context token for the embedded editor

#2968
What

Adds GET /api/process/company/document-editor-token, which issues a short-lived (5 min) HS256 token carrying companyId, userId and isPlatformAdmin for the embedded DocuEdit editor to verify server-side on every request.

Why

DocuEdit runs as an iframe inside EDGE and has no authentication of its own — it scopes every query off context supplied by the parent. That context previously arrived as an unsigned value, so it could be edited in devtools to read another company's templates, documents and submissions.

Notes

Signed with a dedicated COMPANY_TOKEN_SECRET, never JWT_SECRET — otherwise a leaked embed token could be replayed as a session token against this API.
companyId comes from req.user, which verifyToken has already resolved (including the super-user x-company-id switch), so a caller cannot request a company they are not in.
Gated on Report module access. Without it, any authenticated user — including owner and realtor portal users — could mint a token granting company-wide access inside DocuEdit.
Fails closed: 503 when the secret is unconfigured, 403 when the caller has no company context, rather than issuing a token DocuEdit cannot verify or one that is unscoped.

Deploy ordering

COMPANY_TOKEN_SECRET must be set to the same value on the backend boxes and on DocuEdit before the DocuEdit side is deployed. This PR is additive and harmless until then — DocuEdit simply ignores the token.

Pairs with the frontend PR that hands the token to the iframe. Backend must land first; the frontend is inert without th…
Update API

Cherry-pick: gcal webhook graceful-degrade on disconnected account to main (NODE-EXPRESS-2E)

#2966
Same fix as #2894 (merged to staging 08-05), never made it to main so prod kept firing NODE-EXPRESS-2E. Clean cherry-pick, no conflicts, same 5/5 tests green against pgtest.
Fix App

Show property photos full width on the public book-showing page

#3005
Fixes Mike's Loom: property photos on the public book-showing page were small, left-aligned, and the "next" arrow sat outside the photo.

https://www.loom.com/share/9d0375b8f665483482fbd6596c56e577

Root cause

ImagePlaylist's default imageClassName is "aspect-video max-h-64".

aspect-ratio: 16/9 combined with max-height: 256px makes the browser transfer the max-height into a max-width (256 x 16/9 = 455px). The slide can therefore never be wider than 455px no matter how wide the card is.

Measured on prod (/book-showing/10a99909-..., 1440px viewport):

carousel / slide: 598px wide
photo: 455px wide -> 143px dead gutter on the right
.control-next arrow at x=991, i.e. pinned to the carousel's right edge, floating in the gutter instead of over the photo
3 photos on the property, but the thumbnail rail is off for this view (showThumbnails={!bookShowingView}) and there were no indicator dots, so nothing signalled that photos 2 and 3 existed

Change

Scoped behind the existing bookShowingView flag so the other 8 PropertyCard call sites (OwnerConnect dashboard/leasing, AdminPortal lead + rental-update) are untouched — they pass undefined and keep the default.

PropertyCard: on the book-showing view pass imageClassName="aspect-video w-full" (drops max-h-64, so no max-width transfer) and turn on indicator dots when there is more than one photo
ImagePlaylist: new showIndicators prop, defaulting to false
v2026.08.10

Monday, August 10, 2026

4 new 6 fixes 10 total
New App

Vendor compliance workflow UI and AI auto-verification

#3002
Cherry-picks #2958 from staging to main: vendor compliance workflow UI and AI auto-verification.
New API

AppFolio 2-way vendor sync + vendor compliance workflow and AI document auto-verification

#2961
Cherry-picks #2904 and #2905 from staging to main so the AppFolio 2-way vendor sync and the vendor compliance workflow / AI document auto-verification ship to production.
Fix API

Cross-company leakage hotfix (grow read scoping, vitals kpi, routine tasks, staff login)

#2946
Hotfix for the cross-company leakage Sri raised in #eng-general. Minimal, surgical -- no schema change, no refactor. Targets main (prod) directly because two of these are exploitable today by any logged-in staff user.

What was actually exploitable (proven on the dev box, not just read in code)

Attacker = an active internal staff user at Ellis HomeSource. Victim = Rent Solutions data. Both real companies on the dev RDS.

Before the fix:

| attack | result |
|---|---|
| Ellis staff overwrites a Rent Solutions KPI value | 200 Manual override set successfully |
| Ellis staff PATCHes a Rent Solutions routine task | 200 Routine task updated successfully |

The KPI write landed in the DB and I read it back -- manualValue=99999 on a KPI owned by 513c1a07 (Rent Solutions), written by a user whose companyId is 3c2fb797 (Ellis). Row cleaned up afterwards.

After the fix, both return 404, and same-company use still works.

The three bugs

1. vitalKPIValueService.setManualOverride / removeManualOverride -- VitalKPI.findByPk(kpiId) with no company scope. Route is verifyToken only, no ability check at all. Any authenticated user could overwrite or clear any company's KPI numbers by id.
2. routineTaskService.updateRoutineTask / deleteRoutineTask -- RoutineTask.findByPk(id) with no company scope. The controller checks the *ability* (canPerformAction) but never that the object belongs to the caller's company. Read paths (getAllRoutineTasks, getRoutineTaskById) were already scoped -- only the write paths were missing it.
3. POST /api/us…
Fix App

Manage Meeting page named the wrong scheduler

#3001
What

A guest who booked through Lynnette's link opened the Manage Meeting page from their
confirmation email and saw "With Mike Arias".

The booking itself is correct — the prod appointment carries
scheduler: { nameFirst: "Lynnette", nameLast: "Remers" }. The page threw that away and
re-resolved the scheduler with useSchedulerInfo(appointment.companySlug). companySlug is the
company slug, not the booking slug the guest used, so _resolveSchedulerBySlug fell through
to its third branch and returned the company-wide isAppointmentScheduler.

Every booking made through an individual's or a department's link named the wrong person.

Change

Read the scheduler and the company off the appointment payload, which already includes both, and
drop the redundant public lookup (one fewer request per page load).

Proof

Same stubbed API response in both runs — appointment scheduler Lynnette, slug lookup returning
Mike, exactly the prod shape:

| | result |
|---|---|
| before | showsMike: true, showsLynnette: false |
| after | showsLynnette: true, showsMike: false, company name still renders |

Rendered locally against the real prod payload shape; before/after screenshots in manage-meeting-scheduler-proof.html.
Fix API

Appointment emails rendering raw HTML + booked owner leads land in Meeting Scheduled

#2960
What

Two prod bugs in the owner-appointment flow, plus the missing owner-lead follow-through.

1. Confirmation / reminder emails shipped literal HTML to guests

_replacePlaceholders HTML-escapes every merge value whose key does not end in Html
(communicationGatewayService.js, and identically in templateNotificationService.js).
appointmentDescription, locationRow and meetingLinkRow are pre-built HTML blocks, so
every guest confirmation rendered

<div style="font-size: 14px; color: #4a5568; margin-top: 12px;">Want to learn more…</div>


as visible text. Location and Meeting Link rows were broken the same way — they just happen to
be empty for virtual appointments, which is why only the description was reported.

Renamed the three keys to appointmentDescriptionHtml / locationRowHtml / meetingLinkRowHtml
in the two senders, the reminder job, and the three affected templates.

> Note for review: a company that has overridden emailHtml for these templates keeps its
> old {{locationRow}} tokens and will render them empty rather than escaped. Worth a check for
> override rows on these three template ids before/after deploy.

2. A booked call left the owner lead untouched

bookAppointment created the appointment, calendar event and emails but never advanced the lead.
It now moves the linked lead's process to the Meeting Scheduled stage and opens a meet
task due at the appointment start, assigned to the member the call was booked with.

Best-effort and non-blocking (setImmediate + try/catch): a company whose ownerLead template has
no such stage …
Fix API

ResolveLeaseFee was mistaking Appfolio's placeholder $0 for a real fee

#2957
Found while verifying #2950's rollout for Steve/Saket: Appfolio always sends both lease_flat_fee and lease_fee_percent, zeroing out whichever doesn't apply rather than leaving it null. The old presence check treated every Percent-type property's $0 placeholder flat fee as real — verified against the real staging data: 1291 of 1293 fee-configured properties on Rent Solutions would have synced $0 fixed instead of their actual percentage. leaseFeeType is now the authoritative signal. No prod/staging data was actually corrupted — the leasingFeeUnit migration hasn't been applied yet, so the sync's writes to that column were failing silently rather than landing wrong values.
Fix App

Widen Application Link field on rental settings

#2993
Cherry-pick of #2986 (already merged to staging) onto main — same changes, prod release. Application Link fills the full column width, Leasing Fee gets a $/% toggle, Service Level moved to the Application Fee row.
New API

Sync Appfolio lease flat fee onto each listing's Leasing Fee

#2955
Cherry-pick of #2950 (already merged to staging) onto main — same changes, prod release. leasingFeeUnit migration+model+allowedFields+relist carry-forward, propagateLeasingFeeFromAppfolio resolves flat-vs-percent per property.
New API

Persist website source attribution on public realtor registration

#2952
What

Accept and persist website source attribution (metadataJson.website) on the public realtor registration endpoint, POST /api/public/realtors.

Why

The RS website attaches first-touch source metadata — utm_*, gclid/fbclid, landing URL, referrer, page/form name — to its public form submissions and forwards it to EDGE as metadataJson.website. The CMA, book-showing and owner-lead paths already accept and store it. Realtor registration is the only one that never did: publicRealtorValidator.createSchema is a plain Joi.object(), so the extra key was rejected outright with "metadataJson" is not allowed and the whole registration 400'd.

That means realtors registering through Show a Rental currently land with no origin data at all, and the website had to stop sending the field to unblock sign-ups. This change closes the gap so the website can send it again.

Changes

validators/publicRealtorValidator.js — accept an optional metadataJson object.
controllers/listings/publicRealtorsController.js — run metadataJson.website through the shared normalizeSourceMeta (whitelists fields, caps text/URL lengths, rejects payloads over 4KB) and pass it to the service as websiteSource.
services/realtorPortal/publicRealtorService.js
- new advocate: store { website: }, or null when the submit carries none.
- existing advocate (phone dedupe): backfill only — write attribution when the advocate has none, never overwrite one already stored. The stored value is the realtor's first touch; a later submit carries whatever page they happened to land…
Fix API

Response cache serves before auth — forged token reads cached data

#2948
The response cache sits in front of authentication, so any cached endpoint can be read with a forged token. One file, 45 lines changed, no schema change.

The bug

middleware/cacheMiddleware.js is mounted globally at app.js:310; routes and their verifyToken mount from line 499. On a cache hit the middleware does:

return res.status(cached.status || 200).json(cached.body);


It returns without calling next(), so verifyToken never runs. The cache key was derived from jwt.decode() — which parses a token without checking the signature. The existing comment says so outright: *"Decode the JWT (without signature verification — only used to scope the cache key)"*. That assumption held only while the middleware never *served* anything, and it does serve.

x-company-id was also honoured for any caller, letting an attacker steer the company half of someone else's key with no super-user check.

Proof (dev box, /api/roles, same request four ways)

| step | before this PR | after |
|---|---|---|
| forged-signature token, cache COLD | 401 TOKEN_INVALID | 401 |
| valid token (populates the cache) | 200, 9605 bytes, 21 roles | 200, 21 roles |
| forged-signature token, cache WARM | 200, 9605 bytes, 21 roles | 401 |
| garbage non-JWT, cache WARM | 401 | 401 |

The fourth row is the control: a non-decodable token always failed, which proves the bypass ran specifically through the decoded-key path rather than the cache serving everyone indiscriminately.

The attacker needs only the victim's userId and companyId — plain …
v2026.08.07

Friday, August 7, 2026

1 new 8 fixes 11 total
Fix App

Align EDGE Phone header controls in Comms Hub

#2976
Problem

The EDGE Phone in the Comms Hub and the floating EDGE Phone panel looked inconsistent in the upper-right corner. The floating panel shows Home + close (X); the Comms Hub showed Home with a second control colliding with it.

Both surfaces already render the *same* component (). The mismatch was not in the panel: CommHub2 wrapped the panel in a relative container and layered its own raw
Fix App

List counts describe the list, and paging is reachable again

#2974
Found while auditing the rest of the app for the bug class behind Kristina's "leads disappeared" — a number that does not describe the thing it sits above.

Three faults, all on the advocacy-program tabs

1. The header count was the current page. Five paginated tabs — Advocates, Playbooks, Courses, Articles, Assignments — took their count from data.length, the rows of the page. Every one capped at the page size: a program with 200 advocates read 25. The API has returned pagination.totalCount all along.

2. Paging was unreachable, in two different ways. Advocates and Playbooks checked pagination.pages, which this API does not return — it returns totalPages — so their control was permanently hidden. Courses, Articles and Assignments had no Pagination control at all *and* held currentPage in a useMemo frozen at 1; they request 25 rows and there was no way to see the 26th. Both fixed. Combined with a count stuck at the page size, a long list looked complete.

3. The Advocates toteboard had fault 1 a level deeper, tallying stages from those same 25 rows. It now reads stageCounts from the server, computed over the whole filtered set (backend companion below).

Playbooks also showed seven permanent zeros �� now wired up

Not Assigned and 1-Prospect6-Producer were hardcoded 0. A bucket that always reads 0 cannot be told apart from real data that is 0, which is the same failure as a count that does not match its list.

They now read the program's advocates: by stage, and how many have no playbook yet. Staging, Realtors program — 7 advocates, stages summing to 7, 7 not yet assigned a playbook. Needs rentsolutions-app/rentsolution-backend#2943, which supplies a…
New API

Advocate stage + unassigned counts for the Playbooks toteboard

#2943
The Playbooks toteboard under an advocacy program shows Not Assigned and 1-Prospect through 6-Producer. Nothing supplied those, so the frontend had them hardcoded to 0 — and a bucket that always reads 0 cannot be told apart from real data that is 0.

getAdvocacyProgramCounts now returns:

advocateStages — the program's advocates tallied by their process stage name
advocatesWithoutPlaybook — the ones with no playbook assignment of their own

Verified on staging

Realtors program: advocates=7, stage tally sums to 7 (5 no-stage, 2 in a stage), advocatesWithoutPlaybook=7. The sum matching the advocate count is the invariant that was missing — the buckets now describe the same population as the number beside them.

Frontend companion: rentsolutions-app/rentsolution-frontend#2974. Merge this first, or those buckets read 0 until it lands.
Fix API

Count advocate stages over the whole list, not the page

#2942
Found while auditing the rest of the app for the bug class behind Kristina's "leads disappeared" — a number that does not describe the thing it sits above.

The Advocates toteboard under an advocacy program tallied stages on the client, from the 25 rows of the current page. A program with 200 advocates showed a Count of 25 and stage buckets that could never sum past one page — the same shape as the Clockwork board reading List=270 with every bucket empty.

The tally now happens next to the existing type tally, over filteredAdvocates — the full filtered set the service already holds in memory before it paginates. No extra query, and it cannot describe a different population than the count beside it. Returned as stageCounts, keyed by lowercased stage name so the frontend maps it through the mapping it already has.

Verified on staging

Realtors program, forced to limit=3 so the page and the total differ:

rows=3  totalCount=7  totalPages=3  stageCounts sum=7   MATCHES


Before, the header read 3 and the buckets summed to 3.

Frontend companion: rentsolutions-app/rentsolution-frontend#2974 — it also fixes four sibling tabs with the same page-length count, and a pagination control that never rendered because the FE read pagination.pages while this API returns totalPages.
Update API

Revert: drop the automatic company logo on DocuEdit envelopes

#2941
Reverts #2930 and #2932. Restores contractService.js and docuEditAPIService.js to their pre-#2930 state and removes the test that came with them.

Why

The approach is being replaced. Instead of the platform pushing the company's logoUrl onto every envelope automatically, DocuEdit is gaining a logo element in the document template editor — the user uploads whatever image they want and picks its alignment (left of the title, right of the title, or above it). That makes the logo per-template and art-directed, rather than per-company and forced.

What prompted it

Testing against real data on staging surfaced the flaw in the automatic approach. Rent Solutions' logoUrl is a pure-white SVG — all 43 paths are fill:#ffffff on a transparent background — because that field is currently consumed on dark surfaces (admin header, login page). Rendered onto a white contract it is completely invisible: flattening it onto white produces an 887-byte blank PNG, versus 22KB of real content on a dark ground.

That is not a one-off. Any company whose stored logo is a knockout/white variant would have silently shipped contracts with an empty letterhead. Letting the template author upload and place the image they actually want avoids the whole class of problem.

Scope

Only the two logo commits ever touched these files, so this is a clean restore with no unrelated work caught in it. Nothing else referenced senderLogoUrl; DocuEdit ignores the field's absence exactly as it did before #2930.
Fix API

One definition per lead metric, and the rentals list works again

#2939
Kristina, 8/7:

> For example, Ive had 19 leads in EDGE for Holiday Lakes and it says 9 all of a sudden. I had over 39 for Pine Isle... 3 for Dobson Dr...

Nothing was deleted. #2926 changed the rentals-list Leads badge from *every lead ever* to *leads still in play*, under a label that still read "Total Leads". On 3628 Holiday Lake all 19 leads are status active; 10 sit in canceled process stages, so only a stage-aware filter can tell them apart. #2938 reverted all of it, which put the original cross-surface disagreement back.

This lands the fix with the piece that was missing: two named populations, and every surface says which one it shows.

| | definition | used by |
|---|---|---|
| total | everything except archived (soft-delete) | anything labelled "Total Leads": rentals card, Leads tab, gauges, owner portal tile, the Total Leads sort, 7-day intake, conversion denominator |
| live | also drops inactive/declined/unqualified and canceled-stage leads | the Leads list rows, past-due, and the console New / Due Today / Past Due worklists — an archived lead is not work anyone can do |

Kristina's card numbers go back to what they were. The badge kept its "Total Leads" label, so it keeps counting the total; the live population rides alongside as live for the surfaces that need it.

liveLeadSql / totalLeadSql / canceledStageExclusionSql in constants/leasingEnums.js. Every count goes through them, so a number cannot mean something different depending on the screen.

Also fixed (all three were re-broken by the revert)

Rentals list timed out past ~page 2. OFFSET does not avoid a per-row SELECT-list SubPlan — Postgres evaluates it for every row the Limit node pulls, including the ones OFFSET discards. Pages an id-only query f…
Fix App

Say which leads the number counts

#2973
Kristina, 8/7 — "Something happened in EDGE tonight...leads disappeared":

> Ive had 19 leads in EDGE for Holiday Lakes and it says 9 all of a sudden. I had over 39 for Pine Isle... 3 for Dobson Dr...

Nothing was deleted. The badge had changed from *every lead ever* to *leads still in play*, under a label that still read "Total Leads". A number that changes meaning without changing its label reads as data loss, which is why this got reverted rather than explained.

The badge goes back to counting the total, so Kristina's numbers are the ones she had. The label stays "Total Leads" because that is what it now is. What was never said — and is the actual source of the confusion — is now in the tooltip and the help panel: the Leads list defaults to the leads still in play, so it can show fewer rows than the badge counts, and Show Inactives reveals the rest.

Leads badge tooltip: "Total Leads — every lead this property has received"
7-day badge: "New Leads Last 7 Days"
Showings / Applications: "Total Showings — every showing held" / "Total Applications — including any later declined"
Help panel: "Every lead the property has received. The Leads list shows the ones still in play by default, so it can be a smaller number — turn on Show Inactives there to see them all."

The Lead Pipeline tab keeps no count of its own — it came from the cached property GET, so it could never follow the list's own Show Inactives toggle, and one of the two numbers on screen was always wrong. The list header is the same query as the rows.

Verified in the UI on staging

3628 Holiday Lake, default view: 0 rows, tab shows no contradicting count. Show Inactives on: 8 results — every lead the property has ever ha…
Update API

Revert: return the leasing count surfaces to their pre-#2926 state

#2938
Reverts the whole counts series — #2926, #2935, #2936, #2937 — putting every leasing count surface back exactly as it was before the Alexandra PR.
Fix API

Un-collapse the Total Leads sort; unify the Show Inactives reading

#2937
One real bug — sorting the rentals list by Total Leads returned one property instead of 25 — plus a hardening pass that gives the Show Inactives toggle a single reading across all seven surfaces.
Fix API

Align the Leads tab with its badge, keep Show Inactives whole, and fix the rentals-list timeout

#2936
Three fixes found by verifying #2935 against production: the Leads tab now lists the population its badge counts, Show Inactives still reaches every lead, and the rentals list no longer statement-times-out past page 2.
Fix API

Make the 7-day counts describe the same population as their totals

#2935
The rental-list tile's 7-day counts now differ from their totals by the date window and nothing else, instead of filtering a different population.
v2026.08.06

Thursday, August 6, 2026

1 new 7 fixes 8 total
Fix API

Don't fail a contract send when the company logo lookup does

#2932
Follow-up to #2930, which merged before the self-review pass finished. Four findings from that pass, all in the code #2930 introduced.

1. A DB blip aborted the send *after* the DocuEdit document was created

The Company.findByPk lookup sat between createDocument (a non-idempotent external write) and createEdgeDocument, unguarded. Any rejection propagated to the outer catch and became a 500 — leaving an orphaned DocuEdit document, the contract stuck in draft with no edgeDocumentId, and the agent looking at a generic failure. The same-day retry then hits the deterministic documentName and DocuEdit's name-dedupe path that the existing comment right above it warns about.

All to fetch a decorative logo. Connection-pool blips are a live incident class here.

Fixed two ways: the lookup moved before createDocument so a failure has no side effect at all, and it's wrapped in try/catch that degrades to the text-only header with a logger.warn — matching the precedent directly above it, where the equally optional template fetch does the same.

2. A blank logoUrl got baked into a countersigned PDF

companyValidator allows '' with no .uri() and no trim, and the column is plain nullable TEXT. So " " cleared the truthiness gate and shipped in the envelope. Existing consumers (owner portal, video viewer) tolerate this because their pages re-render on every load — an executed PDF is immutable. Now .trim()'d before the gate.

3. A comment describing code that isn't there

// '= {}' only defaults on undefined — an explicit null would TypeError — the implementation is sender || {}, which handles null fine, so the described failure can't occur. A rea…
New API

White-label company logo in DocuEdit document headers

#2930
What

Passes the sending company's logoUrl to DocuEdit on envelope creation, so contracts/documents render that company's logo in the document header instead of relying on the template name text alone.

Today the header of every sent contract is just the DocuEdit template's own name, uppercased and centered (e.g. CLOCKWORK PM - LEASE ONLY AGREEMENT). Per-company branding is achieved by cloning the whole template per company with a different name. This lands the platform half of a proper white-label letterhead: logo left, title centered on the same band, first page only.

Change

contractService.sendContract fetches Company.logoUrl (scoped to contract.companyId, the same id the 403 guard above it pins) and passes it as senderLogoUrl on the POST /edge/documents envelope.
docuEditAPIService.createEdgeDocument gains an optional sender param and only serialises senderLogoUrl when present.

Deploy safety

Backward compatible in both directions and in either deploy order:

A DocuEdit build that predates the column ignores the unknown field.
A company with no logo (logoUrl is nullable and has no backfill) sends a byte-identical payload to today — no key is added at all — so DocuEdit falls back to the current text-only header.
A missing company row degrades to no logo rather than throwing.

DocuEdit side

The rendering half ships separately in the DocuEdit app (Replit): persist senderLogoUrl on edge_documents, render the logo/title band in the compiled HTML + signing page, and draw it on page 1 of the PDF. Note for w…
Fix API

Match the ->> index expression for metadata.parentType/parentId (NODE-EXPRESS-59)

#2927
#2882's two-step fix didn't cover this path — the process-scoped extraOr clause builds metadata.parentType/parentId as a dotted JSONB access, which Sequelize renders as #>> at query time, but the supporting index from the 07-27 migration is built on ->>. Planner falls back to a seq scan (proven via prod EXPLAIN: ~10500 vs ~12 with the matching operator). Fix makes the where-clause literal match the index's operator. 14/14 tests passing (pgtest mocha), rebased clean on main.
Fix App

Stop the Leads tab showing a count the list contradicts

#2966
Alexandra, 8/6, on 10310 Lakeside Vista Dr:

> Leads in Leads list: 121
> Leads in owner updates: 88
> Unrelatedly, as I analyze this, I'm noticing inaccuracies in the pipeline. List is showing more leads than are in the pipeline

The 121 was the Leads tab count, not the rows. It came from the cached property GET, so it could never follow the list's own Show Inactives toggle — with inactives off it read high, and filtering it would have made it read low the moment the toggle went on. Whichever way it went, one of the two numbers on screen was wrong.

Dropped it. The list header count is the same query as the rows, so it agrees in both states. The past-due red highlight stays.

Backend companion: rentsolutions-app/rentsolution-backend#2926 — that one makes the underlying counts agree. Either can merge first; neither breaks without the other.

Also in here

TENANT_LEAD_LIVE_STATUSES replaces the hand-listed STATUSES_EXCEPT_INACTIVE, which named a pending status that is not in TENANT_LEAD_STATUS at all and omitted needs_approval. No behaviour changegetAllTenantLeads reads active as a status *type* and re-expands it to the full active set, which is what kept the old list correct by accident. The constant now says what it means and matches the backend's derived list.
needs_approval added to TENANT_LEAD_STATUS_DISPLAY — it had no label and no filter option anywhere in the FE.
Gauge copy now states what the tiles actually count: leads exclude the ones that are over, applications include ones later declined (filtering applications would skew showConversionRate, whose denominator is unfiltered showings).
<…
Fix API

Count leads, showings and applications as one population

#2926
Alexandra, 8/6, on 10310 Lakeside Vista Dr:

> Leads in Leads list: 121
> Leads in owner updates: 88
>
> Applications in pipeline: 0
> applications in owner updates: 1

Every leasing number was scoped to a different population:

| surface | scoped by |
|---|---|
| Leads tab badge | nothing — every lead ever |
| stats tiles (owner update, owner portal, admin) | dead statuses only |
| Leads list | dead statuses and canceled process stage |
| showings / applications | not scoped to leads at all |

So a card row could drop a declined lead from Leads while still reporting its application — Alexandra's 0-vs-1 exactly. On 16800 Parsonage Lake the tab read 154, the stats read 119, the list showed 117, and the Apps tab read 14 against a tile of 13.

What changed

LIVE_TENANT_LEAD_STATUSES (derived from TENANT_LEAD_STATUS, not hand-listed) plus canceledStageExclusionSql / liveLeadSql / liveLeadOrUnlinkedSql. Every property-performance count goes through them:

lead + apps tab badges and the past-due sub-count
rental-list lead / showing / application columns and the lead sort
Low 7 Day Leads bucket and filter
the stats query behind the owner update, including its daily breakdowns
the three owner-portal services (they ran their own counts, dead-status filtered but stage-blind)
leasing console New / Due Today / Past Due — these were counting archived and declined leads as work due

Showings with no tenantLeadId are kept. An unlinked showing still happened.

Intake and attribution counts are deliberately untouched — different question, and filtering them would make them wrong:
Fix App

RecordingPlayer shows unavailable state on fetch failure

#2965
companion to backend #2924 (Twilio recording 404 fix). RecordingPlayer had no error state - a failed fetch just hung on 'Loading recording...' forever. Adds loading/success/error status; failure now shows 'Recording unavailable'.
Fix API

404 (not 500) when Twilio recording media is gone

#2924
Sentry NODE-EXPRESS-69: getCallRecording threw APIError(500) whenever the Twilio media .mp3 404d, even though the metadata .json fetch succeeded fine. That case (recording deleted/expired on Twilio side) is a not-found, not a server fault - now returns 404 and stops tripping Sentry (which only alerts on >=500) as a fake outage.

Separately flagging: RecordingPlayer.jsx on the frontend has no error branch at all - a failed fetch just leaves it stuck on "Loading recording..." forever with no "unavailable" message. Following up with that fix next.
Fix API

Let an owner-lead assignee view the lead they're assigned to

#2903
ticket 02034e79 — Leasing Agent/PM roles at grow=no_access got 403 opening an owner lead they were just assigned to. getOwnerLeadById only checked type-level grow module access, so an assignment email/link 403'd the person it was sent to (9 assignees affected on RS's current data).

Fix is scoped, not a company-wide grant:
growAbilities.js: new CASL read rule conditioned on companyId + one of assignedToUserId/leasingAgentId/leasingManagerId/propertyManagerId/assistantPMId matching the user. Fired via 5 separate can() calls (one per field) — this codebase's CASL matcher doesn't support $or for instance-level checks (verified in isolation), only implicit top-level AND.
03-businessLogicLayer.js: applyGrowAbilities is now called unconditionally instead of gated on grow!=no_access, since it needs to run for no_access users too. applyOwnerAppointmentAbilities keeps the original gate.
ownerLeadController.js getOwnerLeadById: users without list-level grow read (fast path) now fall back to an instance-level check against the fetched record.

Tests: tests/abilities/growOwnerLeadAssigneeRead.test.js (new, 5 cases incl. cross-company and non-assignee negative cases) + existing owner-lead unit tests, all green locally.
v2026.08.05

Wednesday, August 5, 2026

2 new 5 fixes 7 total
New App

Add Create Lease in AppFolio task action

#2962
Requested by Steve in the leasing meeting, relayed by Srik (Loom). Frontend half of rentsolutions-app/rentsolution-backend#2907 — merge the backend PR first, the button depends on the new redirectUrl it returns.

What this adds

1. "Create Lease in AppFolio" as a third option in the AppFolio task-action dropdown in the process template editor.
2. A "Create Lease in AppFolio" button on the running task, which opens the company's AppFolio rental applications filtered to the process's property in a new tab. The task stays open — the user creates the lease in AppFolio, then completes the task themselves.

Notes for review

The tab is pre-opened synchronously on the click. Opening it after the await consumes the user-gesture token and Safari/Firefox block the popup. useOpenAppfolioLease pre-opens with window.open("about:blank", "_blank") (the pattern already used by IdVerificationStep), then hands it to the existing openInNewTab from @utils/windowUtils, and safeCloseWindows it if the request fails so no blank tab is left behind. Note noopener here would make window.open return null and cause a duplicate tab — the util's own fallbacks cover that case.
Wired in all five TodoList/Tasks consumers, not just ProcessDetail. isCreateLeaseTask lives in the shared TodoItem, so a createLease task surfacing in LeadDetail / OwnerLeadDetail / RealtorDetail / FilesPanel would otherwise render a button that silently did nothing. The button is also hidden when no handler is passed, so a future consumer degrades to "no button" rather than "dead button".
No new dire…
New API

Add Create Lease in AppFolio task action

#2907
Requested by Steve in the leasing meeting, relayed by Srik (Loom). Stages and tasks are moving from the Move-In process into Property Marketing, and the "Create the Lease in Appfolio" task in the Property Leased - Create Move in stage needs an action that today does not exist.

What this adds

A third AppFolio task action, createLease, alongside createOwner and createProperty. Unlike those two it makes no AppFolio API call — it returns a redirectUrl so the user lands on their company's AppFolio rental applications, filtered to the process's property:

https://{company.appfolioReportApiUrl}.appfolio.com/rental_applications?filters%5Bprefixed_property_unit_and_list_ids%5D=p_{property.appfolioNumericId}


The subdomain is per company (rentsolutionsfl for Rent Solutions, NestFinders gets its own), as requested.

The task is deliberately not marked done — the user creates the lease in AppFolio and then completes the task themselves.

Notes for review

Runs before getAppfolioCredentials. A company can have an AppFolio subdomain without working Data API credentials, and this action needs none — failing on a credentials lookup for a pure URL build would be wrong.
appfolioNumericId falls back to appfolioLink. The createProperty branch persists appfolioId + appfolioLink but not appfolioNumericId, which the nightly sync back-fills. Without the fallback, a createLease task would hard-fail on exactly the property the sibling task just pushed. Derived with the existing extractAppfolioNumericId.
manualTask is forc…
Fix API

Extend the dead-lead filter to every remaining lead-count surface

#2910
Closes #2909.

> #2906 has merged; this is retargeted to main and rebased down to a single commit (5 files). No longer stacked.

Why

#2906 shipped the dead-lead filter for the owner update stats only, per the product call on that PR. That left a real, owner-visible gap it deliberately pinned: the same owner sees 22 leads in their emailed update and 42 on their portal dashboard the same minute (16800 Parsonage Lake). This closes it.

What's filtered now

ownerLeasingDashboardService.buildPropertyLeadsComponent — all three counts (total / recent / previous period)
ownerPortalService — property-list tenantLeadCount / tenantLeadCount7d
leasingPropertyService admin list badge — same two literals
The two remaining calculateLeasingPropertyStats callers (owner portal stats, admin stats endpoint) now pass excludeDeadLeads, so all four call sites agree

Conversion rates moved with their denominators

Three sites, each needing its numerator restricted to the same population — otherwise the rate passes 100% the moment a converted lead is marked inactive:

| Site | Numerator |
|---|---|
| stats service | conversionShowings (from #2906) |
| admin list | leadApplicationCount |
| owner portal list | leadShowingCount |

The Showings and Applications columns keep reporting every row; only the ratios use the narrowed population.

Decided and documented, not changed

getTabCounts stays unfiltered. It labels the Leads tab, and that list renders every lead regardless of status — v…
Fix App

Await the post-send refetch so the empty-send confirm can't be skipped

#2960
Follow-up to #2959 (merged). Review of that PR found the confirm it added can be skipped in a race, so the blank owner update it was written to prevent can still go out.

The hole

useSendOwnerUpdate.onSuccess called queryClient.invalidateQueries(...) without returning the promise, so the mutation resolved before the pending-update refetch landed. isSaving went false, the Send button re-enabled, and a click in that window read displayUpdate from a cache still holding the just-sent content — hasContent true, no confirm, and the server (which had already cleared the pending content) sent the owner an empty update.

The window is exactly the one agents click in: the send fans out email + SMS, and this screen shows no inline success, so a slow send invites a second click.

The fix

Return the invalidations from onSuccess so the mutation stays pending until the refetch lands, which keeps the existing isSaving disable covering the whole window. One-line shape change, no new state.

Measured, not assumed

Driven on the real Updates tab with the send stubbed client-side (nothing delivered to any owner) and the pending-update refetch delayed 2.5s, sampling the button across the window:

| | 200ms | 700ms | 1400ms | 2100ms | 3600ms |
|---|---|---|---|---|---|
| before (main) | enabled | enabled | enabled | enabled | enabled |
| after | disabled | disabled | disabled | disabled | enabled |

Same harness both runs; only the hook differed.

Also in here

Confirm copy now says "no saved message" and spells out that unsaved text won't be sent. Text typed into the composer but not saved is…
Fix API

Stop duplicate owner-update sends emailing blank updates, and match the email header to the viewer

#2906
Owners reported receiving blank owner updates (Kristina, 8/5). Confirmed against prod.

What was happening

8 of the 23 owner updates sent on the new rail have no text and no video. 6 of those 8 have a *real* update sent to the same property seconds earlier — 1.5s apart on 3628 Holiday Lake, 1.6s on 18686 Dobson, both on 8/5. So the owner got a genuine update immediately followed by a second one containing only the header, stat tiles and a "View Your Update" button.

sendUpdate clears pendingOwnerUpdateJson's content on success and re-seeds it with next week's dateDue. The only send guard is "does dateDue exist", so a second request that read the pending JSON before the first committed creates a second row — after the content has been cleared.

Prod census for reference (SELECT-only): tokenHash IS NULL AND dateSent IS NOT NULL = 0 and pending backstop candidates = 0, so the reconcile sweep is not involved; videoMessages is clean.

Fix 1 — duplicate send (ownerUpdateService.sendUpdate)

Serialize on the leasing property row inside the send transaction (FOR UPDATE) and compare-and-swap the pendingOwnerUpdateJson draft this send is consuming. The racer waits for the winner's commit, finds the draft replaced by the re-seeded next-week one, and gets a 409. A deliberate later send read that re-seeded draft itself, so it matches and still goes through.

Deliberately not keyed on latestOwnerUpdateSentDate: that column is a derived cache which ownerUpdateModel.afterUpdate recomputes from status: 'sent' rows only, and determineStatus returns 'sent-late' for any send past its due date — so a late send reverts the stamp and the guard would silently become a …
Fix App

Confirm before sending an owner update with no message or video

#2959
Owners reported receiving blank owner updates (Kristina, 8/5). Prod data shows 8 contentless sends on the new rail; 6 of them landed within seconds of a real update to the same property (1.5s and 1.6s apart on 8/5).

Backend counterpart (the actual race fix): rentsolutions-app/rentsolution-backend#2906

Why the button lets this happen

The send re-seeds the pending update with next week's dateDue and no content, so Send Owner Update re-arms the instant a send succeeds — it looks exactly like it did before the click. Clicking again mails and texts the owner an update with nothing written in it. The in-flight disable already on main doesn't help: by then the first request has finished and the button is legitimately enabled again.

The change

Confirm when the pending update has no updateText, no updateVideo and no originalVideoKey:

> Send an update with no message or video?
> This update has no message and no video. The owner will get an email and a text containing only the property stats.
> [Send anyway] [Cancel]

Deliberately a confirmation and not a block — contentless updates are a real shape (61 of the pre-existing owner updates on prod have no text and no video; they were portal-only and never emailed). This only stops it happening by accident.

Uses useConfirm() from ConfirmContext (destructive: false), the dominant pattern in the repo (73 call sites) — no new modal component.

Verification (real UI, staging API)

Empty update → dialog appears. Driven with Playwright on the actual Updates tab; cancelled, nothing sent.
Update with content → no dialog, send proceeds. Ver…
Fix App

Company-switch dropdown behind profile-menu header

#2953
Superadmin/Steve accounts: opening the profile menu -> clicking the current company to switch companies opened a list that rendered behind the profile panel, unclickable (reported via Sri's Loom in this thread).

Root cause: #2947 lifted the header to z-[1001] while the profile menu is open, to clear the EDGE Phone/CallQ panels (z-[1000]). The company-switcher's Dropdown renders its options list through a portal to document.body, hardcoded at zIndex: 1000 — one below the header, so it stayed trapped underneath.

Fix: added an opt-in menuZIndex prop to Dropdown (default unchanged at 1000, no effect on any other usage), set to 1010 on this one instance — clears the header, stays well under the modal/toast tier (z-[9999]+).

Verified the exact stacking mechanism with a minimal repro (same z-index values): before the fix a click at the overlap region resolves to the header; after, it resolves to the dropdown.
v2026.08.04

Tuesday, August 4, 2026

2 new 1 fix 4 total
Fix App

Show profile menu above the EDGE Phone panel

#2947
Problem
Clicking the profile picture while the EDGE Phone panel is open made the profile dropdown open behind the panel.

Cause
The admin top header (NavigationMenu.jsx) is position: fixed with z-40, so it forms its own stacking context. The ProfileMenu dropdown lives inside that header, while the EDGE Phone panel (WebphonePanel.jsx) is a sibling at z-[1000]. Because 1000 > 40, the panel always paints over anything inside the header — a child z-index can't escape the header's context.

Fix
Make the header's z-index conditional: z-40 normally, z-[1001] only while the profile menu is open. That lifts the whole header (and its dropdown) just above the phone panel, while staying far below modals/toasts (z-[9999]+). The header and panel don't overlap vertically (the panel starts below the nav), so nothing else is affected. Connect portals use a different header and have no EDGE Phone, so no change is needed there.
New App

Per-group select-all in task list reschedule mode

#2943
Steve's request (Loom): in Task List reschedule mode ("change dates"), each date-group divider header turns its calendar icon into a Select All checkbox so a group's tasks can be picked in bulk.

What it does
In reschedule mode each divider ("Due in the past", "Due today", …) swaps its calendar icon for a Select All checkbox that toggles only that group's reschedulable tasks
Indeterminate state when a group is partially selected
Non-editable tasks (done/rejected) excluded, matching the row-checkbox rule
Outside reschedule mode the divider looks exactly as before (calendar icon)

Page + group-header component + hook only; no shared-component changes.

Verified end-to-end in the running app against real data: selecting "Due today" checks only its 3 tasks while "Due tomorrow"/"Due in 7 days"/past stay unchecked.

![proof](https://ai.edge.rent/proofs/tasklist-groupselect-swap-1785859171.png)

Based on main since Steve is waiting to see it on prod.
Update App

Release: EDGE Phone iPhone springboard → production (#2939 + #2941)

#2942
Promotes the EDGE Phone iPhone-style springboard to production. Cherry-picks two already-merged staging changes onto main, with no other staging work included:

#2939 — reframe the EDGE Phone panel as an iPhone springboard: Home/lock screen (clock + notification cards), dock + app grid, Phone / Messages / Mail / Video apps, and bottom search + compose bars.
#2941 — fix: prevent EDGE Phone list rows from squishing when a list overflows (flex-shrink-0 on the email/message/video list rows; EmailRow's overflow-hidden was collapsing the flex rows instead of scrolling).

Clean cherry-pick — 20 files, +2594/−112.
New App

PostHog usage tracking identified by user email and company

#2938
What

Adds PostHog product analytics to EDGE so we can see how much each user and company is actually using the platform. Uses the same PostHog project as the rentsolutions.com marketing site (same phc_ key, us.i.posthog.com), so marketing and product analytics live in one place.

How it works

Init in src/config/posthog.js, called from main.jsx next to Sentry/Clarity. Enabled only in production/staging modes (same gating pattern as Sentry), overridable via VITE_POSTHOG_KEY / VITE_POSTHOG_HOST / VITE_POSTHOG_DISABLED.
Identity wired to email + company: on login/rehydrate the user is identified with their email as the distinct id, with email, name, userType, role, companyId person properties, plus a company group. user_email and company_id are registered as super-properties so every event can be filtered by user or company directly. Identity resets on logout/portal switch (piggybacks on the existing guarded Sentry subscribe in UserStore).
Pageviews: PostHogPageTracker (mirrors ClarityPageTracker) captures a $pageview on every SPA route change, so per-page usage per user/company works out of the box. Autocapture (clicks/inputs metadata) stays on for depth-of-usage.
Explicit EDGE AI events at the service layer so all consumers are covered:
- edge_chat_message_sent — Agent Assist / chat assistant messages (with owner/property context flags)
- edge_callq_call_created (with call_type), edg…
v2026.07.31

Friday, July 31, 2026

3 new 1 fix 6 total
Update App

Release: staging → production (incl. Email Domain settings) — merge after BE

#2936
Cumulative staging → production cutover, paired with backend #2858. 27 commits, everything on staging not yet in main.

Raised from a release branch rather than merging staging into main directly, so branch history stays intact for the next cutover.

⚠️ Merge order

Merge and deploy backend #2858 first. The Email Domain section calls /api/company-sending-domain and reads rootDomain, subdomain and lastCheckOutcome, none of which exist on production today. Ship this first and the section renders a blank status line and the wrong DNS short name — which is the specific bug it was written to fix.

Headline: Email Domain settings (#2933)

The customer-facing half of the white-label work. Settings → Company Settings → Email domain, company-admin gated.

The shared address is now editable. A company's mailbox on the platform domain no longer has to be its URL slug — which is now printed in every recipient's inbox, so renaming a company's URL used to silently rewrite its From line and split its Gmail threads.
A company can connect its own domain, with the three CNAMEs shown as the *short name* a registrar actually asks for plus the full hostname beneath, per-record Found/Missing so a partial publish is visible, and copy buttons.

Zone splitting is done by the API rather than the client — a client that guesses shows acme.co.uk customers a DNS name their registrar turns into …acme.co.uk.acme.co.uk, which resolves nowhere and looks like the customer's fault.

Visual proof, five states, captured against a real stack: https://claude.ai/code/artifact/60dd4953-f81d-4b6d-8627-efa…
New App

Show website lead source page, form and link in pre-qualify

#2935
Shows where a website lead came from in the owner-lead Pre-Qualify → Source section, with the page URL as a clickable link.

Pairs with backend #2854, which stores the data at metadataJson.website. This is display-only and renders nothing when that data is absent, so it is safe to merge in either order.

What changes

Three read-only rows, each rendered only when its value exists:

Source        RS.com
Page Free Rental Analysis
Form free-analysis-hero
URL rentsolutions.com/free-analysis?utm_source=google <- clickable
Referred By ...


Page and Form use the existing EditableFieldRow exactly like their neighbours.
The URL is a raw with the scheme stripped for readability and the full URL on title. There is no Link component in the barrel; 28 places in the admin portal use a raw anchor and the closest analogue (CaseStudyInfo) renders external links the same way. This uses the theme colour rsos-blue-dark rather than a hardcoded one.
The three fields join REFERRAL_FIELDS so a lead that has source data but no referral data stays expanded instead of collapsing.
They are provenance, not user input: the fields are absent from GROW_OWNER_LEAD_SIMPLE_FIELDS, so they are never written back on save. Verified.

Campaign values (utm/gclid) are stored by the backend but deliberately not shown here — the ask was page, form and URL.

Scope

+40 lines in one file. No new component, no restyling, no design change. The only new markup is a two-line grid wrapper aligning the URL to the same label column as the rows around it.
Fix App

Owner lead icon uses stale processOwner instead of assignedTo

#2934
Mike flagged: some leads assigned to Revekka weren't showing her icon in Owner Leads.

Root cause: the Lead Owner avatar (list view + kanban/toteboard cards) was sourced from item.processInfo?.processOwner, which is set once when the process is created and never updates. The lead's actual assignment (assignedToUserId/assignedTo) is a separate, live field — every time a lead gets reassigned (Crystal/Revekka/Blakely etc), processOwner goes stale and the icon shows the wrong person or nothing.

Fix: build the leadManager/avatar fields from item.assignedTo instead of processOwner in OwnerLeadList.jsx.

Verified locally against prod data: filtering the list to Revekka now shows her photo on all her assigned leads; mixed view shows the correct distinct icon per assignee (Crystal vs Revekka).
New App

Owner lead detail — doors default, date won, collapsible source

#2932
Three small refinements to the Owner Lead detail page. Frontend only — no backend changes, no new API calls.

1. Doors defaults to 1

A lead with no recorded door count now shows 1 in Pre-Qualify → Lead Info instead of an empty field.

This is display-only. initialFormData is seeded from the same mapping, so the dirty-check in handleSave sees no change and doors is never included in the payload — a lead stays null in the DB until someone actually edits the field. ?? is used rather than || so a genuine 0 from the backend survives.

2. "Date Won" under the stage dropdown

A read-only Date Won row appears in the Action card, but only when the lead sits in a stage whose stageGroup is completed (the green group in STAGE_GROUPS) and only when a date can actually be resolved.

The date comes from process history, which the page already fetches for its Activity Log tab — no extra request. Stage moves are logged as stageChanged events carrying the target stage name, so the date is the most recent move *into the current stage*. This matters: a template can have several completed stages in sequence (staging has a lead that went Completed LO AgreementCompleted PM Contract seconds apart), so simply taking the latest stage change would show the wrong date. Falls back to generationState.stage_changed_at; renders nothing if neither exists (e.g. a lead created directly into a completed stage) rather than showing a misleading date.

LeadActionSection is shared with Tenant Lead detail, LeadDetailCard and WorkOrderDetail. The change is a single optional dateWon prop — those three pass nothing and render exactly as befor…
New App

Require owner first and last name on new owner leads

#2930
Problem: Manually-added Owner Leads could show blank Property/Owner detail cards. The New Lead form only sent newOwnerData/newPropertyData when a section had any input, and every owner/property field was optional — so a lead saved with e.g. just a phone number got no linked Property (and a nameless Owner).

Fix: Make First Name, Last Name (owner) and Address 1 (property) required in the zod schema, so both newOwnerData and newPropertyData are always sent on save — matching the CSV ownerLeadImportService behavior (which always creates both records).

Verified on staging (dev.edge.rent) against the real backend:
save blocked with only a phone → 3 'is required' errors, no POST
name+address → POST 201 with both newOwnerData + newPropertyData; detail page shows populated Property + Owner cards
required asterisks render on First/Last/Address only; no horizontal overflow at 390px
Update App

Comms Hub: one-click Accept, hub-only new-chat toast, AI reply options

#2929
Three UI changes Sri asked for on the internal side. (The fourth, the website card's "Talk with AI" button, is Rentsolutions-Website#19.)

1. Accept, right on the row
A waiting visitor now has an Accept pill on their row in the Live Chat lane. One click claims the chat *and* drops the cursor in the reply box — previously you selected the row, hunted for "Take chat", then clicked into the composer before you could type.

Focus is passed as a focusComposerToken that WebsiteChat watches, rather than the hub reaching into the chat's DOM. The pill is a sibling of the row button, absolutely positioned over its right edge — a button nested inside a button is invalid HTML and breaks keyboard activation — and the row reserves right padding so the pill never covers the preview text.

already_claimed still refetches the pool (someone else got there first, the lane must stop offering it) but does not steal focus, since the chat isn't ours to answer.

2. New-chat toast, only on the Comms Hub
A waiting request now raises a top-right toast in the same shape as the incoming-call alert, so asking for a human is as loud as a ringing call. Accept takes it; Dismiss hides the toast only.

It is page-scoped by construction — rendered by CommHub2, not by the global alert layer (Sri: "only in the comms hub page"), so it cannot follow an agent around the admin portal and nag them somewhere they can't act on it. Dismiss deliberately does not remove the visitor from the lane: silencing a notification must never quietly drop someone who asked for a person.

3. AI proposes options instead of overwriting
The ✨ assist used to replace whatever was in the composer with a single suggestion. It now requests a few drafts, de-duplicates them (the model sometimes lands on the same phrasing t…