RS Release Notes

StatusReleasesTicketsProjectsOps Dashboard

Production releases and updates to the RentSolutions platform.

v2026.10.09 Latest

Friday, October 9, 2026

3 fixes 8 total
Update API

Release: staging → prod 2026-10-10 (b)

#3925
Staging → prod release, 2026-10-10 (second).

• #3920 feat(owner-update): fill missing transcripts from Vimeo captions

Deploy the worker as well as the API (new cron job). Post-deploy backfill steps are in #3920.
Fix API

Stop auto-moving tenant leads to Contacted on outbound communication

#3862
What
Removes the automatic New -> Contacted stage move that fired every time any outbound communication reached a tenant lead (TenantLeadService.updateLastContactDate). The lastContactDate stamp stays.

Why
Steve, Oct 6 2026 team meeting: "Contacted" must mean a person actually reached the lead. The automated Day 1 text/email in the New Lead workflow was flipping leads to Contacted with no workflow trigger, which (a) misreports contact and (b) exits the New Lead stage, skipping its open tasks before the planned multi-attempt cadence can run. Stage moves should come only from a person or a workflow stage-change task.

Added in d47cab9b7 (Mar 20 2026). Before that commit the same spot carried a comment saying pipeline columns are computed from contact history and no stage move is needed; that is still true (_getManualContactedLeadIdsSubquery).

Also: automatic "Change to Nurture" on a tenant lead failed after moving it
executeStageChangeTask wrote the stage group straight into tenantLead.status. Only 'active' is a valid status, so any automatic move to a Nurture (backlog), Lost (canceled) or Moved In (completed) stage threw *after* moveToStage had already moved the process. The task was then marked failed and turned into an open manual task. moveToStage already syncs status through tenantLeadService.handleStageChange (with STAGE_GROUP_TO_STATUS), so the duplicate write is removed. On prod it is latent today, because no tenant-lead template has an automatic non-active move. The new New Lead cadence ends with one.

Test: tests/automationTenantLeadStageChange.test.js fails on the old code and passes now. Live local E2E: a TEST lead on 16800 Parsonage reached Nurture on its own.

Blas…
Update App

Release: staging → prod 2026-10-10

#3792
Staging → prod release, 2026-10-10.

Commits:
• refactor: remove Expand button from owner update details (#3789)
• fix(recorder): confirm before Remove Video clears the take (#3788)
• fix(owner-update): keep a saved draft video playable and editable (#3780)
• fix(leasing-updates): old update popup plays its own video, not the unsent draft (#3778)
• fix(owner-leads): outline icons in Last Contact and Next Action (#3779)
• fix: remove misleading caption under the draft video player (#3776)
• fix(owner-leads): Reply in the Last Contact popup needs full lead access (#3775)
• feat(owner-leads): reply from the Last Contact popup (#3766)
• fix(owner-update): hold Save for the transcript, retry on failure, keep it on re-save (#3767)
• feat(process-list): drop the template name from row names in a one-template list (#3770)
• fix(video-message): hold Send for the transcript, retry on failure, drop stale ones (#3773)
• feat(tasks): lock the trash on process-generated tasks for everyone (#3764)
• fix(last-contact): review follow-ups from #3733 (#3759)
• feat(owner-leads): Last Contact cell opens the communication in place (#3733)
• feat(realtor-connect): My Profile page from the avatar menu and Home banners (#3757)
• feat(showings): send lead and showing to the public slot list (#3750)
• feat(owner-leads): Lead Date, Contract Signed and Source filters in a two-column card (#3736)
• fix: owner demo dashboard — occupied list s…
Update API

Release: staging → prod 2026-10-10

#3923
Staging → prod release, 2026-10-10.

Commits:
• fix: finish task re-evaluation before a field save returns (#3921)
• fix(process): first owner never sends old automatic texts or emails (#3916)
• fix(process): first owner on a paused lane creates nothing (#3913)
• feat(video-message): transcribe video recordings with gpt-transcribe (#3911)
• feat(tasks): refuse to delete process-generated tasks (#3907)
• fix(communication): staff-only GET /history (#3909)
• fix(communication): staff-only unified history (#3905)
• feat(process): remove conditional tasks when the answer no longer matches (#3888)
• fix(leasing): 1h unconfirmed alert stays truthful when auto-cancel is off and survives a lookup error (#3902)
• feat(owner-leads): send the record id with each lead's lastContact (#3875)
• feat(realtor-connect): return the realtor's ProWorxx onboarding answers on /profile (#3900)
• fix(leasing): unconfirmed-showing alert no longer says a follow-on will be auto-cancelled (#3899)
• fix(leasing): keep same-site follow-on showings from auto-cancelling (#3896)
• feat(leasing): offer the slot right after an on-site showing inside the scheduling notice (#3889)
• feat(owner-leads): filter the list and board by source and contract signed date (#3876)
Fix App

Show the saved booking link after a blank save

#3791
Pairs with BE #3922: a blank booking link no longer clears the slug — the server keeps the current one or generates one from the user's name.

The settings field read only from me, and after a blank save the users refetch can return the same me reference, so the input stayed empty while the real slug was still set. It now takes bookingSlug from the save response.
Fix API

Blank booking link never clears the slug

#3922
Problem
Cristina Santiago's owner-update page said "Book a time with Mike Arias". She had no personal booking slug, so resolveViewerBooking fell back to the company scheduler (Mike).

New users already get a slug + default hours at creation (#3120). Remaining hole: My Booking Link sends null when the field is blanked, and updateUserBookingSlug cleared the slug.

Fix
A blank slug now keeps the current one, or generates one from the user's name (same generateUniqueBookingSlug createUser uses). 400 only if the name can't produce a slug.

Prod data (already applied 10/09)
• Cristina: booking_slug = 'cristina-santiago' (only staff user on prod without one).
• 53 internal users with blank hours ({}/null) set to default Mon–Fri 9–5 (their booking button was hidden). Revert SQL kept locally.
• Live check: her owner update now shows "Book a time with Cristina Santiago".
Update App

Show booking times and today's date on the company's clock

#3744
Public booking pages and a few admin views showed times in the viewer's browser zone, or in Eastern when logged out, instead of the company's zone.

What changes
• Book a showing (schedule, confirm step, confirmation page): slots and the chosen time show in companyTimezone from the API, falling back to the stored zone.
• Showing board: time column keys use the company zone.
• Task "new" card: "today" is the company's today.
• timezoneStore: reuses DEFAULT_TIMEZONE.
• Comments: stale "EST" comments in Call Q are fixed.
• Removed: the unused src/api/scheduleApi.js.

Every time zone field in the app is already a dropdown; none take typed text.
Update API

Run dates, windows and call times on each company's clock

#3882
Companies outside Eastern (Clockwork, Ellis) see the wrong "today", wrong call times and wrong windows, because the API, the worker and Postgres all run in UTC and many paths either used UTC days or hardcoded New York.

What changes
• Day / week / month boundaries now use the company's clock. This covers toteboards, priorities, scoreboards, ranks, tickets, memos, inspections, photography, owner appointments, owner dashboards, enrollment and Vitals query windows.
- Real instants (createdAt, showing start) are compared against the company's midnight.
- Plain days stored at 00:00Z (lease end, next contact) are compared against the company's date at 00:00Z.
- Stored Vitals KPI keys are unchanged.
• Call Q: Add to Call Q and Start Now now store the real UTC time. They used to store New York wall time as UTC, which put Pacific calls 7h off. The AI call window falls back to the company zone when Call Q has no zone set. The off-hours job includes non-Eastern companies.
• Showings: slot weekdays use the company zone. The booking page receives companyTimezone. Lead groupings (upcoming showings, move-ins) are by company day. The guided-session line prints the company zone.
• Ticket completedAt and default due/scheduled dates use the company's date.
• Validation: a time zone must be a known name. A miscased name like America/New_york is now rejected; aliases such as Asia/Kolkata still pass. This applies to company settings, Call Q settings, EDGE Phone shifts and AI routines. Company create now requires timezone.
• Removed dead code: _buildShowingQueryConditions, ownerUpdate breakdo…
v2026.10.08

Thursday, October 8, 2026

2 new 12 fixes 24 total
Fix API

Default edge phone new-email push to off

#3906
Hotfix requested by Sri: EDGE Phone → Settings → Notifications → Email should default to off.

Change: push-new-email anchor template defaultEnabled['in-app'] true → false (constants/notificationTemplates.js). One line, no app rebuild: the phone toggle reads/writes the server-side preference row.

Effect
• New users: their seeded push-new-email / in-app preference row is created off (seeding in templateNotificationService reads defaultEnabled), and the push gate's absent-row fallback (pushNotificationService.anchorDefaultEnabled) is now off.
• Existing users who already have a row keep their current value; this PR does not change any stored data.
• Text-message and video-reply phone alerts are unchanged (still default on).

Checks: node --check on the file; loaded templates show push-new-email in-app = false, push-new-text = true. The existing "sends by default when no preference row exists" test targets push-new-video-reply, which is unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
New API

ProWorxx admins text realtors from their own EDGE line; replies sync back

#3904
What
ProWorxx admins can text realtors from their own EDGE line, the same way the EDGE composer does it. Replies come back into ProWorxx.

Asked for by Alex: he texts realtor applicants from ProWorxx, and they should see his EDGE number, not the shared ProWorxx number.

How
• GET /api/proworxx/texts/sending-line?email= returns the user's primary agent line (reuses communicationUtilService.getAgentPhone).
• POST /api/proworxx/texts sends through smsService.sendSms as that user, from that line, so EDGE's normal threading, opt-out and DNC guards all apply. The message is stamped metadata.proworxx = { realtorId, origin: true }.
• _emitSmsMessageEvent (the one hook both inbound and outbound SMS pass through) calls emitRealtorText. Any later message on a thread ProWorxx opened gets queued as realtor.text and posted to ProWorxx /api/integrations/edge/realtor-communications. The ProWorxx-sent message itself is skipped, because ProWorxx already has it.
• Both routes sit behind verifyProworxxHmac. The user lookup is scoped to the paired company and to active users, and the email match ignores case.
• Cost: for the paired company with push enabled, each SMS adds one indexed lookup (conversation_thread_id, direction). Other companies are skipped before any query runs.

Tests
• tests/proworxx/proworxxRealtorTexts.test.js, 5 tests, real DB and signed HTTP, Twilio client stubbed:
- right line
- foreign-company user → 404
- unsigned → 401
- send threads and stamps
- reply queues one push with the right payload
- unrelated thread …
Fix App

Align footer with page body and nav

#3763
Follow-up to #3761. The footer was capped at max-w-screen-xl (1280px), so on wide screens the Privacy / Terms links stopped well short of the body cards' right edge.

Footer now uses the same container as the admin body and nav: max-w-adminMaxWidth (1716px, centred) with the body's px-8 padding.

Measured locally against an element with the body's exact container classes, at 1320 / 1440 / 1920 / 2400px: the links' right edge matches the body card's right edge (to the pixel), and the copyright is centred on the body. Lint + build pass.
New App

Add Privacy Policy and Terms & Conditions links

#3761
Requested in Loom 10/08 (Sai): add Privacy Policy and Terms & Conditions to the bottom right of the app.edge footer.

• DefaultFooter: copyright stays centred; two links sit bottom-right (stack under the copyright on mobile).
• Links reuse the existing /privacy and /terms routes, which already redirect to edge.rent/privacy and edge.rent/terms (same targets as the portal login page). Open in a new tab so nobody loses their place in the app.

Tested locally in Chromium: 1440px and 390px, no horizontal overflow; clicking each link opens edge.rent/privacy and edge.rent/terms. vitest 2548/2548 pass; lint + build pass.
Improvement App

Cherry-pick public slot lead/showing params to main

#3758
Cherry-pick of #3750 (staging squash c7aa5a0ae2) onto main with git cherry-pick -x. Clean pick, no conflicts. The three files match origin/staging exactly.

Merge AFTER the BE main pick of #3889 (validator rejects unknown keys on older API).

No prod scripts.

Tests: npx vitest run src/hooks/leasing src/pages/BookShowing → 11 files, 61 tests passed. ESLint clean on the changed files.
Improvement API

Cherry-pick same-site follow-on showing work to main (#3889, #3896, #3899, #3902)

#3903
Cherry-picks four merged staging commits to main (git cherry-pick -x, in order, no conflicts):

1. 669c864368 (#3889) feat(leasing): offer the slot right after an on-site showing inside the scheduling notice
2. d77369fd4c (#3896) fix(leasing): keep same-site follow-on showings from auto-cancelling
3. 26e404e936 (#3899) fix(leasing): unconfirmed-showing alert no longer says a follow-on will be auto-cancelled
4. e54e3652a2 (#3902) fix(leasing): 1h unconfirmed alert stays truthful when auto-cancel is off and survives a lookup error

All 10 touched files are byte-identical to origin/staging after the picks.

Merge before the FE main pick. The FE sends tenantLeadId / showingId; the validator on the older API rejects unknown keys.

No prod scripts.

Tests (TZ=UTC), all passing: showingSameSiteFollowOn (22), showingAutoCancelUnconfirmed (18), validateTimeSlotBlackout (17), timeSlotCompanyTimezoneLabels (7), showingTravelBuffer (19), showingCapacityLimits (20), showingBlockedDates (8), availableSlotsUuidGuard (2), systemTemplateResolver (12), notificationTemplateDefaults (4), systemNotificationRequiredPlaceholder (16). eslint: 0 errors.
Fix API

Hire/end dates and end reason are admin-owned on a self-edit (#3892 review)

#3898
Follow-up to #3892 (merged before its review was addressed). Pairs with FE rentsolutions-app/rentsolution-frontend follow-up.

Change
• hiredDate, endedDate and endReason join the self-edit guard (list renamed ADMIN_OWNED_USER_FIELDS). A user who cannot manage users can no longer set their own HR record. Resending an unchanged value still passes.
Fix App

Lock HR fields on a My Settings self view (#3754 review)

#3756
Follow-up to #3754 (merged before its review was addressed). Backend: rentsolutions-app/rentsolution-backend#3898.

Change
• Hired / Ended / End Reason are now admin-owned like Role and Status: read-only on a self view for anyone who cannot manage users. The flag is renamed adminOwned, the prop canEditAdminFields.
• ProfileSection.canEditAdminFields and ModuleAccessSection.canEdit are now required, with no opt-in default. Every caller passes them: the owner, realtor and photographer detail pages and UserPermissionSetting pass true (admin pages for another account), and UserDetail passes its computed value.
• The self-or-module route guard moved to UserDetail/_components/UserDetailRouteGuard.jsx, following EdgeAIRouteGuard. This clears the react-refresh warning in settingsRoutes.jsx.
Fix App

Edge phone voicemail tab label overflows the tab bar

#3755
Sri flagged it in #eng-general: on prod the EDGE Phone bottom tab bar clips "Voicemail" past the right edge.

Cause

The panel is 320px wide (w-80). All 5 tabs were flex-1 with a gap-1 between them, inside a bar inset left-3 right-3. Their combined label width is wider than the bar, so the last tab ("Voicemail", the longest label) spilled 9px past the bar's edge.

Fix (one file, PhoneApp.jsx)

• Tabs are flex-auto instead of flex-1, so each tab sizes to its label and they split the leftover space. "Voicemail" gets the most room.
• No gap between tabs (gap="none"). Each tab already has its own pill padding, which is how the iOS tab bar does it.
• The bar is inset left-2 right-2 instead of left-3 right-3, so it is 8px wider.
• The labels still use whitespace-nowrap, so "Voicemail" stays on one line. Sri asked for no second line.

Verified

I rendered the tab bar markup with the same classes at 320px in headless Chrome, before and after:
• Before: the Voicemail tab ends 9px past the bar's right edge (the bug is reproduced).
• After: every label is on one line and inside the bar. The tab widths are 61 / 56 / 61 / 52 / 64px.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fix App

My Settings opens your own profile without Company Settings access

#3754
Closes #3751. Backend: rentsolutions-app/rentsolution-backend#3892. Merge both; neither is enough alone.

Problem
"My Setting" sends the user to /admin-portal/settings/users/. Since #2783 the whole users subtree has been gated on CompanySettingsModule, so a user with companySettings = no_access got Access Denied on their own profile.

Change
• settingsRoutes.jsx: the User List keeps the CompanySettingsModule gate. users/:id goes through SelfOrCompanySettings, which skips the gate only when :id is the caller (or me).
• UserDetail.jsx: canEditAccess matches the API rule (an admin page for others; on yourself only super or a companySettings administrator). When it is false, Role and Status are read-only in Profile and Module Level Access has no edit button. Module Level Access is hidden on a self view without the module, because its endpoint would 403. The back button goes to the list only when the caller can open the list.
• ModuleAccessSection gets canEdit and ProfileSection gets canEditAccess, following the existing canEdit pattern on the signature sections. Both default to true, so other callers are unchanged.
• Menu label "My Setting" → "My Settings".

Known gap
GET /api/company/roles needs Company Settings, so on a no_access self view the read-only Role and the Routine Tasks role pickers are empty, and there is a "Failed to fetch roles" toast. Out of scope here.

Tests
• vitest over Settings/User, routes, components/molecules: 86/86.
saketsarin · 2d ago
Fix API

Every user can read and edit their own profile, never grant themselves access (FE #3751)

#3892
Fixes the backend half of rentsolutions-app/rentsolution-frontend#3751.

Problem
A user with companySettings = no_access (Leah at Clockwork) gets a 403 on their own My Settings page. The User CASL rules only come from the companySettings module, so GET /api/user/:id, PATCH /api/user/:id and the notification-preference endpoints refused the caller's own row.

Change
• canActOnUser: your own row is always readable/updatable; anyone else's still goes through CASL against the target row. Used by getUser and updateUser. getUser now checks the loaded target instead of the class-level READ.
• Self-escalation guard in updateUser: someone editing their own row who cannot manage users (not super, no company-wide User update) gets a 403 if the request changes any of moduleAccess, extendedPermissionsJson, role, roleId, userType, isActive, isSystemAdmin. Resending the same value is allowed. Applies to companySettings standard too, since they can't change these for anyone else either.
• Notification preferences: removed the class-level checks that came before resolveTargetUser. Self needs no permission; another user still needs administrator plus an instance-level CASL check.

Not in this PR
• POST /api/user/change-password still has its class-level check, so no_access users still can't change their own password.
• GET /api/company/roles still needs Company Settings, so the read-only Role field shows blank on the self view.

Tests
• New tests/…
Fix App

Profile menu name invisible in Owner/Realtor Connect

#3753
The "Logged in as" name in the Owner Connect and Realtor Connect profile menu rendered blank.

Cause: the Connect top bar sets text-white; the profile menu (white background) sits inside it and the name span has no colour of its own, so it inherited white — white on white.

Fix: give the avatar/menu wrapper in ConnectHeaderTop a dark text colour (text-gray-700, same as the settings popover in that file). AvatarCircle sets its own text-white, so the avatar initials are unchanged. Shared ProfileMenu untouched; admin header unaffected.

Tested locally in Playwright (TEST owner + TEST realtor sessions): name computed colour was rgb(255,255,255) on a white panel before, rgb(55,65,81) after; visible in both portals.
Update App

Realtor Connect: colour remaining black icons, shorter Ways to Earn tiles + Scoreboard list

#3749
More Realtor Connect fixes after #3747:

• No more black list icons: every sidebar row now has a colour.
- Status rows (Pending / Approved / Paid, Pending Invitees, Pending and Paid Commissions) use a new PORTAL_STATUS_ICON_COLORS in growConstants: pending is blue, approved and paid are green. The Home and Quick Stats cards check REFERRAL_TYPES / NETWORK_LEVEL_CONFIG first and then this map.
- Conversion Rate uses the paid colour.
- Rewards, Sharpening Skills and Quick Guide rows use the theme primary colour, through the existing getTypeColor.
• Ways to Earn tiles are shorter: less padding, no icon padding and a smaller grid gap. The tiles no longer make the Shareable Link and Post on Social Media cards taller, so those cards have no empty space above their button.
• Scoreboard list is shorter: max-h-80 → max-h-60, which is two rows less. Row 2 of the Home grid drops from 674px to 617px at 1600px wide, and the other cards in that row lose the extra space.

Checked locally with TEST data at 1300, 1600 and 1920px wide. eslint is clean, and the RealtorConnect / OwnerConnect / AdminPortal _components tests pass (251/251). Prettier flags growConstants.js and ScoreboardCard.jsx, but those files were already unformatted on main, so I left them.
Update App

Realtor Connect: #3747 review follow-ups (CommunicationButton placement, Quick Stats type keys)

#3748
Follow-up to #3747's review:

• CommunicationButton: TeamMemberCard is its only caller, so the under-the-avatar calendar placement now lives in CommunicationButton's left position. The iconClassName prop is removed.
• QuickStatsSidebarCard: type now holds real keys ("owner", "3"). The colour is looked up from REFERRAL_TYPES / NETWORK_LEVEL_CONFIG, the same way MyReferralsCard and MyNetworkCard do it.

No visual change: the classes and colours are the same as in #3747. eslint and Prettier are clean, and the RealtorConnect / OwnerConnect / AdminPortal _components tests pass (251/251).
Update App

Realtor Connect: Loom 8 Oct icon and colour fixes

#3747
Steve's 8 Oct Loom on Realtor Connect:

• Icons beside the titles: Shareable Link, Post on Social Media, Ways to Earn and the 6 Resources & Training cards now put the icon next to the title, like Contest, using SectionTitle's icon prop. No more centred icons on top.
• Contest icon: now green (secondary) instead of dark.
• Book an Appointment: the calendar icon is centred under the 80px photo above it. CommunicationButton gets an optional iconClassName, so its other callers keep their position. Owner Connect's Your Team card uses the same TeamMemberCard, so it changes too.
• Colour-coded sidebar icons: they match the list icons.
- Referrals sidebar (Owners…Sellers) and Home "Owner Referrals" use REFERRAL_TYPES.
- My Network Level 2/3 use NETWORK_LEVEL_CONFIG.
- Quick Stats Total Referrals and Network Size use the owner and network colours.
- All of this goes through CategoryList's existing type + getTypeColor. The Referrals sidebar items are now derived from REFERRAL_TYPES instead of restated.

TEST demo data for the TEST Realtor was seeded on prod separately (not part of this PR).
Fix API

Guard legacy stages in active tenant lead lists

#3885
Why
Sentry NODE-EXPRESS-92's 2026-10-08 event was GET /api/tenant-leads?status=["active",...] on a local-origin API. That route uses canceledStageExclusionSql for the active filter. The shared predicate still cast processes.currentStage directly to UUID, so a legacy label such as New could abort the whole list. #3883 fixed a separate fallback used by /new and /contacted; it did not reach this reported list path.

Change
Use pg_input_is_valid(currentStage, 'uuid') inside a CASE before casting in the shared canceled-stage join. Valid UUIDs retain the same stage-group behavior and the UUID primary-key side of the join remains cast-free. Invalid labels and NULL produce no canceled-stage match.

Proof
• A PostgreSQL 16 regression using mixed CTE rows failed before the change with invalid input syntax for type uuid: "New", then passed after it. It checks legacy, canceled, active, unknown, and NULL stages without writing tables.
• npx mocha --exit tests/leasing/legacyCanceledStageList.test.js tests/leasing/tenantLeadLegacyStageCast.test.js tests/contracts/propertyDeactivationWebsiteVariant.test.js: 19 passing.
• Changed-file ESLint and git diff --check: pass.
• Production is still unmodified by this PR. Destination verification of the reported route is owed after approved merge and deploy. Production currently has no tenant-lead process with the literal New; live success alone cannot replay that fixture.
Update App

Realtor Connect: remove the impersonation welcome bar

#3746
Removes the green "Welcome! This is what your realtor sees…" bar that Realtor Connect showed when an admin opened a realtor's portal.

The bar was position: fixed at the top, but the page content was only pushed down by the header height, not by the bar's height. So the bar hid the top of the first row of cards, and scrolling up never showed it.

• RealtorConnectWrapper stops passing topBanner.
• ImpersonationBanner.jsx is deleted. Nothing else used it.
• ConnectHeaderLayout is unchanged. Without a topBanner it takes the same path as Owner Connect and normal realtor logins.
Fix App

Owner Connect headings, icons, 24px grid and admin-style list toolbars

#3743
Realtor Connect now uses the same headings, icons and spacing as Owner Connect (Steve's 7 Oct Looms, #3726–#3737), built from the existing Owner Connect and admin components.

What changes
• Card headings: every card title uses SectionTitle (Outfit semibold 24). The 11 CategoryList cards pass a SectionTitle node as title. CategoryList still renders its old h3 for string titles, so admin screens are unchanged.
• Icons: Shareable Link, Post on Social Media, Ways to Earn and the 6 Resources tiles keep their icon above the title, now drawn with the Owner SectionTitleIcon (44px solid circle, still green). The card layout is unchanged. Contest's bare chart icon sits in the same circle; a new dark colour keeps its existing gray-900.
• Grid: all Realtor pages, including the 6 Resources sub-pages, use Owner Connect's TWO_COLUMN_PAGE_GRID (24px gaps instead of 48px, 425px left column).
• List pages: on Referrals, Commissions, Rental Analysis, My Network and the Resources sub-pages, the filter bar and the list now sit in one box, as on Owner Connect and the admin portal.
• List toolbars: the first four pages now use the admin defaults: MultiToggle, FilterButton, ListCountBadge and SearchBar (w-64). ListCountBadge takes optional count/countLabel props and still reads the header store when they are not passed. FilterButton's size="lg" option was only used by Realtor Connect, so it is removed.
• Help card: uses Owner Connect's
Update API

ProWorxx reconciliation: re-push EDGE-only realtors; stamp and report refused pushes

#3884
Why: a realtor created in EDGE is pushed to ProWorxx once. If that push doesn't land, EDGE logs it and gives up: no retry, no alert. On 10/06 that left 8 EDGE realtors with no ProWorxx record. Seven were TEST rows. The eighth is Cesar Verbel, who is now an EDGE duplicate.

The pushes failed in three ways (from queuedEvents):
• endpoint_missing: ProWorxx's realtor endpoint wasn't deployed yet.
• 400: the payload was rejected.
• 409: the identity was refused because the email/phone already belongs to another ProWorxx record.

Change (the existing 30-min ProWorxx reconciliation job):
• New realtorExportHeal: re-queues the realtor push for every EDGE realtor that has no ProWorxx id. Same pattern and batch cap as the vendor export heal.
• A 409/400 from ProWorxx now stamps metadataJson.proworxx.pushRefusedAt/Reason. This is a key-level jsonb write that doesn't bump updatedAt. The heal skips those realtors until someone edits them, and lists them in a warn log every run (name + reason), so they get merged instead of drifting silently. A successful push clears the stamp.
• 400 used to throw for a queue retry. It now skips like 409, because retrying the same payload can only fail again.

Tests: 2 new tests against the real test DB in proworxxRealtorTwoWaySync.test.js:
• refused push → stamped, updatedAt untouched;
• the heal re-pushes the missed realtor, skips the refused one, ignores the linked one, re-pushes the refused one after an edit, and a later success clears the stamp.

Ran the 4 ProWorxx spec files per-file like CI: 75 passing. Two mutations (heal ignores th…
Fix API

Avoid UUID cast for legacy tenant stages

#3883
Fixes NODE-EXPRESS-92.\n\nLegacy process rows can retain a text stage label such as New. The New-tenant-leads fallback cast every currentStage to UUID, so one legacy row made the entire list fail. Compare the process-stage UUID as text instead, preserving the existing fallback lookup without risking a cast failure.\n\nTests: npx mocha --exit tests/leasing/tenantLeadLegacyStageCast.test.js; ESLint exits 0 (34 existing warnings in tenantLeadService).
Fix App

Show Inactives in the Filter dialog waits for the Filter button

#3742
Loom: https://www.loom.com/share/be7a31ed80eb4fe1bcfb922fa91635be

Bug (live on prod): in Owner Leads → Filter dialog, flipping Show Inactives filtered the list straight away, behind the open dialog, ignoring the Filter button. Same on every list that has the toggle in its dialog (Owner Leads, Tenant Leads, Showings, Rentals, Feedback, Updates, Listing Syndication).

Fix
• FilterButton: the toggle is now a draft like every other field in the dialog. Filter applies it; Cancel drops it.
• useFilterParams: two URL writes in one handler (filters + toggle) now compose instead of the second overwriting the first.

Tests
• New: toggle waits for Filter; Cancel drops it (Owner Leads); two writes compose (hook). Both fail without the fix.
• Updated Showing calendar test that pinned the old instant behaviour.
• Full FE suite green; lint + build green.
• Live local browser: toggle → no URL change / no request; Filter → showInactives=yes + list refetch without status=active; Cancel → unchanged.
Update App

Advocates list: page through every advocate instead of the first 25

#3741
Grow › Advocate Programs › Advocates only ever fetched page 1 at 25 rows and had no pager, so "Showing 25 advocates" was the page size, not the total. Alex read it as "only 12 real realtors in EDGE" while prod has 49 (all 28 ProWorxx applicants included).

• Pass page (from the URL's existing page param) to the list query; page size stays EDGE's 25.
• Render the shared Pagination under the list when there is more than one page (same pattern as the program-detail Advocates tab).
• "Showing N advocates" now reads the server total (pagination.total), except while an A–Z letter is active — that filter is client-side on the current page, so the count follows the rows shown.
• Any filter/sort/search change returns to page 1; a shared ?page=N link still opens on page N.

Tested live: local build of this branch against the prod API (read-only browsing) — "Showing 49 advocates", pager 1·2 under the list, page 2 lists the rest (Kelly Irelan, Jennifer Scales, …), a search resets to page 1.
Improvement API

Cherry-pick owner lead source + contract signed filters (#3876) to main

#3881
Cherry-pick of the staging merge of #3876 to main, no conflicts (git cherry-pick -x bcb0f7288). Tree matches the staging merge commit exactly.

• #3876 feat(owner-leads): filter the list and board by source and contract signed date

Merge this before the frontend pick (rentsolutions-app/rentsolution-frontend cherry-pick of #3736). No migration, no prod step. Proof of the live end-to-end run: https://claude.ai/artifact/CKN2XaRKiG6LtP8yuxjVth
Improvement App

Cherry-pick Owner Leads filter card (#3736) to main

#3740
Cherry-pick of the staging merge of #3736 to main, no conflicts (git cherry-pick -x 1d640c7e1). Only tree difference from staging is #3739, which is not part of this pick.

• #3736 feat(owner-leads): Lead Date, Contract Signed and Source filters in a two-column card (includes Saket's picker test fix 834e0c6a9)

FE only, no prod step. Needs the backend pick (rentsolutions-app/rentsolution-backend cherry-pick of #3876) on main first; until then the card sends keys the API ignores. Owner Lead list folder 5 files green on this branch. Proof page: https://claude.ai/artifact/CKN2XaRKiG6LtP8yuxjVth
v2026.10.07

Wednesday, October 7, 2026

1 new 10 fixes 16 total
Fix App

Owner dashboard maintenance mock showed a real property address

#3738
The Maintenance card's "Needs Approval" item on the owner dashboard is hardcoded mock data (MOCK_APPROVAL_CARD_DATA), and its address was a real property (110 28th Avenue N Unit A, St. Petersburg). Every owner on prod sees it. Swapped for a sample address (1250 W Bayview Ave, Tampa, FL 33606). One-line constant change, no logic touched.
Fix App

Thin scrollbars at card edge, KPI tiles, SearchBar review note

#3737
Owner Connect follow-ups after #3735.

• Scrollbars: every inner scroll area (dashboard Communication, % Occupied, Rent Collected, Leasing Updates, Upcoming Leases, properties list, Price, CMAs, Applications, Communication page lists, detail pane, assistant) now uses the existing thin kanban-column-scroll style.
- The cards with padding pull their scroll area out to the card edge (-mr-6 pr-6), so the scrollbar never sits over content. The main page scrollbar is untouched.
• Property Performance: the KPI tiles keep their own height and sit centred beside the photo, instead of stretching.
• #3735 review: showIcon is removed from the shared SearchBar, so it matches main again. Communication hides the search icon from its own side ([&>*:first-child]:hidden), as asked: icon-only button, no icon inside the bar.

Tested: eslint, the OwnerConnect vitest suite (29/29), build, and live local screenshots of the dashboard, Communication and Leasing pages. A DOM check confirmed each scroll area ends at the card edge.
Fix App

Wire showcase, favourites and team booking to real data

#3735
Follow-up to #3731: Owner Connect pieces that showed placeholders even when the data exists.

• Property Showcase (Leasing detail): "See Where Your Property is Being Showcased" read a store field nothing ever loaded, so it always said "No active listing websites available". It now reads syndicationJson.sources from the owner leasing-property response. A source counts as live once it has a URL, the same rule the admin syndication view uses.

More wiring lands here as it's done:
• the Communication favourites panel;
• Let's Talk opening the member's booking page;
• Leasing detail moving off the staff-only update endpoints (paired backend PR).

Tested: vitest passes for OwnerConnect, including a new PropertyShowcase test; eslint is clean and the build passes. Checked locally in Chromium with the seeded TEST owner.
Fix API

Team booking link, owner favourites, listed rent in previous updates

#3877
Owner Connect backend fixes. This only wires data that already exists: no migrations and no new tables.

1. Team members' booking link in the owner team payload
• getLeasingPropertyTeam now also returns each member's bookingSlug (users.booking_slug, the value staff set under Company Settings → My personal booking link).
• It shows up on GET /api/owner-portal/dashboard/team and in the owner leasing detail team[], so "Let's Talk" can open /schedule/ (frontend #3735).
• Auth and scoping are unchanged.

2. Owners can use favourites
• Owners got 403 on /api/user-favorites because the business-logic layer returned before applying applyUserFavoriteAbilities. They now get the same own-userId read/create/delete rules as staff.
• Owners can only favourite knowledge/edgepath content (article, form, policy, script_and_dialogue, collection, course, path, badge, lesson, group, playlist, live_event, smart_plan). Creating a favourite looks the record up by id with no company check, so without this limit an owner could read a tenant lead, resident or staff user by id. The controller checks create against the row being created. Staff are unaffected.

3. "Listed Rent" in owner Previous Updates
• getPropertyOwnerUpdates rebuilt statistics without listedRent, so the owner saw $0. It now returns the snapshot value, falling back to the unit's current rent for updates saved before the snapshot kept it.
• createStatsSnapshot now stores listedRent when an update is sent.

Tested
•…
Fix App

Spacing pass + Property Performance KPI cards

#3731
Follow-up to #3729: the items Saket raised after it merged.

Spacing pass (dashboard)
• Leasing Updates rows: 16px padding inside each row, a 24px gap between the photo, text and columns, and roomier address/location lines. The Leads / Applications / Showings block has 1.5 line spacing.
• Upcoming Leases cards get the same treatment.
• Needs Approval box, % Occupied address list, Communication rows (dashboard and Communication page) and Maintenance Activity rows: more padding and looser line spacing.
• The % Occupied list component is shared with the "Last 7 Days / Total" lines on the Leasing detail gauges, so those get the same spacing.

Dashboard Property Performance card
• KPI tiles: white with a thin border and small shadow (no flat green fill). The icon sits in a light-green circle, the value is text-3xl bold, and the tiles form an equal-height 2×2 grid matching the photo height.
• Photo: rounded-xl, with a white "Live"/"Projected" pill that has a status dot.
• Under the photo: street in semibold, city in grey, a tidier beds/baths/sqft row, and bigger rent.
• No sparklines, because the card only gets single values with no history.

Tested
• eslint is clean, vitest passes for OwnerConnect, and the build passes.
• Checked locally in Chromium against the seeded TEST owner.
Update App

Use palette tokens for the update bar colours

#3734
Follow-up to #3732 (review point 2): the update bar used raw Tailwind colours. Now uses palette tokens.

• bg-gray-800 → bg-rsos-gray-dark
• text-blue-300 → text-rsos-blue-light-2 (Refresh)

Checked in Playwright on local EDGE, signed into the admin and owner portals at 1280 and 390px: same layout, new colours, text readable.

Review point 1 (one wording everywhere) is left as is on purpose: owner/realtor portals saying "This page has been updated" instead of the EDGE name was requested.
Update App

Redesign the update bar and show it only inside signed-in portals

#3732
What
The "There's a new version of EDGE. Click to update." bar wrapped onto two lines and had a boxed white button. It is now a one-line snackbar like Gmail/Slack use: EDGE has been updated · Refresh · ✕.

• One line from 320px up; full width on phones (clears the iPhone home bar), compact bottom-left from 560px (sm here is 1000px, so tablets used to get the phone layout).
• Shown only inside signed-in portals: admin, photographer, and the owner/realtor connect portals (via ConnectHeaderLayout). Moved out of App.jsx, so login, public, resident-link and vendor-application pages never show it.
• Owner/realtor portals say "This page has been updated" (no EDGE name).
• Z_LAYERS.UPDATE_BANNER raised above the mobile launcher range: on a phone the launcher is My EDGE and covered the bar completely (same on prod today). The bar is bottom-anchored and toasts are top, so it doesn't cover them.

Tested
• Local FE + API + local DB, Playwright Chromium, signed in through each portal's login form: bar shows and is the top element at 1280 / 768 / 390px in admin, owner and realtor portals; no bar on any login page.
• Refresh reloads; ✕ dismisses.
• chromeStacking, portalMenuStacking, ConnectHeaderLayout, PageHeader, useNewerVersion tests pass; build passes.
• Not covered live: photographer portal (no photographer user in local DB).
Fix App

Loom 7 Oct layout pass (welcome photo, team, performance, comms, docs, resources)

#3729
Changes from Steve's Owner Connect review Loom ("UI Updates and Layout Fixes Review", 7 Oct), plus the dashboard reference he sent.

Dashboard
• Welcome card: the owner's first property photo fills the top of the card and runs down to the vertical centre of the Recent Wins / Equity Growth toggle, with a white gradient over it that never drops below 25%, so the dark greeting stays readable. The photo is the listing's primaryImage from leasing-updates; if that's missing it falls back to the AppFolio imageUrls[0]. Both come from queries the dashboard already makes. With no photo, the card looks as before.
• Property Performance: one bordered panel per property with no shadow. Photo and details are on the left; NOI, Cash Flow, Cash on Cash and Cap Rate tiles sit in a 2×2 grid on the right.
• Communication card: rows and dividers now sit inside the 24px card padding instead of running edge to edge.
• Your Team (owner view): a large photo or initials, phone numbers with a phone icon, and round call / email / text buttons. Call and text open tel:/sms: on the owner's own device, so owners never send through the company's Twilio line. The admin version is unchanged.

Communication page: uses the shared two-column grid. The left panel has a "Communication" header with the search box next to it, then Your Contacts (the same card as the dashboard). The thread opens on the right.

Docs & Inspections: Document Library is the narrow left panel; the categories sit in 3 columns on the right, and the inner tiles have no shadow.

Rewards & Resources:
• The Investor Resources card's corners now match the other cards, and its paragraph uses the same line spacing as the calculator cards.
Improvement App

Cherry-pick Pulse card close badge (#3725, #3728) to main

#3730
Cherry-picks of two staging merges to main, no conflicts:

• #3725 feat(owner-leads): close badge on the active Pulse Row card (colour coded per card)
• #3728 refactor(owner-leads): Pulse card X always renders, onReset takes no argument

FE only, no prod step. Owner Lead list tests 32/32 green on this branch, eslint clean on the touched files.
Fix App

Review notes from

#3727
Follow-up to #3726: fixes the two review notes on that PR.

• YourTeamCard now sets its own users icon inside SectionTitle. The icon prop is gone, so callers no longer pass icon="users". The isAgent IconButton branch was removed: it could never render, because the only agent caller (UpdateDetail) passes no icon.
• Prettier run on the lines #3726 added in CMA.jsx, PriceHistory.jsx and OwnerConnectHome.jsx.

No visual change.

Tested: eslint clean; vitest passes for OwnerConnect (23 tests) and AdminPortal/Lease; build passes.
New App

Show a bottom popup when a new version is deployed

#3612
What
When a new build is deployed while someone has EDGE open, a bar slides up from the bottom of the screen: "There's a new version of EDGE. Click to update." Clicking reloads the page. The × hides the bar for that version; a later deploy brings it back.

How it detects a new version
Every build gives the entry script a new hashed name (/assets/index..js on prod, main..js on dev). The tab remembers the module entry it booted from and re-fetches /index.html (cache: no-store plus a cache-busting query string) every 5 minutes and whenever the tab regains focus or becomes visible (at most once a minute).
• If the entry has changed, the bar shows. First it HEADs the new entry and requires a 200 with a javascript content type, so it is never offered mid-deploy.
• It keeps checking after a hit, so a rollback clears the bar.

Guards
• It never reloads by itself.
• It is hidden while a screen recording is rolling (useRecordingSession().isRolling), because reloading would lose the take.
• A tap on the bar doesn't count as an outside click for dialogs.
• The animation is disabled under prefers-reduced-motion.
• New UPDATE_BANNER tier in zLayers.js (1400): above dialogs and the recorder, below toasts.
• The inspections iframe (InspectionEdgeConsole) can post {__EDGE_INSPECT__: "updateAvailable"} when *its* build changes. Origin and source are checked as for every other message. EDGE then shows the same bar, and the reload refreshes the iframe too. It pairs with rentsolutions-app/inspections#136.
Fix App

Loom 10/07 cleanup — one card-title style, 24px grid, ProWorxx pill nav, avatar

#3726
Steve's Loom (7 Oct, "Owner Connect UI Cleanup and Spacing Fixes"), checked against every Owner Connect page.

What changed
• One card-title style on every Owner Connect card, copied from the PM Connect Documents card: a 44px brand-colour circle with a 22px lucide line icon, and an Outfit bold 24px title. It's built into the existing (unused) SectionTitle component plus a SectionTitleIcon export, so new cards get it for free. It replaced 25+ hand-rolled IconButton + h3 pairs, which had come in 3 sizes, 5 colours, and solid/outline/custom-svg icons. Cards that had no icon (Your Team, Quick Guide, Listing Report Card, Price, CMA's, Concessions) now have one, and cards that all reused the users icon now each have their own.
• Outfit is self-hosted (public/fonts/outfit-latin-v15.woff2) and exposed as Tailwind font-display, the same token name PM Connect uses.
• 24px grid: the column gap in TWO_COLUMN_PAGE_GRID goes from 48px to 24px (gap-standard-space). Resources now reuses that grid instead of its own copy. Performance Key Insights tiles go from 16px to 24px apart.
• Docs & Inspections: counts are centred under the titles, and every tile uses the same icon circle, which fixes the Insurance tile that sat 3px off.
• Top nav matches the ProWorxx admin sub-nav (measured live): full pill, 6×16 padding, 14px/500 text, 4px gap, hover turns the text the brand colour.
• Top-right avatar: AvatarCircle now renders the way the admin portal does: 32px, with the profile photo when there is one and initials otherwise. It used to be squeezed to 16px, …
Update App

Release: staging → prod (2026-10-07 #2)

#3724
Staging → main release.

• #3720 page-header one-line title row default
• #3653 toteboard Owner Leads tiles one row
• #3722 login: company host over own origin
• #3719 owner-connect shared two-column grid
• #3649 owner leads pulse row
• #3718 process-template milestone dot colour
• #3712 owner-connect communication

Ship after the BE release.
Update API

Release: staging → prod (2026-10-07 #2)

#3873
Staging → main release. No migrations.

• #3867 appfolio: guard APPFOLIO_REPORT_MOCK, scope to staging API
• #3865 appfolio: APPFOLIO_REPORT_MOCK staging stand-in
• expose video playback fields on owner communications

Deploy BE before FE.
Fix API

Lead-event alerts back to the agent; coordinator only on tour request, tour scheduled, ID failed

#3868
What

Lead-event alerts go to the leasing agent only again. Three alerts also go to the Leasing Coordinator:

• showing request received (showing-request-received-agent)
• showing scheduled (showing-scheduled-agent)
• ID verification failed (id-verification-failed)

The Property Manager and Leasing Manager no longer get any of these alerts.

util/leadCommunicationRouting.js: leadEventRecipientIds now returns [agentId] by default. A new withCoordinator: true option adds the coordinator (the lead's, else the property's), unique and non-null. leadTeamRoleLabel now only knows "Leasing Coordinator" (or null).

Why

• #3778 sent lead-event alerts to the agent and the Leasing Coordinator.
• #3794 (prod Oct 3) widened that to the Property Manager and Leasing Manager too.
• Cristina is Leasing Manager on 12 properties (about 110 leads) and is now flooded with every lead's alerts.
• Decision (Sri, Oct 7): back to agent-only, with the coordinator added on the three alerts above. Saket's Slack reply (Oct 7, 9:59 AM ET) also recommended narrowing.

Call sites (9)

| File | Alert | Recipients now |
|---|---|---|
| services/leasing/showingService.js _sendShowingRequestReceivedToAgent | showing-request-received-agent | agent + coordinator |
| services/leasing/showingService.js _sendShowingScheduledToAgent | showing-scheduled-agent | agent + coordinator |
| services/leasing/showingService.js (ID check failed path) | id-verification-failed | agent + coordinator |
| …
Fix API

Auto-task scheduling broken on the worker by an eventService circular require

#3872
On prod, the worker logged TypeError: eventService.createRecurrentEvent is not a function 12 times today, once per new process with an automatic "Send Email - Day 1" task.

Cause: a circular require. The worker loads eventService before processService. eventService requires the handler registry, which loads processService back mid-cycle. So processService's top-level require('../recurrentEvent/eventService') gets the empty exports, and eventService later replaces them with module.exports = new EventService(), so the binding stays {} for the life of the process. Reproduced by loading ./models + ./jobs in worker order: processService sees createRecurrentEvent === undefined. taskGenerationService hit the same cycle and already defers its require (getEventService).

It only surfaced today: until #3857, the same worker call stopped earlier at "Process not found".

Impact checked on prod (read-only): all 12 tasks still got their email. Task generation had already created each recurrentEvents row, and every task is isDone=true with exactly 1 event. So this was error noise from a redundant second scheduling attempt, not missed sends.

Fix
• Require eventService at the call site, with a comment naming the cycle.
• With the call now working, it would hit the existing unique index idx_recurrent_event_auto_task_dedup (confirmed on prod) whenever task generation scheduled the task first. A SequelizeUniqueConstraintError is now treated as already scheduled, the same way taskGenerationService treats it. No duplicate sends are possible either way.

Test…
v2026.10.06

Tuesday, October 6, 2026

1 new 7 fixes 12 total
Update API

Release: staging → prod 2026-10-07

#3864
Staging → prod release cut from origin/staging at 3b4a2d61c. 9 files differ vs main (owner-lead list/pulse, process delete/automation). If squash-merged, merge main back into staging afterwards.
Fix API

Rental card 7-day applications count uses applicationDate

#3863
Follow-up to #3861 (rental card badge counts).

• Rental card applications (7 days) count now filters on applicationDate, the field the Applications list's Filter dialog filters on, with an explicit [start, end] window that is also returned as stats.last7DaysWindow so the frontend's badge link uses the exact same bounds.
• The createdAtStart/End range added to the applications list endpoint in #3861 is removed: the frontend now links with applicationDateStart/End, which the dialog already supports.
• Showings 7-day count keeps its <= NOW() bound (contract test).
Fix API

Rental card counts and the lists they open agree

#3861
Loom 10/05 (Steve): clicking a rental card count opened a list with a different number (4324 W Gray St: card 3, list 2; prod sweep: leads total 229 vs 27, showings 46 vs 57).

• Card stats now return the exact 7-day window they counted (stats.last7DaysWindow).
• Lead list: leadPopulation=total = every non-archived lead (reuses totalLeadSql), the population the card counts.
• Application list: createdAtStart/End, the column the card's 7-day count uses.

Paired with the FE PR. Tested live locally: all 6 badges match (8/11, 3/7, 1/2) on a property seeded with declined/inactive/archived/canceled-stage leads and canceled + no-show showings. Related BE tests pass; 11 contract files fail identically on main.
Performance API

Stop loading every company profile to name a text thread's contact

#3859
Opening a text thread (GET /api/communication/conversations/:threadId) is the second most expensive call on prod: about 1,700 calls in the last few hours, averaging 0.6 s each.

Why it was slow: to name the contact in the thread header, findProfileFromPhoneNumbers loaded every owner id and every tenant-lead id in the company. It then queried phones with that whole list and matched the number in JS. That added about 200 ms to every thread open on prod.

Fix: the header now uses batchFindProfilesForThreads, the lookup the thread list already uses. It queries phones by number, scoped to the company. findProfileFromPhoneNumbers and _findProfilesByPhones had no other callers, so both are removed.

Side effect (a fix): the old lookup matched the exact string, so a contact saved as (813) 322-5831 never showed on a +1813… thread. The list path matches on the last 10 digits, so the header now names the same people the list does, newest first. The response shape is unchanged; it still goes through formatProfilesWithProgress.

Proof
• Prod, read-only, 6 recent threads: old lookup 190–400 ms, new 4–9 ms. Two threads match the same people. Four now find a second tenant lead on the same number, which the list already shows.
• New real-DB test tests/smsThreadDetailProfiles.test.js: a formatted phone in the company resolves; the same number under another company never surfaces. It fails on the old code.
• smsThreadProfileOrdering, smsThreadRecentMessageSender and communicationRoutes all pass.
Update API

Real uuid in the signature media scope spec (main CI red)

#3858
Main CI fails on tests/attachedMediaScope.test.js — *scopes the ids parsed out of the submitted HTML*. #3847 drops non-uuid media ids before the lookup, so the spec's media-B id never reached Media.findAll (firstCall was null). The spec now uses a uuid. Test-only change.
New API

New processes created inside a transaction never scheduled their automatic tasks

#3857
Why
Prod, 6 Oct 2026: 37 × Failed to schedule automatic tasks for process / Process with ID … not found (31 on the worker, 6 on the API). Every one of those processes exists; I checked 4 read-only, and all 4 are there.

createProcess fires executeAutomaticTasksForProcess(process.id, …) without awaiting it, right after creating the row. That function reads the process through getProcessById on its own connection. When createProcess runs inside a caller's transaction (isExternalTransaction), the row isn't committed yet, so the read finds nothing and throws. The stage's automatic tasks are then never scheduled.

What
When the transaction is external, the call is deferred to transaction.afterCommit, the same pattern the marketing-cycle emit a few lines above already uses. Without an external transaction, behaviour is unchanged.

Tested
• New tests/processAutomaticTasksAfterCommit.test.js, against the real DB: it creates a process inside a caller transaction and checks whether executeAutomaticTasksForProcess can see the row.
- main: [false]
- this branch: [true]
• Existing process tests still pass: processCreateResponseAbility 3/3, processDuplicateSubmissionGuard 2/2, updateProcessTransactionAtomicity 6/6, subprocessTenantIsolation 11/11, taskTitleRenderAtCreation 10/10, processDueTimeUtc 16/16.

Not done
Processes already created this way are missing their automatic tasks. Re-running them now would send any automatic SMS or email late, so that's a separate decision; no backfill is included.
Fix API

Owner balance and start-date syncs use company-wide reports instead of 2,400 rate-limited calls

#3856
Why
Prod worker, 6 Oct 2026: the three nightly owner syncs (started by recurring events, around 07:16–07:46 UTC) each called AppFolio once per owner, about 2,400 calls each. They used raw axios, bypassing the shared Report API throttle, so AppFolio answered with 429s:
• SYNC_OWNER_CASH_BALANCE: 181 owners updated, 2,230 failed.
• [Owner Tenant Balance] API error: 1,291 failures.
• [Owner Management Start Date] API error: 1,182 failures.

So owners.ownerPortfolioCashBalance, portfolioTenantBalance and listedWithUs are mostly stale.

What
Each sync now pulls company-wide reports through the throttled createAppfolioReportClient and computes each owner's value from the properties they own:
• Owner map. New getPropertiesByOwnerId in appfolioReportService makes one property_directory call and groups properties by owner_i_ds. That field holds the same numeric id an owner's appfolioLink ends in; multi-owner properties list them comma-separated.
• Cash balance. trial_balance_by_property (paged, 4 pages / 16k rows on prod) replaces the per-owner trial_balance calls. extractOperatingCashBalance now sums every matching account row, because rows are per property.
• Tenant balance. One delinquency call replaces the per-owner calls; rows are summed over the owner's properties.
• Management start date. Uses the earliest management_start_date across the owner's properties from the same own…
Fix API

Re-syncing a BCC-only email no longer fails and pins the sync cursor

#3855
Why
Prod worker, 6 Oct: mailbox 688e67ab… logged 197 [IMAP Sync] Failed to save message — skipping errors with notNull Violation: EmailMessage.recipientEmail cannot be null. The same message failed on every run, and each failure pins the IMAP UID cursor, so the mailbox re-downloads from that point every run.

The update path for an already-stored email writes recipientEmail: message.toEmail and senderEmail: message.fromEmail raw. A BCC-only or undisclosed-recipients email parses to toEmail: null. Both columns are NOT NULL and isEmail. The create path already guards this through isStorableEmail in createFromGmailData; the update path never did.

What
In the update, senderEmail and recipientEmail take the new value only when isStorableEmail passes. Otherwise they keep the stored value, using the same predicate the create path uses.

Tested
• Local backend, real DB: stored an email, then ran imapSyncService.saveMessages with the same message and toEmail: null.
- main: minFailedUid 9, saved 0, so the cursor is pinned.
- this branch: minFailedUid 0, saved 1, and the recipient is kept.
• tests/emailSyncTrashOwnership.test.js: 42/42, with 1 new case that fails on main.
Fix API

Make message ids unique per mailbox — a shared email never saved to the second mailbox

#3854
Why
On prod today (6 Oct) the worker logged about 2,000 [IMAP Sync] Failed to save message — skipping errors across 4 mailboxes. Each failing message is retried on every sync (about 190 times today each), because a failure pins the IMAP UID cursor.

Cause: prod still has emailMessages_providerMessageId_unique, a unique index on providerMessageId alone, across all mailboxes. For IMAP, that id is the email's Message-ID header, which every recipient of the same email shares. When two connected mailboxes receive one email, the second mailbox can never store it. Gmail hit the same wall earlier (NODE-EXPRESS-13); it was worked around by counting the clash as a "duplicate", so the second mailbox silently never shows the email.

Uniqueness per mailbox is already enforced by email_messages_account_provider_message_unique (mailAccountId, providerMessageId). That is the key every dedup lookup and createFromGmailData's race recovery use.

What
• New migration 20261006170000: DROP INDEX CONCURRENTLY of the global index, with lock_timeout 3s. It runs only if the per-mailbox unique index exists and is valid; otherwise it logs and keeps the global one. down recreates it as UNIQUE, which fails if two mailboxes have since stored the same message.
• tests/_shared/ci-schema.sql: removed the global index.
• gmailWebhookDuplicateHistoryId.test.js: the cross-account case now expects the second mailbox to store its own copy. Added an IMAP case: saveMessages on the second mailbox saves the message with minFailedUid === 0, so the cursor is no longer pinned.

• The SendGrid event webhook looked rows up by …
Fix API

Tell the user to reconnect when Gmail rejects a send for missing permission

#3853
Why
On prod today (14:46 UTC) a user sent the same direct email 3 times and got a 500 each time: "Failed to send email: Failed to send email". The real cause was in the log: Google answered 403 Insufficient Permission, meaning their Gmail connection doesn't have send permission. Reconnecting fixes it, but nothing told them to.

What
• gmailSendService.sendEmail: a Gmail 403 for a missing scope becomes a 403 that says to reconnect the Google account. It reuses the existing gmailMessageService._isMissingModifyScopeError check. Every other failure is unchanged (500).
• Direct email already turns a "reconnect" error into a 401 "please reconnect your Google account", so the composer now shows that message instead of a 500.

Tested
• tests/gmailReadStatusSync.test.js: 14/14, with 2 new cases:
- Gmail's 403 becomes a reconnect error.
- Direct email returns 401 with the reconnect message.
• The new send test fails against the old code.

Not tested
• A live send. That needs a Gmail account connected without the send scope, which I can't set up locally.

---

Also: lighter qualifying-questions check (public booking page)
Why. GET /api/book-showing/leasing-property/:id/tenant-lead/:tenantLeadId/qualifying-questions (160 calls/day on prod) loaded the full property, with all of its leads and a fresh AppFolio photo fetch, only to check that the property exists. On this public route there is no req.user, so the photo fetch fails with "Company not found". That is the ~45 [DEBUG] Error fetching fresh photos from Appfolio errors a day in the prod logs.

What. It now checks t…
Performance API

960 px photo thumbnails (thumbUrl) for cards

#3852
Why
On a phone over Slow 4G, the public booking page (/book-showing/:id) was 2.6 MB and took 18 s to load (Playwright, iPhone 13 profile, 6 Oct). Most of that was AppFolio photos: each is our 1600 px CDN copy at 400–570 KB, while the card shows it at about 252–360 px. The admin rental list shows one such photo per property card.

What
• The existing background resize now also makes a 960 px / q72 copy (appfolio-photos/-960.jpg). It is made from our own 1600 px CDN copy, never from AppFolio's 5-minute signed URL. It uses the same queue, concurrency (2) and cap.
• Once the copy exists, each photo gets thumbUrl. url stays the full-size copy, so the preview and lightbox are unchanged.
• No backfill is needed: thumbnails are made on first view, the same way the 1600 px copies are.

Real prod photos re-encoded the same way: 567 → 171 KB, 569 → 174 KB, 394 → 121 KB, 198 → 64 KB (about 70% smaller). 960 px still covers a 360 px card on a 3x phone.

Tested
• tests/appfolio/appfolioPhotoListCache.test.js: 7/7, with 2 new cases:
- The thumbnail is made from the CDN copy (source URL and s3Key asserted) and returned as thumbUrl once it exists.
- No thumbnail is ever made from an AppFolio link.

Not tested
• Local runs can't reach AppFolio. After deploy I'll check thumbUrl and the page weight on the prod booking page with Playwright.

The FE PR uses thumbUrl and falls back to url, so either can merge first.
Fix API

A profile photo must be the caller's company's media

#3851
Why
updateUser checked profileMediaId with Media.findByPk, so any company's media id was accepted. A user could set another tenant's uploaded file as their avatar, and it would then be served wherever that avatar shows. liveSupportMediaId, a few lines below, is already scoped to req.user.companyId for exactly this reason.

What
profileMediaId is now looked up with the same { id, companyId: req.user.companyId } check. Another company's media gets the existing 404 ("Profile media not found").

Tested
• New tests/profileMediaCompanyScope.test.js (2/2), using real company, user and media rows. Only the S3 resize is stubbed.
- Another company's media gets a 404, and profileMediaId is unchanged.
- The user's own company's media is saved.
• Mutation check: dropping the companyId condition makes the cross-company case fail.

Related: no PR
resize-old-profile-photos-1006.js is handed over in the workspace root. It shrinks the one prod avatar that predates the upload resize: a 2.2 MB PNG uploaded on 5 Oct, two hours before #3826 deployed. That one photo made My EDGE 6 MB and 35 s on Slow 4G. I ran its dry run on prod: 79 profile photos, 1 over 300 KB.