# PRD: Live Tracking — Properties Tab Operational Workflow

## Problem Statement

Coordinators recommend measures in HubSpot, which sync to `hubspot_deal_data.proposedMeasures` and surface in the live tracking properties tab. The current MVP captures client approvals against those measures, but the broader operational workflow has gaps:

- Clients sometimes instruct additional measures the coordinator did not recommend. There is no way to capture this in the platform; instructed measures are tracked outside the system or pushed informally back to coordinators.
- After approval, Pre-Installation Building Inspections (PIBIs) are ordered against a subset of approved measures. There is no UI to record which measures are going for PIBI, when they were ordered, or when they completed.
- Some properties require a Domna technical building survey before measures are finalised. The boolean flag `domna_survey_required` is too coarse for this — the survey type can vary and the corresponding date has nowhere to be set in-app.
- Properties get halted mid-flow for varied reasons. There is no place to record halt date and reason.
- After tech survey, the internal team finalises a list of technical-approved measures. This list lives in `hubspot_deal_data.technicalApprovedMeasuresForInstall` and is not surfaced in the UI.
- The HubSpot pull pipeline now sends multi-value measure fields semicolon-separated, but the current parser still splits on commas — so post-pipeline data renders as a single concatenated string.

The result is that key operational steps are managed outside the platform, the platform shows stale data, and clients cannot self-serve the parts of the lifecycle that belong to them.

## Solution

Extend the properties tab to be the operational source of truth across the deal lifecycle: survey → propose → approve → instruct → PIBI → tech survey → tech-approved → halt. Where the platform captures user-originated state (instructed measures, PIBI selections, dates, halt reason, domna survey metadata), persist it locally and push it to HubSpot. Where HubSpot is the source of truth (proposed measures, technical-approved measures, survey type), display it read-only.

Introduce a normalized `user_defined_deal_measures` table for measures created in this platform, keyed by `(deal, measure_name, source)` with a polymorphic `source` enum. Reuse the existing detail drawer as the rich editing surface, widened from ~512px to ~1024px and reorganised into stage-ordered sections. The main `MeasuresTable` becomes a scannable summary; row click opens the drawer.

Do not gate the workflow by stage. Soft-warn at out-of-order actions. Role/capability gating still applies.

## User Stories

1. As a coordinator, I want HubSpot's semicolon-separated `proposed_measures` field to render correctly in the properties tab, so that approvers see the measures I recommended.
2. As an approver, I want to see survey type, survey date, proposed measures, approvals, instructed measures, PIBI status, domna survey status, halted state, and technical-approved measures in one drawer per property, so that I can understand the full state of a deal without leaving the page.
3. As an approver, I want to instruct a measure that wasn't proposed by the coordinator, picking from a known catalogue of measure types, so that the coordination team is told to add it to the deal.
4. As an approver, I want my instructed measures to push back to HubSpot under a dedicated `instructed_measures` deal property, so that the CRM reflects the decision and downstream consumers see it.
5. As an approver, I want an instructed measure to be auto-approved on my behalf, so that I don't have to perform a redundant approval click on something I just instructed.
6. As an approver, I want instructing a measure on a deal with no proposed measures and no existing approvals to also populate `proposed_measures` in HubSpot with that same measure, so that the deal has a coherent measures starting point.
7. As an approver, I want to mark which approved measures are going for PIBI, so that we capture intent before ordering PIBIs externally.
8. As an approver, I want PIBI selections to push to HubSpot under `measures_for_pibi_ordered`, so that the CRM is consistent.
9. As a write-or-above user, I want to set `pibi_order_date` and `pibi_completed_date` against a deal, so that the actual external dates are recorded and visible to all stakeholders.
10. As an approver, I want to mark a property as halted with a date and a free-text reason, so that the halted state is visible to coordinators and stakeholders.
11. As an approver, I want to clear the halted date to resume a property, so that the deal returns to the active workflow.
12. As an approver, I want to specify the domna survey type and date when a domna survey is required, so that the platform can replace the legacy boolean flag with structured data.
13. As any user with read access, I want to see read-only `survey_type` and `technical_approved_measures_for_install` displayed in the drawer, so that I can verify what the internal team finalised without opening HubSpot.
14. As an approver, I want a soft warning when I attempt a workflow action out of typical order (for example, instructing measures after tech-approved is set), so that I know I'm overriding the normal flow but I'm not blocked from doing so.
15. As a developer, I want a single canonical list of measure names in TypeScript, so that UI dropdowns, validation, and document-requirements lookup all share one source.
16. As a developer, I want a generic HubSpot measures-field sync helper, so that adding new multi-value measure properties does not require copy-pasting another bespoke sync function.
17. As a developer, I want instructed-measure writes to be transactional across the local table, the approvals table, and the HubSpot push, so that we don't end up with half-written state on partial failure.
18. As a tester, I want unit tests for sync helpers and write services, so that regressions in HubSpot integration logic are caught without standing up the full app.
19. As a tester, I want Cypress e2e coverage of the instruct/PIBI/halt/domna happy paths, so that the user-facing flows are validated against a running app.
20. As a release engineer, I want the change to land on a single feature branch with a Vercel preview deploy, so that we can run UAT before merging to production.

## Implementation Decisions

### Data model

- Existing measure text fields on `hubspot_deal_data` (`proposedMeasures`, `actualMeasuresInstalled`, `approvedPackage`, `technicalApprovedMeasuresForInstall`) remain HubSpot-mirrored text. They are read-only in the platform.
- New table `user_defined_deal_measures` stores measures created in this platform. Columns: `id`, `hubspot_deal_id`, `measure_name`, `source` (enum: `instructed`, `pibi_ordered`, extensible), `created_by_user_id`, `created_at`, `pushed_at` (nullable, set on successful HubSpot push), `confirmed_in_hubspot_at` (nullable, set when HubSpot pull confirms the value round-tripped), `notes` (nullable text).
- Read strategy: the local table is canonical for sources it owns. HubSpot text fields act as a mirror, not a fallback read source. Once the upstream HubSpot pull pipeline sends instructed/PIBI fields back, a follow-up will set `confirmed_in_hubspot_at` for round-tripped rows. That follow-up is out of scope for this PRD.
- `domna_survey_required` boolean column is dropped. `domna_survey_type` text (nullable) is added. No automated backfill — historical data will be back-populated separately by the data team.
- Multi-value measure fields are semicolon-separated in HubSpot output. The shared parser is updated from `split(",")` to `split(";")`.

### Measure name catalogue

- Lift the implicit measure list (currently the keys of `MEASURE_DOC_REQUIREMENTS` plus the trailing comment) into an exported `MEASURE_NAMES` const tuple with derived `MeasureName` type. `MEASURE_DOC_REQUIREMENTS` is refactored to consume the catalogue rather than own it.
- `user_defined_deal_measures.measure_name` is stored as `text`. Validation is enforced at the write boundary via zod against `MEASURE_NAMES`. No PG enum (HubSpot is the upstream source of measure names; migration churn is undesirable).

### HubSpot sync layer

- Extend `dealSync.ts` with one generic helper: `syncMeasuresFieldToHubSpot(dealId, propName, measureNames)`. It dispatches to the correct HubSpot deal property based on `propName` (`instructed_measures`, `measures_for_pibi_ordered`, future). Retains the existing direct-API + 3x retry pattern.
- The three existing bespoke sync functions can stay as thin wrappers over the generic helper or be left as-is for the existing approval/removal/contractor flows. The new helper is the canonical entry point for the new fields.
- Sync is synchronous within the API request lifecycle, matching existing convention. On success, `pushed_at` is set on local rows. On failure, `pushed_at` stays null and the request returns the error.
- An outbox/worker pattern is explicitly out of scope.

### Instruct-measure service

- Single transactional service entry point. Inputs: `dealId`, `measureName`, `actingUserId`. The service:
  1. Inserts a `user_defined_deal_measures` row with `source = 'instructed'`.
  2. Inserts a `deal_measure_approvals` row with `is_approved = true`, `acted_by = actingUserId`, plus the corresponding append-only event in `deal_measure_approval_events`.
  3. Reads `hubspot_deal_data.proposedMeasures` and counts approvals for this deal. If `proposedMeasures` is empty AND no approval rows exist for this deal, also pushes the new measure name as the `proposed_measures` HubSpot property.
  4. Pushes the updated instructed-measures list to HubSpot via the generic sync helper.
- All steps are committed in a single DB transaction; HubSpot push happens after commit, with `pushed_at` updated on success.
- This bypasses the existing pending-changes batch UI used for ordinary approval toggles. Instructing is an immediate, non-toggle commit.

### PIBI-selection service

- Mirrors the instruct-measure service, but writes `user_defined_deal_measures` rows with `source = 'pibi_ordered'` and pushes to the HubSpot `measures_for_pibi_ordered` property. No auto-approval row is created — PIBI selection is downstream of approval.
- The PIBI selector lists all measures associated with the deal (proposed + instructed). Approved measures are pre-ticked. The user can select non-approved measures; no hard block.

### Deal-property update service

- Single PATCH endpoint covering field-level updates on `hubspot_deal_data`: `pibi_order_date`, `pibi_completed_date`, `property_halted_date`, `property_halted_reason`, `domna_survey_type`, `domna_survey_date`. Validates per-field and pushes the corresponding HubSpot property updates. Halted-resume is modelled as setting `property_halted_date` to null. Halted reason is free text. Halt history is not modelled.

### API routes

- `POST /api/portfolio/[portfolioId]/instructed-measures` — body `{ dealId, measureName }`, approver-only.
- `POST /api/portfolio/[portfolioId]/pibi-measures` — body `{ dealId, measureNames[] }`, approver-only.
- `PATCH /api/portfolio/[portfolioId]/deal-properties` — body `{ dealId, fields: { ... } }`. Per-field permission checks: PIBI dates require `write+`; halted, domna fields require approver capability.

### Permissions

| Action | Required |
|---|---|
| Instruct measure | approver capability |
| PIBI tick | approver capability |
| Set PIBI dates | role `write` or above |
| Set property halted (date + reason) | approver capability |
| Set domna survey type/date | approver capability |
| View any of the above | role `read` or above |

### UI

- `PropertyDetailDrawer` width: drop `max-w-lg`. Set to `w-[60vw] max-w-5xl` (~1024px).
- Drawer content reorganised into sections, stage-ordered: Survey → Measures (proposed + instructed + approve toggle + add-instructed) → PIBI (selector + dates) → Domna Survey (type + date) → Halted (date + reason) → Technical Approved (read-only).
- `MeasuresTable` simplified to a scannable summary: measure name, source badge, approved indicator, PIBI indicator, technical-approved indicator. Row click opens the drawer scrolled to the Measures section.
- `survey_type` and `technical_approved_measures_for_install` rendered read-only in their respective sections.
- Soft warnings: out-of-order actions display a non-blocking inline warning. Implementation reuses the existing stage-detection logic in `computeLiveTrackerData()`.

## Testing Decisions

A good test exercises external behaviour, not implementation detail. Tests assert the contract of a module — what it returns, what it writes, what it pushes — not how it's wired internally. Mocks are restricted to system boundaries (HubSpot API, DB driver where unavoidable).

### Vitest (added)

The repo currently has no unit test infrastructure for app code. Vitest is added as part of this work. Unit-tested modules:

- **Measure name catalogue** — type/runtime invariants, doc-requirements lookup falls through to base docs for unlisted names.
- **HubSpot measures-field sync helper** — given a list of measure names, formats the semicolon-joined value and calls the HubSpot client with the correct `propName`. Retry behaviour on `ECONNRESET`. Failure leaves `pushed_at` unset.
- **Instruct-measure service** — happy path writes user-defined row + approval row + event row + pushes HubSpot instructed-measures. Auto-populates `proposed_measures` when proposed is empty and no approvals exist; does not when either is non-empty. Transaction rolls back on DB failure; HubSpot push failure does not roll back the DB commit but leaves `pushed_at` null.
- **PIBI-selection service** — writes rows, pushes correct HubSpot property, does not create approval rows.
- **Deal-property update service** — per-field validation, correct HubSpot property mapped per field, halted-resume clears the date.

The semicolon parser change is exercised through the tests of any module that consumes parsed measure lists.

### Cypress (existing)

Happy-path e2e coverage:

- Approver instructs a measure; the drawer reflects it; HubSpot mock receives the push; an approval row is visible in the approval log.
- Approver toggles PIBI selections; the drawer state reflects the selection; HubSpot mock receives the PIBI push.
- A write user sets PIBI order and completion dates; the drawer reflects them.
- Approver halts a property with a reason, then resumes it.
- Approver sets domna survey type and date.

There is no prior art for unit tests in the app codebase; Vitest config and the first test files in this PRD establish the pattern. Existing Cypress specs in `cypress/e2e/` are prior art for the e2e additions.

## Out of Scope

- Reverse-confirm pipeline that sets `confirmed_in_hubspot_at` after HubSpot pull — depends on changes in the upstream HubSpot-pull repo. Tracked separately.
- Backfill of historical data from HubSpot text fields into `user_defined_deal_measures` — done separately by the data team after the upstream pipeline lands.
- "Other observations" / additional measure observations from coordinators — out of MVP. Will be revisited if it surfaces as a user need.
- Editable `technical_approved_measures_for_install` from this platform — phase 1 read-only only.
- Halt history (multiple halt/resume cycles preserved) — only current halt state is modelled.
- PG enum for measure names or domna survey type — TS-side validation only for now.
- Outbox / background worker pattern for HubSpot sync — premature given current scale.
- Hard workflow gating — explicitly avoided. Soft warnings only.

## Further Notes

- Delivery: single feature branch, four internal slices (data plumbing → instructed measures → PIBI → halt+domna+migration). Vercel preview deploy used for UAT before merge to `main`.
- The design preserves the existing approval batch UI for ordinary proposed-measure approvals; only instructed-measure writes bypass it.
- The HubSpot deal property `instructed_measures` does not yet exist in HubSpot. Coordinating its creation with the CRM admin is a prerequisite for the push path to succeed end-to-end. Until it exists, push calls will error and `pushed_at` will remain null; the local rows are still authoritative for in-app reads.
- The semicolon parser change is potentially user-visible the moment it lands — verify the upstream pipeline is producing semicolons before merging, or the parser change will break the existing read path. If timing is uncertain, make the parser tolerant (`split(/[,;]/)`) as a safety net and tighten later.
