Production releases and updates to the RentSolutions platform.
npm run dev:staging was connecting to the production database, not staging.dev:staging ran bare nodemon app.js and relied on config's process.env.NODE_ENV || 'staging' default. But app.js calls dotenv.config() (bare .env) at startup, and a local prod-shaped .env (NODE_ENV=production, prod DB URL) sets NODE_ENV=production before config reads it. config/database.js then resolves to .env and loads it with override: true → prod DB. Result: a script literally named dev:staging silently pointed at prod.NODE_ENV=staging MODE=staging in the script so it always loads .env.staging, independent of the bare .env. dotenv won't override an already-set var, and database.js's override-load then pins the staging DB values.start is intentionally left as bare node app.js (prod servers legitimately use the bare .env)..env — is a setup concern outside this diff; this change makes dev:staging safe regardless.npm run test:sync → tests/_shared/syncTestDatabase.js, which calls sequelize.sync({ force: true }). That helper trusted .env.autotest to point somewhere safe, but never verified the connection target. With no .env.autotest present it fell back to the default env (.env → staging RDS) and dropped/recreated every table — emptying staging's core tables (users, companies, owners, properties, tenants) and taking the app down (login → 500).tests/_shared/syncTestDatabase.js (the force: true schema wipe) and the test:sync npm script that invoked it.scripts/database-sync/ (old prod→local sync tool that drops/overwrites databases; unused).tests/_shared/assertSafeTestDb.js — refuses any TRUNCATE/destructive op unless the live connection is a local host AND a test-named DB. No override flag.TRUNCATE in testSetup.js (mediumCleanup/fullCleanup).USE_LOCAL_DB=true npm run migration:apply for test-schema setup.contractService.syncContractStatus was gated to the application-approval flow (contractType === 'exception' && parentType === 'application'), so when an owner signed, the assigned agent never heard about it. (Real case: agent reported "he signed but I didn't get notified.")signer_signed / document_completed webhook for an ownerLead contract, notify the single responsible agent:signer_signed → "{signer} signed — X of N … still waiting on …"document_completed → "{contract} fully signed".leasingAgentId, falling back to assignedToUserId (many ownerLeads — including the case that prompted this — only populate assignedToUserId).ownerLeadContractSignedNotificationCore.js (new) — pure, fully unit-tested decision logic.contractService.js — dispatch on the two events for ownerLead contracts; best-effort, never blocks the webhook ack.notificationTemplates.js — two new templates (ownerlead-contract-signer-signed, ownerlead-contract-completed); in-app + email.[])./units returns Amenities ([{Name,Price}]), ApplicationURL, YouTubeURL. The mapper read AmenitiesIncluded, ApplicationUrl, YoutubeUrl → always empty.customFields, not real columns. unit_amenities / unit_appliances / unit_utilities were stored in a JSON blob the Property Profile UI never reads, instead of amenitiesIncluded / appliancesIncluded / utilitiesIncluded. youTubeUrl (wrong casing) was silently dropped.unit.update(payload) with utilitiesIncluded/appliancesIncluded/parkingOptions/petsAllowed forced to [] (reading non-existent keys), so every webhook wiped values the nightly Report sync or an agent had set. This is why fixes "disappeared."unitAppfolioService.js (Data API): read Amenities/ApplicationURL/YouTubeURL correctly; map Amenities[].Name; omit the 16 fields App…executeCreateProcessTask inherited roles only from the *source process's* processRoles. Those are populated by syncProcessRolesFromOwnerLead, which writes a role only if the owner-lead's own template defines it as a slot.PreQualify.jsx) always shows PM / Assistant PM fields regardless of template. So a PM assigned on such a lead lives only on the OwnerLead FK column, never reaches processRoles, and was silently dropped by the auto-pipeline — even though Owner Property Onboarding and Property Marketing both define a Property Manager slot.getProfilePreview#suggestedRoles, so manual creation carried PM while the automatic spawn did not.executeCreateProcessTask, in addition to inheriting from the source process's processRoles, pull role assignments straight from the linked owner lead (reusing the existing getSuggestedRolesFromOwnerLead) and merge by roleId:/admin-portal/process) is stuck on page 1 — navigating to page 2 instantly snaps back to page 1.SearchBar's debounce effect re-fires onChange(localValue) on mount and on every render (its onChange prop is an unstable reference). With an empty search box this calls handleSearchChange("") → handleFilterChange({ search: null, page: "1" }), forcing page back to 1 ~300ms after any render. So ?page=2 reverts to ?page=1.page, so filtering while on a later page could land on an out-of-range empty page.handleSearchChange now ignores no-op calls where the search term is unchanged. *(primary fix)*page: "1", matching search/agent/sort.ProcessList.jsx.?page=2 reverted to ?page=1; page 2 showed page 1's rows.?page=2 sticks and shows the genuine tail of the list; page "2" active, next-chevron disabled.?page=1&category=open.roleId matches that exact role slot — e.g. the "Leasing Agent" slot shows only leasing agents, the "PM" slot shows only PMs.src/pages/AdminPortal/Process/_components/ProcessCard.jsx — the per-role options now filter users by u.roleId === role.roleId before mapping.processRoles (each has a roleId that is a company-role id); users carry a matching roleId, so the filter is a direct equality.PUT /properties/{id}):CurrentManagementFeePolicy. The Data API integration role cannot write this field at all (AppFolio's GET doesn't even return it). Sending it — even as an empty string — fails the whole PUT, and propertyService re-throws it as a 400 and rolls back the local edit. The create path already guarded the empty case; update didn't, so every edit without a policy hit it.''/[]/null) overwrites AppFolio's value, while an omitted field is preserved. The mapper emitted every field with empty defaults from our (sparser) local record, so simply removing the 403 would make updates succeed *and wipe* fields like Amenities. Verified live: sending Amenities: [] cleared 22 amenities; omitting the key preserved them.mapPropertyDataToAppfolioFormat only includes CurrentManagementFeePolicy when there's a value (consolidates the inline guard create carried).updatePropertyInAppfolio prunes empty fields before the PUT and never sends CurrentManagementFeePolicy, so updates succeed and only fields we actually have values for are written — no clobbering AppFolio data.nameFirst is "Elizabeth " (trailing space). The public booking page (PlaidVerification.jsx) calls createLinkToken first, which passed the name to Plaid untrimmed. Plaid rejects with:400 user.name.given_name must end with a non-whitespace character.
createIdVerification and retryIdVerification already trim + validate the name (the NODE-EXPRESS-1S fix). createLinkToken was missed — and it's the *first* Plaid call in the self-show flow, so the whitespace never even reaches the methods that would have cleaned it.createLinkToken:given_name / family_namecreateLinkToken returned 400 before the change. With the trim applied, the whitespace is stripped before the Plaid request, so the link token issues normally.PATCH /api/process/instances/:processId, both flagged unhandledPromiseRejection: true — reported by Elaine via Loom ("I fill out the data form on Edge, it's not saving, and it erases everything"):APIError: Field holdingFeeAmount must be a valid numberAPIError: Field hoaApprovalRequired must be true or falseProcessService.validateCustomFieldValue:async but has no await. Its three callers invoke it without await (two inside synchronous forEach), so any thrown APIError became a rejected promise that escaped the controller's try/catch as an unhandled rejection instead of a clean 400 — the save crashed and the form lost all entered data.holdingFeeAmount as "1000", hoaApprovalRequired as "true"), but the validator demanded a real number/boolean and rejected them. So even without the crash, the save could never succeed.validateCustomFieldValue synchronous — throws now propagate normally and are caught by the controller (clean 400).NUMBER accepts numeric strings → Number
PATCH /api/process/instances/:processId, both flagged unhandledPromiseRejection: true:APIError: Field holdingFeeAmount must be a valid numberAPIError: Field hoaApprovalRequired must be true or falseProcessService.validateCustomFieldValue was declared async but its body contains no await — it only ever throws synchronously. All three of its callers invoke it without await, two of them inside synchronous forEach callbacks:processService.js ~L1421 (createProcess) — forEachprocessService.js ~L2003 (updateProcess) — forEachprocessService.js ~L2439 (processProcessFields) — for-ofasync, every validation throw became a rejected promise that nobody awaited. The rejection escaped the controller's try/catch and surfaced as an unhandled promise rejection instead of a clean 400. As a side effect, the forEach kept iterating and wrote the invalid value anyway.async. The validator is now synchronous, so its throws propagate normally through the forEach/for-of callers and are caught by the controller — returning a proper 400 and blocking the invalid write. No caller awaited it, so no call sites need to change.SlideOutPanel from overlapping the EdgePhone and Chat Assistant panels by offsetting its width when those panels are openSIDE_PANEL_OFFSET inline in SlideOutPanelGET /api/leasing-properties 500s with invalid input syntax for type uuid: "undefined".?leasingAgentId=undefined) cause the shared useFilterParams hook to return the literal string "undefined". It's truthy, so callers (e.g. RentalList) forward it as an API filter, and it lands in a UUID column → Postgres 500.useFilterParams, treat literal "undefined"/"null" URL values as absent and fall back to the default. Fixing it at this shared source covers every page that reads URL filters (rentals, showings, applications, feedback, etc.), not just the one endpoint.Failed to update unit in Appfolio: Only one of the following parameters is permitted: MarketRent (recommended), ListedRent, or ListedMarketRent when saving the Property Profile (reported by Joe Nunez on 2411 W Fig St Unit 1).mapUnitDataToAppfolioFormat (shared by unit create and update) always put both rent keys in the PATCH payload:ListedRent: unitData.listedRent || null,
ListedMarketRent: unitData.marketRent || null,
null, the key is still present, so AppFolio rejected every unit save that ran through this mapper.priceHistoryService / leasingPropertyService):appfolioUnitPayload.ListedRent = unitData.listedRent || null;
appfolioUnitPayload.AdvertiseUsingListedMarketRent = true;
update() call in ensureTwimlApp that was overwriting the TwiML app voice URL on every fresh process startfetch() that only validates the SID existscachedTwimlAppSid to validatedTwimlAppSid to reflect the actual behaviournew-communication-from-profile) fires only when an inbound email/SMS is matched to a lead (sender == lead's stored email). Portal leads (Zillow/Realtor/etc.) arrive from masked relay addresses that never match, so brand-new leads were never alerted. Prod (1 company, 14d): 665 leads (465 Zillow), 11,678 inbound emails, only 78 matched. Roberto 0/51 alerted, Jennifer 1/63. Not a regression — structural.new-tenant-lead-assigned template (email/sms/in-app).leadAssignmentNotificationService.notifyTenantLeadAssigned helper (fire-and-forget, respects UserNotificationPreference).afterCreate via transaction.afterCommit — covers every creation path (manual, portal/webhook, bulk).updateTenantLead + batchReassignTenantLeads alert the new agent post-commit.owner-lead-agent-assigned from updateOwnerLead, with proper role labels). No duplicate mechanism added.SlideOutPanel (leads detail, course panels, etc.) now closes the EdgePhone on open, guarded so it won't close mid-callAdminPortalLayout enforces mutual exclusion between the phone, AI chat, and CallQ edge panels — opening any one closes the othersuseRef prev/curr diff instead of three separate single-dep useEffect hooks, which had a closure-stale cascade that caused the chat panel to immediately close after opening when the phone was already openensureTwimlApp had a fallback that searched for a TwiML app by friendly name (RentSolution Webphone) when the configured SID failed to updateWEBHOOK_BASE every time a staging user hit the token endpoint with an invalid/mismatched SIDTWILIO_TWIML_APP_SID is now required; a missing or unreachable SID logs an error and returns null instead of auto-discovering and clobbering whatever app it finds400 with a real message), and the axios interceptor *does* call showErrorToast. But showErrorToast in @utils/toast is gated behind VITE_NODE_ENV === "local", so it's a no-op in every non-local environment. The error was generated and then thrown away before the user could see it — looking like a silent failure.400 on the unit PUT and observing zero feedback.)PropertyProfileCard.jsx:if (isEditing) return; so an in-flight refetch no longer clobbers unsaved input mid-edit.useEffect watches profilePreview.suggestedRoles (added to the GET /api/process/profile-preview response on the backend) and fills the role assignment selectors. It only fills slots the user has not already set, so manual choices are never overridden. No effect in edit mode.suggestedRoles to the profile-preview response.createProcess automation tasks (e.g. owner-lead onboarding spawning *Property Marketing* / *Property Onboarding*).automationService.executeCreateProcessTask — before creating the child process, map the source process's processRoles onto the target template's role slots by roleId (a shared Role entity reused across templates) and copy the assigned userId. Roles unique to the source are dropped; roles unique to the target stay unassigned. This also avoids createProcess#detectRoleKeys throwing on a roleId the target template doesn't define.processService.getProfilePreview — new helper getSuggestedRolesFromOwnerLead returns suggestedRoles derived from the most-recent owner lead linked to the selected owner (read from the owner lead's denormalized role columns, not its processRoles JSONB which can be the legacy {}). getProfilePreview now includes suggestedRoles so the frontend New Process card can pre-fill role selectors. linkedProfiles output is unchanged.showClearIcon prop already implemented on Field/Input and Dropdown — no new components or custom styling. The icon only renders when a value is set and the field isn't disabled; clicking it fires the field's existing onChange with an empty value.role_1 process-role slot. On the company template whose role_1 is the Leasing Manager role, this dropped the creating user into the Leasing Manager slot while Leasing Agent / Property Manager stayed empty.role_1.* / role-name merge fields), which read process.processRoles directly. (The owner-lead PreQualify section reads the leasingManagerId FK — which stays null — so it correctly showed blank; that mismatch is what made this confusing.)createOwnerLead injected { key: 'role_1', roleId, userId: leadOwner } into processRoles at creation. Prod leads in early stages (New, Made Contact, Active Nurturing, Sent Agreement) already carried role_1 = Leasing Manager = lead owner — proving it's set at creation, not at the stage move.role_1 auto-assignment. Role people-assignments now start empty and are set deliberately. The Lead Owner is unaffected — it's still persisted via assignedToUserId and process.processOwner, independent of any role slot.role_1 was used as a generic owner slot will now show that role blank by default (intended).view URL param. The Rentals page uses a view=Badge value that can persist in the URL when moving between sections (also reachable via bookmarks/back-button). On the Lead Pipeline, Badge matches neither the Pipeline nor List toggle option, so the render fell to the empty-DataList branch (whose query is disabled) — leaving the board blank with no toggle selected.EmailThread.subject and EmailMessage.subject are varchar(255), so the insert in EmailThread.findOrCreateByProvider threw:SequelizeDatabaseError: value too long for type character varying(255)
historyId in a retry loop — same failure class as the previously fixed NODE-EXPRESS-13/27.beforeSave hook on both EmailThread and EmailMessage that caps subject at 255 chars on every write path (create, findOrCreate, and the update calls in saveMessages). Both are fixed because the thread insert failed first — fixing only the thread would just move the same error to the subsequent message insert.saveMessages, compare the incoming subject against its truncated form so an overlong subject no longer looks 'changed' and thrash a redundant thread update on every sync.SlideOutPanel → Modal. The Modal backdrop is z-50, which sits above the top navbar (header z-40, nav-tabs z-30). The navbar's hover dropdown menus are confined to the navbar's own stacking context, so when a SideCard was open the menu dropdowns rendered behind it — the panel overflowed the menu hover states.SlideOutPanel already offsets its backdrop to start below the navbar (top: navHeight), signalling the navbar should stay above the panel; only the z-index was missing.!z-[25] — above page content (z-20) and below the navbar (z-30/z-40) — so navbar menu dropdowns render on top. One-line change in the root SlideOutPanel, so every SideCard is covered.InnerBox body wrapper — min-h-0 overflow-y-automax-h-16 overflow-y-autoInnerBox body wrapper's scroll for this single instance via bodyClassName="!overflow-y-visible", leaving the textarea as the sole scroll owner. Scoped to this instance only — no other InnerBox usage is affected.groups.approved / groups.declined) on the surviving group only, and deletes the orphaned solo group (its live fetch 404s). So our solo Application never receives a terminal event, and refreshApplicationFindigsCore has no removal logic — the row sits at in-progress indefinitely and clutters the In Progress toteboard.setStatusApproved / setStatusDeclined), we detect orphaned solo siblings and cancel them, recording the surviving group's outcome (Approved/Denied) in the cancel note:supersedeMergedSiblingApplicationsCore (dependency-injected, unit-testable like refreshApplicationFindigsCore) finds open apps in the same company sharing an applicant email but in a different Findigs group._applyMergedSiblingOutcome cancels via the existing cancelApplication (history log +…tenantLead process template, afterCreate silently skips process creation. The lead ends up with processId = null, never resolves to an active stage, and is invisible in the active pipeline / leads list (only shows under "Show Inactives"). This was logged locally only and went unnoticed.Sentry.captureMessage (level warning) in the no-template branch of TenantLead.afterCreate, with the tenantLeadId + companyId, so process-less leads are caught.bulkCreate) never run afterCreate at all, so they won't be caught here — those must go through createTenantLead / upsertTenantLead (hooks on). Surfacing that class is a separate follow-up (implementing the tenant-lead import branch, currently a TODO stub in leadImportHandler).+ IconButton has existed since the page was built but never had an onClick, and createApplicationMutation was never invoked anywhere — so manual application creation was impossible in the UI.POST /applications only needs a tenantLeadId (the lead already carries property/unit/company context), so a lead picker is sufficient.ApplicationListNewCard.jsx (new): a CardWindow with a searchable Dropdown (server-side onSearch) to pick a tenant lead, then createApplicationMutation → navigate to the new application.status: 'active').useTenantLeads.js: extended useTenantLeadSearch to apply defaultFilters to the search branch too (eligibility applies while searching, not just the initial list). No other consumers.ApplicationList.jsx: import the card, isCreateAppOpen state, wire the existing + onClick, render the card. Existing canCreate() && hasApplicationListFullAccess gating preserved.processRoles stored as {} (a JSON object) instead of [] (an array). Backend readers iterate it (for…of / .find / spread), so they threw processRoles is not iterable, 500ing GET /instances/:processId/tasks and /tasks/:id/instruction-details → the detail page's tasks panel span forever.Process.processRoles is documented and consumed as an array, but the column defaultValue was {}. The tenant-lead afterCreate hook builds the roles array from the lead's assigned Leasing Agent/Manager/PM, then did:processRoles: processRoles.length > 0 ? processRoles : undefined
undefined, so Sequelize wrote the {} default. Companies that assign agents on their listings always produced a non-empty array and never hit this; companies/properties with no roles assigned (e.g. Ellis manual leads) did.models/processModel.js — column default {} → [] (the real fix).models/leasing/tenantLeadModel.js & services/leasing/tenantLeadService.js — always pass the roles array (drop the : undefined) so the create/update→create paths never fall back to the column default.processRoles to [] (jsonb_typeof <> 'array'), idempotent, lossless …created/skipped/failed. A re-import of an existing portfolio is update-heavy — almost nothing is "created" — so the screen showed "0 created" + red bars even when thousands of rows updated successfully, making a healthy sync look like a total failure. (This is exactly what made a real Ellis import *look* failed when only 5 of ~3,720 rows actually failed.)updated everywhere the result is shown:" synced" = created + updated (both live during processing and on completion) — never misleadingly 0.Updated series (rsos-blue #0090CD).Updated slice." created · updated" .created only (undo removes only created records — unchanged).duplicate key value violates unique constraint "properties_appfolio_uuid_company_unique"
Key ("appfolioId","companyId")=(24897e1a-be39-11ef-..., Ellis) already exists.
(appfolioId, companyId) unique constraint — failing that row and cascading "No property" to its unit + 2 tenants. The lone Peralta De Leon tenant failure was the same bug on tenants (dup Tenant Integration ID).appfolioId (name differs from the CSV), register the CSV name in the in-run lookup maps so child rows referencing it by the new name still resolve.units.postedToInternet is frozen. 4 currently-posted units sit at postedToInternet=false with inactive leasing properties → off the Rentals list. Re-running the import did nothing because those units already existed and were skipped.postedToInternet/postedToWebsite, and route the unit into the activate list (Posted=Yes) or a new deactivate list (Posted=No), bringing the leasing property's active/inactive status back in line with AppFolio. A posted existing unit with no leasing property gets one created.Model.update (no per-row service hooks, AppFolio push, or workflow events — same reason the importer already bypasses the create services under bulk load). Contact child rows (Email/PhoneNumber) are left untouched to avoid duplicates.Posted To Internet column, so a stripped export never wipes listings.updated counters and le…DateTimeInput — via a new clearable prop that surfaces MUI's native actionBar Clear button. No custom ✕ icon, no onMouseDown hacks, no changes to generic Wrapper/Input/Field.DateTimeInput.jsx (gated behind clearable, default false):"HH:mm:ss"), so real task times aren't rejected as Invalid Date.pendingValue ?? validDayjsValue) so the native Clear produces a genuine value → null change (it was a no-op before because the picker's value started null).null change as an immediate accepted clear (the actionBar Clear fires onChange(null) but not onAccept; normal clock/calendar interaction never yields null).DataList.jsx (EditableCell):clearable for date/time cells and commit on change (pickers fire onChange only on accept), collapsing the two near-duplicate edit branches into one Field.clearable defaults false, so the actionBar/value-fallback never apply; only the strictly-more-permissive parse-tolerance is shared.20260601120000-drop-duplicate-users-email-indexes) failed on prod:ERROR: cannot drop index users_email_key because constraint users_email_key on table users requires it
users_email_key is backed by a unique constraint (from the original CREATE TABLE), not a plain index — so DROP INDEX can't remove it. It failed on the first object, so nothing was dropped (the migration is unrecorded and re-runnable).pg_constraint and use the right statement for each:users_email_key) → ALTER TABLE users DROP CONSTRAINT IF EXISTS (drops its backing index too)users_email_key1…N) → DROP INDEX IF EXISTSIF EXISTS on both so a re-run after the partial failure is safe. users_email_company_unique is retained.NODE_ENV=production npm run migration:apply
~1025 matching objects (1 constraint-backed, 1024 plain indexes) then done. Verify with scripts/diagnose-duplicate-unique-indexes.js → should report zero remaining.ActionMenu (edit / delete) to each photographer row in PhotographerListCard, gated on photographyManage full access (onEdit != null).useDeletePhotographerUser mutation with a confirmation DeleteModal; clears the selection if the deleted photographer was selected.Photographers now passes onEdit (open edit card) down to the list, alongside the existing onAdd.addressLine1 / city / state / zip are now optional in the edit form's zod schema (zip still validates 5 digits *when provided*), and the required markers were removed from those fields.addressZip / addressCity / addressState / addressLine1 were required() on the create schema (and effectively required via the FE). They're now optional() (allow null/empty) on both createPhotographerUserSchema and updatePhotographerUserSchema, matching the FE form changes.unique: true on User.emailunique: true is an unnamed constraint that sequelize.sync() cannot match against the existing index, so it re-issued CREATE UNIQUE INDEX on every process boot during the pre-ENABLE_DB_SYNC-guard era. Postgres auto-suffixed the names, accumulating ~1024 duplicate users_email_key global indexes on prod (one per boot).users_email_company_unique composite index (which sync() matches by name and never duplicates), so the column-level unique was both redundant and harmful.WHERE companyId IS NOT NULL). Rows with a NULL companyId (e.g. super users) rely on app-level checks. This matches the existing composite-index intent.20260601120000-drop-duplicate-users-email-indexes.js dynamically finds and drops every ^users_email_key[0-9]*$ index, retaining users_email_company_unique. Verified via a prod sweep that…AppFolio API error (undefined): Unit not found in AppFolio (148 events, ongoing, server_name=worker)Unit data mapping failed - property not found (10 events)null value in column "leasingPropertyId" of relation "photographyAssignments" violates not-null constraint
at executePhotographyTask → executeTask → runManualTaskAutomationOnCompletion → markTaskCompletionByCriteria
resolvePropertyFromProcess intentionally returns either leasingPropertyId or propertyProfileId. The model, and the existing CHECK constraint chk_photography_assignments_property_ref (leasingPropertyId IS NOT NULL OR propertyProfileId IS NOT NULL), both expect leasingPropertyId to be nullable.20260323190907 was supposed to make it nullable, but its changeColumn(..., { allowNull: true, references: {...} }) step silently failed to DROP NOT NULL (Sequelize Postgres quirk when a references clause is included). The column stayed NOT NULL on already-migrated DBs. inspections.leasingPropertyId had the identical stale constraint.leasingPropertyId for both photographyAssignments and inspections (only when currently NOT NULL). DROP NOT NULL is instant (no table rewrite/scan); integrity remains enforced by the CHECK constraints. down restores NOT NULL only if …unqualified → "Application Denied") or routed to a new non-terminal "Needs Approval" stage for manual leasing-agent review, based on a resolved preference.rentsolution-frontend#2561.PhotographyAssignment ever appears.photography (and inspection) tasks create their scheduling record (PhotographyAssignment, with category = the "type" set on the template) only when AutomationService.executePhotographyTask / executeInspectionTask runs. That executor is invoked only by the automatic task pipeline. A manual photography/inspection task has no execution trigger — taskService completion only special-cases actionType === 'call', and the FE just calls the mark-done mutation. So marking a manual photography task done flips isDone=true and the executor never runs.isDone=true, valid config (category: interior_exterior_tour), resolvable property — and zero PhotographyAssignment records, no execution trail. photography/inspection are the only executable task types with no manual trigger path (appfolio/createProcess run automatically; email/SMS get a modal; stageChange has a Run button).TaskService.runManualTaskAutomationOnCompletion(task), called from both completion paths (markTaskCompletion and markTaskCompletionByCriteria) when a task is completed. It runs the matching executor so the record gets created on completion.processConfig.manualTask === true (set at generation: manualTask: template.isAutomatic …app.rentsolutionsemail.com/assets/ProcessDetail.COJC9qFy.js contains the post-refactor handleDataFieldsSave from commit 1312e883:Rs.mutate({
processId:n,
taskId:b?.id,
fields:e,
templateSnapshot:i?.templateSnapshot,
processRoles:i?.processRoles
}, {onSuccess:()=>x(null)})/admin-portal/process/ page. But Mike was on /admin-portal/grow/owner-lead/ — the OwnerLead detail page — not Process Detail. That page uses the same useUpdateProcessDataFields hook (the refactor target of #2570) but the callsite was never updated to pass the new params.src/pages/AdminPortal/Process/ProcessDetail/ProcessDetail.jsx — fixed in PR #2570src/pages/AdminPortal/Grow/OwnerLead/OwnerLeadDetail/OwnerLeadDetail.jsx:188 — broken (this PR)src/pages/AdminPortal/Lease/Lead/LeadDetail/LeadDetail.jsx:870 — broken (this PR)templateSnapshot + processRoles on the mutate input, the hook's resolveRoleAssignments produces an empty roleBySlotKey + roleByName map, every role-type field falls through every lookup, the partition returns {} (empty payload), the mutationFn returns Promise.resolve({success: t…useQuery only destructured isLoading. When the query goes to error state (after the global retry: 1 exhausts), the shimmer condition isTaskPreviewLoading || (!!task?.id && !isFormReadyForEditor) keeps evaluating true via the !isFormReadyForEditor side of the OR — isFormReadyForEditor only flips when the preview's useEffect runs, which never happens on error — so the editor never replaces the placeholder.isError + refetch from the same useQuery.Button + Wrapper with paddingHorizontal/paddingVertical + gap props (no raw /, no toasts — matches CLAUDE.md inline-feedback rule and the established TemplateSelectionModal isError pattern).
Verification (E2E, local dev app)
• Started backend on :4100 (USE_LOCAL_DB=true), frontend on :5273 pointed at it.
•…
ProcessDetail.jsx updateDataFieldsMutation sent every changed field as processFields[name] = value via PATCH /api/process/:id. Role assignments live on processRoles[] (the JSONB shape that the working ProcessDetailDueDateCard writes to), not processFields, so role values landed in the wrong column and were never read back.ProcessDetail.jsx:fields by type.roleId by matching the merge-field key (e.g. leasing_manager.fullName → roleByKey('leasing_manager')) or, as a fallback, the field label against processData.processRoles. Then sent as processRoles: [{ roleId, userId }] — the exact shape ProcessDetailDueDateCard.jsx:168-174 already uses successfully.processFields unchanged.process query on settle so Pre-Qualify and Due Date refresh with the new assignments without a manual page refresh.InstructionCard.jsx, the AP…mergeFieldLabels for the execution Instruction modal, but the body still showed raw {{role_2.fullName}} tokens on prod ([screenshot](https://app.rentsolutionsemail.com/admin-portal/grow/owner-lead/ade9ed71-f853-4480-9da9-86166c576697)). Live console fetch on prod confirmed the BE returns the instruction HTML with merge fields already wrapped in ql-merge-field spans (because the template editor saves them that way):<p>Assigned Leasing Agent : <span class="ql-merge-field" data-value="role_2.fullName" contenteditable="false">{{role_2.fullName}}</span></p>QuillEditor.processValueForEditor stashes pre-wrapped spans verbatim — it only applied mergeFieldLabels to *bare* {{...}} tokens. In edit mode the MergeFieldBlot rewrites the inner text when Quill mounts the editor, but read-only previews (dangerouslySetInnerHTML) bypass blot creation, so the raw token leaks into the UI.ql-merge-field spans whose inner text is still the raw {{key}} token and rewrite the inner to mergeFieldLabels[key] when registered. No-op when no label is registered — preserves current behavior.unwrapResolvedMergeFieldSpansForQuill, so resolved values (e.g. Joseph) are unwrapped to plain text first and never enter my regex. Verified against 7 cases:{{role_2.fullName}} placeholders for unassigned roles (per [saket's Loom](https://www.loom.com/share/e529fb9e9cc34f31ba9b19b809060916)), instead of the friendly blue badges (Leasing Agent - Full Name) shown in the template editor.InstructionCard in execution mode (showDataFields) doesn't fetch template injectable fields (read-only, no templateId in route params), so mergeFieldLabels was empty and the QuillEditor blot fell back to rendering the raw {{key}} token.dataFields the BE already returns ({ field, key, type }), and registers it via setMergeFieldLabels synchronously in a useMemo — so labels are populated *before* the read-only QuillEditor processes its value on the same render.${field.field} - ${camelCaseToTitleCase(subfield)} — e.g. "Leasing Agent - Full Name" — matching the template editor exactly.camelCaseToTitleCase in mergeFieldUtils.js.src/pages/AdminPortal/_components/PopCards/InstructionCard.jsx (+25)src/utils/mergeFieldUtils.js (+14)InstructionCard with showDataFields={true} — OwnerLeadDetail, tenant LeadDetail, ProcessDetail.role_1 assigned, task …skipConflictCheck: true on authenticated-agent create-showing ([useScheduleForm.js](src/hooks/leasing/useScheduleForm.js)) and on all reschedules ([useShowings.js](src/hooks/leasing/useShowings.js)).skipConflictCheck:true so agents could double-book.{{owner.name}} / {{unit.fullAddress}} into a task's name — the same authoring UX already available for instruction cards.useTemplateInjectableFields + mergeFieldUtils (formatMergeFieldToken, insertTextAtCursor, focusInputAtPosition), matching the existing EmailTextTemplateNewCard pattern.rightContent); selecting a field inserts {{token}} at the cursor in the Task Name input.templateId threaded through from TaskTemplateManagement.autoArchiveLeads form field, hydrated from user.settingsJson?.autoArchiveLeadsOnReject, and mapped on save to settingsJson: { autoArchiveLeadsOnReject }.16rem label column, so labels right-align to the same edge and the dropdown + toggle line up.12rem right-aligned label column, leaving a leading gap that made the label look indented. Set labelWidth="auto" so it sits flush-left with the Default Questions labels./admin-portal/settings/process/merge-field/importls_field, edge_field, action, [label, type, profileType, confidence]), parses client-side, shows every row in a DataList with inline-editable Edge field, action, and label/api/imports/merge-fields/dry-run and shows per-row status (use existing, will create, already exists, skip, invalid); Commit → calls /commit and reports countsskipConflictCheck: true:useScheduleForm.js — agent-initiated bookings (isAgentBooking)useShowings.js — all reschedulescheckCalendarConflicts. The backend reschedule path already excludes the current showing from its own conflict scan (showingService.js otherConflicts), so the flag was unnecessary there and only served to allow collisions with *other* showings.