Removed properties have no coordinator or confirmed survey date by definition,
so those columns were always blank. Outcome notes are the field that actually
explains why the property dropped out of bookings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, any HubSpot outcome would override bookingStatus="Do Not Book"
and keep the property in the normal pipeline. That was too permissive —
outcomes like "Tenant Refusal" or "Not Viable" combined with Do Not Book
should classify the property as Removed from Bookings, not lurk in Queries
or the survey-issues bucket.
Now only completed survey outcomes (Surveyed, Surveyed - Pending Upload,
EPC Completed) override Do Not Book. Any other outcome + Do Not Book
falls through to Removed from Bookings, surfaces in the Halted or Removed
panel, and gets the matching stage badge in the Properties tab. The
redundant "Removed from Bookings" chip in the drill-down table is gone
since the stage classification now carries that signal cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits the DB migration artifacts off the frontend branch so they can
land independently:
- 0215_invert_column_mapping: one-shot data migration inverting
bulk_address_uploads.column_mapping from header->field to field->header
(drops 'skip' entries). One-shot — see file header and ADR-0003.
- 0216_add_subtask_service: adds sub_task.service (nullable text) to tag
which pipeline a subtask belongs to (address2uprn vs
landlord_description_overrides).
Includes the subtask.ts schema source for 0216 so drizzle's snapshot
stays consistent. No app code depends on these yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both surface in the column-toggle dropdown and the CSV export, hidden by
default like the other optional columns. surveyedDate sits next to
designDate (chronological survey -> design order); epcPrn sits after the
lodgement date since the PRN is the output of lodgement.
epcPrn was already in the DB schema but absent from the HubspotDeal type
and mapper, so the plumbing is added alongside.
Extracts the CSV-formatting logic out of PropertyTable into a pure
propertyCsv module with tests, locking in the export contract (header
order, en-GB date formatting, null cells empty) so future column drift
is caught.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Softer, less technical language for landlord-facing copy. The subtitle
now spells out the two states (halted before a survey, or removed from
the project entirely) so the relationship between the section header
and the two cards is explicit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Properties that are intentionally not progressing (bookingStatus = "Do Not
Book" with no outcome, or batch = "Removed from Program") were landing in
the "Queries" bucket, inflating it with non-actionable rows. Two new
terminal DisplayStage values now classify these explicitly, with
precedence Removed from Program > Removed from Bookings > Queries. Both
are excluded from pipeline funnel and stage-progress denominators
(sibling to Queries) and surface as their own cards under "Excluded from
Pipeline" on the analytics tab. Drill-down rows in Survey Issues get
slate chips when a deal carries either flag, preserving outcome history
for properties surveyed before being de-scoped.
Also removes the unused SurveyedResultsPieChart chain (component,
computeOutcomeSlices, OutcomeSlice, outcomePieSlices field).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Coordinators record non-damp/mould observations (e.g. wasp nests) in the
same comments field, but the section was framed entirely around damp and
mould. Reframe the panel copy and table titles around "condition issues",
keep "Damp, Mould" up front so the Awaab's Law urgency still leads, and
mark the damp/mould rows specifically with a red badge column so they
don't blend into the broader list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The risk drill-down's coordinator-stage table showed "Yes" for every row,
which carried no useful signal. It also missed properties where the
coordinator wrote a comment without setting the growth flag.
Include rows where dampmould_growth is "yes" (case-insensitive) OR the
comment is populated, and render the comment in the cell — truncated
with a popover for the full text, or a "no note from coordinator"
placeholder when the row is here only because the flag was ticked.
Also drop the typo in the schema property name
(damnp -> damp); SQL column unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Next.js 15 enforces a strict allowlist of named exports from route.ts
files; "inviteRequestSchema" was rejected by the route-validator with
"is not a valid Route export field" during npm run build. Splitting the
schema into a sibling module (which is exempt from the allowlist) and
importing it from route.ts and the test. Renames the test file to
match its target module.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Full name input on the user-access card was never persisted (no
column on portfolio_invitations), never used in the invitation email
(only the inviter's name appears there), and never displayed in the
pending-invitations table — purely dead weight on the form. Removing
the input, the request-body field, and the response-payload reference.
Extracts the POST body schema to a named inviteRequestSchema export so
the contract change is locked in by a unit test rather than left implicit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the auto-dismiss toast on accept with a shadcn Dialog that
names the portfolio and offers a "Go to {portfolioName}" CTA. Decline
keeps the existing toast. router.refresh() updates /home in place, and
revalidatePath("/home") in the API handler guarantees the server-rendered
portfolio list is fresh on any later navigation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Escape apostrophe in the revoke-invitation ConfirmDialog description
(react/no-unescaped-entities)
- Add role="tab" to the two tab-button arrays in PropertyDetailDrawer
and DealPage so aria-selected is valid for that element
(jsx-a11y/role-supports-aria-props)
The aria-selected warnings were pre-existing in those files but the
build now blocks on warnings as well as errors. The fix is the correct
ARIA pattern — these buttons are real tabs, role="tab" is what
aria-selected expects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the auto-accept-on-signin behaviour with an explicit accept or
decline step. Every invitee (existing user or brand new) is now treated
the same way: an invitation lands in portfolio_invitations, the email
is a deep-link "Sign in to Ara", and the invitee accepts (or declines)
explicitly from a notifications panel hanging off their profile avatar.
Why: the previous flow silently added existing users to portfolios with
no way to refuse, and gave them no in-app confirmation that the invite
landed. Existing users complained they didn't know which account they
were signed in as either — both gaps are closed by the same panel.
Backend:
- POST /collaborators no longer creates portfolioUsers directly for
existing users; it writes a portfolio_invitations row in every case
except the trivial "already a member of THIS portfolio" path
(silent role update, no email)
- New /api/user/invitations endpoint: GET lists pending invitations
addressed to the current user across all portfolios, joined with
portfolio + inviter context; POST accepts or declines a single
invitation, scoped to session.email so users can't act on others'
- Accept reuses the existing planInvitationApplication helper for
the "already a member" idempotency check
- Decline is a silent delete (matches GitHub/Linear/Notion convention;
no email to inviter, no tombstone)
- signIn callback no longer auto-applies pending invitations — that
block is removed entirely along with its now-unused imports
- Email template subject + body unified, no longer suggests the user
is "added"; both modes say "invited" and direct them to the profile
menu
Frontend:
- ProfileDropDown rewritten as a notifications panel: shows the
signed-in email at the top (closing the "which account?" gap),
lists pending invitations with Accept/Decline buttons, displays a
red count badge on the avatar (max "9+"). Uses TanStack Query with
optimistic update on accept/decline and toast on outcome. Existing
Help + Sign Out menu items preserved.
- No useEffect — pending-count derived from query data, mutations
handle the rest
Vercel preview test plan:
- Invite a user already in another portfolio → red badge appears on
their next page load; Accept lands them in the portfolio
- Invite a new email → sign-up flow finishes; new account lands on
home with a badge waiting for Accept (no longer auto-accepted)
- Existing member of THIS portfolio re-invited → silent role update,
no email
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small UX upgrades on the portfolio user-access page:
- Replace native confirm() prompts (for "Remove user" and "Revoke
invitation") with a shadcn-based ConfirmDialog. Shows the email of
the user/invitation being acted on, disables buttons while the
mutation is in flight, and matches the visual language of the rest
of the app. New ConfirmDialog component is generic and reusable.
- Toast on every mutation outcome (invite/remove/revoke, success and
failure), using the existing useToast hook. Invite success now
surfaces "Invitation sent — We've emailed <email>…" so admins get
immediate feedback that the email was dispatched, instead of just
the form silently clearing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Crash root cause: UsersPermissionsCard and CapabilitiesCard both used the
TanStack Query key ["portfolioUsers", id], but a previous change made
UsersPermissionsCard's fetcher return { users, currentUser } while
CapabilitiesCard's still returned an array. TanStack Query dedupes by
key — whichever component mounted first won the cache; the other read an
incompatible shape and crashed on `for (const c of collaborators)`.
Fix: extracted a single shared fetcher (collaboratorsClient.ts) so both
components import the same fetchCollaborators function and consume the
same response shape. CapabilitiesCard projects data?.users ?? []; the
shared cache stays consistent regardless of mount order.
Also rename the route folder from "colloborators" to "collaborators"
(spelling fix). Used git mv so history follows. Fetch URLs, console
error labels, and the route doc comment updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a portfolio-privilege concept (creator > admin > domna employee >
write > read > none) and gates all user-access mutations + the pending-
invitations view behind it. Plus opens the role dropdown to include
"admin" so creators/admins/Domna can promote and demote.
Privilege model:
- Portfolio creator: full admin powers (cannot be removed/demoted)
- Portfolio admin: full admin powers via explicit membership role
- Domna employee (email ends @domna.homes, case-insensitive):
implicit admin across all portfolios, even if not a member —
intended for customer-support / internal-tooling needs
- Anyone else (read/write/none): no admin powers
Backend:
- New pure-function helpers in src/app/lib/portfolioAdmin.ts —
isDomnaEmail() and canAdminister(privilege), with 6 tests covering
case-insensitivity and look-alike domain rejection
- New server helper resolvePortfolioPrivilege() that reads
portfolioUsers + checks the email, returning the highest privilege
- New denyIfNotAdmin(portfolioId, session) one-liner that returns a
401/403 NextResponse or null; used at the top of every mutating
route handler to keep the guard out of the way
- POST/PUT/DELETE on /colloborators and GET/DELETE on /invitations
are now all gated. Non-admin callers get 403.
- GET /colloborators now requires auth and returns
`{ users, currentUser: { privilege } }` so the UI knows which
actions to expose without an extra round-trip
Frontend:
- ROLE_OPTIONS extended to ["read", "write", "admin"]. RoleDropdown
takes allowAdminPromotion?: boolean to keep the basic dropdown
unchanged where promotion isn't allowed.
- UsersPermissionsCard derives isAdmin = canAdminister(privilege)
from the API response. Invite section, role-change dropdown,
Remove button, and the entire Pending Invitations section are now
rendered only when isAdmin. Non-admins see a read-only members
table.
- The invitations useQuery is disabled when !isAdmin, avoiding
guaranteed-403 network calls.
Defensive note: the UI gating is for UX; the backend guard is the
security boundary. A non-admin who hand-crafts a POST still gets 403.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two pieces of user-access management on the portfolio settings page
that were previously stubbed or missing:
- The existing "Remove" button was wired to console.log only. It now
calls a DELETE on /colloborators with optimistic cache update and
rollback on failure. The route refuses to remove the portfolio
creator and 404s if the membership isn't in the URL's portfolio.
- A new "Pending invitations" section in UsersPermissionsCard lists
invitees who haven't signed in yet, backed by a new
/api/portfolio/[id]/invitations endpoint (GET + DELETE). Admins can
revoke a pending invitation; revoking deletes the row so the invitee
no longer auto-joins on sign-in. Inviting a new email shows up here
immediately because the invite mutation invalidates both query keys.
Both new mutations use optimistic updates with rollback, and disable
only the in-flight row (mutation.variables === currentId) so the rest
of the table stays interactive. No useEffect, TanStack Query throughout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-second countdown was the only thing pulling useEffect into this
component, in violation of the project rule (CLAUDE.md: avoid useEffect,
prefer event handlers). The hook was driving a tick purely so we could
show "Resend in 28s" / "27s" / ... — none of which is load-bearing.
Replace it with a single setTimeout fired from the resend event handler
that flips resendStatus from "cooldown" back to "idle" after 30 seconds.
The button stays disabled with "Code sent — wait a moment" instead of
showing a live countdown. Same blocking signal, no hook needed.
While here, split the single status enum into verifyStatus + resendStatus
so the verify button no longer wrongly disables during a resend cooldown
(latent bug from the previous shape).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Email body now contains the 6-digit code only. The /verify/[token] route
and the EmailProvider link callback are intentionally left wired (just
not advertised in the email) so reverting to a link-bearing template is
a content-only change if the all-code variant doesn't improve
deliverability for the Atkins-style blocked recipients.
Hypothesis being tested: corporate gateway URL scanners are part of why
some emails got silently quarantined, and a short transactional body
without an auth-token URL clears more filters.
Two small UX bugs surfaced in preview testing also fixed here:
- Paste of "482 911" (with the visual space from the email's formatted
code) was dropping a digit. Root cause: maxLength=6 on the input
truncated the 7-char paste before our \D-stripping ran. Drop the
attribute and let the existing .slice(0, 6) after stripping handle
the bound. Pasting the formatted code now works.
- After requesting a resend and then typing into the code field, the
green "we've sent a new code" notice would re-appear as soon as the
previous error message cleared. handleChange now clears the resend
notice on the next keystroke, alongside the error clear it already
did.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same email now contains a 6-digit code and a magic link, both backed by a
single verificationToken row. After submitting their email, the user
lands on /auth/verify-code with a single-input form (inputmode=numeric,
autocomplete=one-time-code, auto-submit on 6 digits or paste) and can
either type the code or use the link from the email. Either path
consumes the same row — single-use, replace-on-resend.
This is the structural fix for the silent-quarantine pattern observed
with Atkins and Sustainable Building UK: corporate gateways are happier
with short transactional content than long opaque token URLs, and a
code can't be broken by SafeLinks-style URL rewriting. The link path is
preserved so users whose email gets through unmangled keep one-click UX.
Security:
- Codes are 6-digit, crypto.randomInt-generated, stored as sha256
hashed against NEXTAUTH_SECRET on the same row as the link token
- 5-attempt lockout per code (attempts column); 6th attempt with the
correct code still fails
- Per-email send rate limit: 5/hour fixed window (authRateLimits
table); 6th send returns an error
- Code + link share a 10-minute window (maxAge dropped from 1h)
- Resending replaces any prior token rows for the identifier so only
the latest send is ever live
Implementation:
- verificationCode.ts holds generateCode + hashCode + the pure
evaluateCodeAttempt decision tree; 9 unit tests cover every branch
of the verification outcome (no-such-row, expired, locked-out, ok,
wrong-with-newAttempts, locked-out-still-rejects-correct-code)
- sendVerificationRequest now hashes the URL token the same way
/verify/[token]/page.tsx does, applies the rate limit + records the
code + replaces older rows in two transactions
- CredentialsProvider (id: "email-code") calls evaluateCodeAttempt
inside a transaction, handles all 5 outcomes, creates the user on
first successful code (parity with the magic-link callback path)
- oauthId backfill in the signIn callback is now guarded on
account.type === "oauth" so the credentials flow doesn't pollute
oauthProvider with "email-code"
- Migration is additive: code_hash nullable, attempts default 0; new
authRateLimits table is independent. In-flight tokens at deploy time
keep working via the link path.
Vercel preview deployment is the test surface; a Mailpit + Cypress E2E
loop is intentionally deferred per the lean-setup plan in docs/wip/
auth-email-code-fallback-plan.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the X-SES-CONFIGURATION-SET header on outbound auth emails so SES
bounce/delivery events flow through dev-ses-config to the dev-ses-events
SNS topic. Replaces the fire-and-forget "EMAIL MAGIC LINK SENT" log
(which fired before the SMTP transaction and swallowed downstream errors)
with structured EMAIL_MAGIC_LINK_SUCCESS/_FAILURE logs carrying the
Nodemailer messageId, so app-side sends are now correlatable with SES
events.
Motivated by the Atkins / Sustainable Building UK silent-quarantine
incidents where we couldn't tell whether SES had even tried to send.
Plan doc at docs/wip/auth-email-code-fallback-plan.md tracks the
broader email-code-fallback design that PR 2 will implement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>