Production releases and updates to the RentSolutions platform.
New -> Contacted stage move that fired every time any outbound communication reached a tenant lead (TenantLeadService.updateLastContactDate). The lastContactDate stamp stays._getManualContactedLeadIdsSubquery).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.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.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.resolveViewerBooking fell back to the company scheduler (Mike).null when the field is blanked, and updateUserBookingSlug cleared the slug.generateUniqueBookingSlug createUser uses). 400 only if the name can't produce a slug.booking_slug = 'cristina-santiago' (only staff user on prod without one).{}/null) set to default Mon–Fri 9–5 (their booking button was hidden). Revert SQL kept locally.companyTimezone from the API, falling back to the stored zone.timezoneStore: reuses DEFAULT_TIMEZONE.src/api/scheduleApi.js.companyTimezone. Lead groupings (upcoming showings, move-ins) are by company day. The guided-session line prints the company zone.completedAt and default due/scheduled dates use the company's date.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._buildShowingQueryConditions, ownerUpdate breakdo…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.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.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.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.verifyProworxxHmac. The user lookup is scoped to the paired company and to active users, and the email match ignores case.conversation_thread_id, direction). Other companies are skipped before any query runs.tests/proworxx/proworxxRealtorTexts.test.js, 5 tests, real DB and signed HTTP, Twilio client stubbed:max-w-screen-xl (1280px), so on wide screens the Privacy / Terms links stopped well short of the body cards' right edge.max-w-adminMaxWidth (1716px, centred) with the body's px-8 padding.DefaultFooter: copyright stays centred; two links sit bottom-right (stack under the copyright on mobile)./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.git cherry-pick -x. Clean pick, no conflicts. The three files match origin/staging exactly.npx vitest run src/hooks/leasing src/pages/BookShowing → 11 files, 61 tests passed. ESLint clean on the changed files.git cherry-pick -x, in order, no conflicts):669c864368 (#3889) feat(leasing): offer the slot right after an on-site showing inside the scheduling noticed77369fd4c (#3896) fix(leasing): keep same-site follow-on showings from auto-cancelling26e404e936 (#3899) fix(leasing): unconfirmed-showing alert no longer says a follow-on will be auto-cancellede54e3652a2 (#3902) fix(leasing): 1h unconfirmed alert stays truthful when auto-cancel is off and survives a lookup errororigin/staging after the picks.tenantLeadId / showingId; the validator on the older API rejects unknown keys.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.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.UserDetail/_components/UserDetailRouteGuard.jsx, following EdgeAIRouteGuard. This clears the react-refresh warning in settingsRoutes.jsx.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.flex-auto instead of flex-1, so each tab sizes to its label and they split the leftover space. "Voicemail" gets the most room.gap="none"). Each tab already has its own pill padding, which is how the iOS tab bar does it.left-2 right-2 instead of left-3 right-3, so it is 8px wider.whitespace-nowrap, so "Voicemail" stays on one line. Sri asked for no second line./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.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.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.Settings/User, routes, components/molecules: 86/86.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.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.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.resolveTargetUser. Self needs no permission; another user still needs administrator plus an instance-level CASL check.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/…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.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.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.getTypeColor.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._components tests pass (251/251). Prettier flags growConstants.js and ScoreboardCard.jsx, but those files were already unformatted on main, so I left them.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._components tests pass (251/251).SectionTitle's icon prop. No more centred icons on top.secondary) instead of dark.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.REFERRAL_TYPES.NETWORK_LEVEL_CONFIG.CategoryList's existing type + getTypeColor. The Referrals sidebar items are now derived from REFERRAL_TYPES instead of restated.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.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.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.git diff --check: pass.New; live success alone cannot replay that fixture.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.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.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.TWO_COLUMN_PAGE_GRID (24px gaps instead of 48px, 425px left column).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.
queuedEvents):endpoint_missing: ProWorxx's realtor endpoint wasn't deployed yet.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.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.proworxxRealtorTwoWaySync.test.js:updatedAt untouched;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).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.showInactives=yes + list refetch without status=active; Cancel → unchanged.page (from the URL's existing page param) to the list query; page size stays EDGE's 25.Pagination under the list when there is more than one page (same pattern as the program-detail Advocates tab).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.?page=N link still opens on page N.git cherry-pick -x bcb0f7288). Tree matches the staging merge commit exactly.git cherry-pick -x 1d640c7e1). Only tree difference from staging is #3739, which is not part of this pick.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.kanban-column-scroll style.-mr-6 pr-6), so the scrollbar never sits over content. The main page scrollbar is untouched.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.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.getLeasingPropertyTeam now also returns each member's bookingSlug (users.booking_slug, the value staff set under Company Settings → My personal booking link).GET /api/owner-portal/dashboard/team and in the owner leasing detail team[], so "Let's Talk" can open /schedule/ (frontend #3735)./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.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.bg-gray-800 → bg-rsos-gray-darktext-blue-300 → text-rsos-blue-light-2 (Refresh)sm here is 1000px, so tablets used to get the phone layout).ConnectHeaderLayout). Moved out of App.jsx, so login, public, resident-link and vendor-application pages never show it.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.chromeStacking, portalMenuStacking, ConnectHeaderLayout, PageHeader, useNewerVersion tests pass; build passes.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.tel:/sms: on the owner's own device, so owners never send through the company's Twilio line. The admin version is unchanged.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.CMA.jsx, PriceHistory.jsx and OwnerConnectHome.jsx.OwnerConnect (23 tests) and AdminPortal/Lease; build passes./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).useRecordingSession().isRolling), because reloading would lose the take.prefers-reduced-motion.UPDATE_BANNER tier in zLayers.js (1400): above dialogs and the recorder, below toasts.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.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.public/fonts/outfit-latin-v15.woff2) and exposed as Tailwind font-display, the same token name PM Connect uses.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.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, …showing-request-received-agent)showing-scheduled-agent)id-verification-failed)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).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 |TypeError: eventService.createRecurrentEvent is not a function 12 times today, once per new process with an automatic "Send Email - Day 1" task.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).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.eventService at the call site, with a comment naming the cycle.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.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.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.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.<= NOW() bound (contract test).stats.last7DaysWindow).leadPopulation=total = every non-archived lead (reuses totalLeadSql), the population the card counts.createdAtStart/End, the column the card's 7-day count uses.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.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.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.(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.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.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.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.transaction.afterCommit, the same pattern the marketing-cycle emit a few lines above already uses. Without an external transaction, behaviour is unchanged.tests/processAutomaticTasksAfterCommit.test.js, against the real DB: it creates a process inside a caller transaction and checks whether executeAutomaticTasksForProcess can see the row.[false][true]processCreateResponseAbility 3/3, processDuplicateSubmissionGuard 2/2, updateProcessTransactionAtomicity 6/6, subprocessTenantIsolation 11/11, taskTitleRenderAtCreation 10/10, processDueTimeUtc 16/16.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.owners.ownerPortfolioCashBalance, portfolioTenantBalance and listedWithUs are mostly stale.createAppfolioReportClient and computes each owner's value from the properties they own: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.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.delinquency call replaces the per-owner calls; rows are summed over the owner's properties.management_start_date across the owner's properties from the same own…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.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.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.imapSyncService.saveMessages with the same message and toEmail: null.minFailedUid 9, saved 0, so the cursor is pinned.minFailedUid 0, saved 1, and the recipient is kept.tests/emailSyncTrashOwnership.test.js: 42/42, with 1 new case that fails on main.[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.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.email_messages_account_provider_message_unique (mailAccountId, providerMessageId). That is the key every dedup lookup and createFromGmailData's race recovery use.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.403 Insufficient Permission, meaning their Gmail connection doesn't have send permission. Reconnecting fixes it, but nothing told them to.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).tests/gmailReadStatusSync.test.js: 14/14, with 2 new cases: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./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.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.thumbUrl. url stays the full-size copy, so the preview and lightbox are unchanged.tests/appfolio/appfolioPhotoListCache.test.js: 7/7, with 2 new cases:s3Key asserted) and returned as thumbUrl once it exists.thumbUrl and the page weight on the prod booking page with Playwright.thumbUrl and falls back to url, so either can merge first.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.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").tests/profileMediaCompanyScope.test.js (2/2), using real company, user and media rows. Only the S3 resize is stubbed.profileMediaId is unchanged.companyId condition makes the cross-company case fail.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.