Merge branch 'main' into feature/frontend_landlord_overrides

# Conflicts:
#	docs/wip/landlord-override-frontend-plan.md
This commit is contained in:
Jun-te Kim 2026-05-28 17:40:26 +00:00
commit f1a794ccbe
66 changed files with 64011 additions and 506 deletions

View file

@ -0,0 +1,451 @@
# Email-auth code-fallback — implementation plan
**Status:** Ready to implement (2026-05-27)
**Author:** Khalim (with Claude, via `/grill-me`)
**Trigger:** Multiple corporate-domain users (Atkins, Sustainable Building UK,
Arup pre-fix) reporting magic-link emails never arriving. One IT department
confirmed direct sender-policy block.
## Problem
[Magic-link sign-in](../../src/app/api/auth/[...nextauth]/authOptions.ts#L74-L95)
emails are being **silently quarantined** by some corporate email gateways
(Microsoft 365 Defender / Mimecast / Proofpoint family). The mail reaches the
recipient's MX, is accepted at SMTP, then disappears post-acceptance — never
hits the inbox or junk folder. SES sees a clean `Delivery` event; the user
sees nothing.
This is invisible to the sender by design. We confirmed it via the Atkins-
adjacent case (Craig Williams, sustainablebuildinguk.com) where the
recipient's IT department reached out, confirmed the block, and manually
allow-listed `noreply@domna.homes`.
### What's been ruled out
- SPF, DKIM are correctly configured (verified via `dig`)
- SES suppression list does not contain the failing users
- Bounce rate is 0.03%, complaint rate 0.01% — reputation is fine
- Link pre-fetching is already defended by
[/verify/[token]/page.tsx](../../src/app/verify/[token]/page.tsx) (two-step
consent before token consumption)
### What's contributing but not the root cause
- `noreply@domna.homes` is not a real mailbox → small reputation hit and
broken `List-Unsubscribe` ([magic_link.ts:54](../../src/app/email_templates/magic_link.ts#L54))
- DMARC is at `p=none` with no `rua=` reporting — zero visibility into
alignment failures
- `.homes` is a 2014-era new gTLD with thin aggregate reputation at some
receivers (structural, hard to fully mitigate without changing sender domain)
- App's `EMAIL_MAGIC_LINK_SENT` log fires *before* the SMTP transaction and
isn't wrapped in try/catch, so it can't distinguish "tried" from "sent"
([authOptions.ts:86-94](../../src/app/api/auth/[...nextauth]/authOptions.ts#L86-L94))
- SES configuration set exists but the app never sets the
`X-SES-CONFIGURATION-SET` header, so events don't flow through it
([magic_link.ts:53-55](../../src/app/email_templates/magic_link.ts#L53-L55))
## Decisions (grilled and locked)
| # | Decision | Rationale |
|---|---|---|
| D1 | Code-first email auth with link as fast-path fallback (option b2) — both delivered in the same email | Code is more filter-friendly than a long opaque URL; SafeLinks can't break a 6-digit code; cross-device UX is naturally same-device |
| D2 | 6-digit numeric code | Industry standard (AWS/Stripe/GitHub/Anthropic Console); easy to read aloud and type on mobile |
| D3 | 10-minute expiry for both code and link (single artifact) | Code's lower entropy demands shorter window; unifies behaviour |
| D4 | Single DB row per verification — extend `verificationToken` with `code_hash` and `attempts` columns | No new table; both paths consume the same row; deletion on first success enforces single-use |
| D5 | Rate limits: 5 attempts/code, 5 codes/email/hour, 20 verify-attempts/IP/10min — stored in Postgres | Postgres avoids Redis dependency; numbers give attacker ~1-in-40k/hour worst case |
| D6 | NextAuth v4 `CredentialsProvider` alongside existing `EmailProvider` | Cleanest extension point; works with existing JWT session strategy at [authOptions.ts:269](../../src/app/api/auth/[...nextauth]/authOptions.ts#L269); reuses existing user / `signIn` callback |
| D7 | Ship in two PRs: observability first, code-flow second behind feature flag | Lets us measure the improvement; isolates risk |
| D8 | Testing: Mailpit + Playwright in CI, mail-tester.com manual on template changes, SES dashboard for prod | Lean — no paid SaaS at current scale |
| D9 | Skip the SES event-consumer Lambda for now; ticket on backlog | Volume doesn't justify it; SES dashboard + suppression list + customer-success channel are enough for forensics today |
## Out of scope / deferred
- **Microsoft work-account OAuth** (Azure AD multi-tenant) — would skip email
entirely for M365 corporate users but locks them to MS SSO; revisit if
enterprise customers demand it
- **Switching sending domain off `.homes`** — measurable deliverability lift
but requires re-warmup and brand thinking; revisit if `domna.homes`
reputation stays a bottleneck after the DMARC + MAIL FROM work
- **SES event-consumer Lambda** — ticketed for the backlog (see planning
board: "Wire up SES event consumer for email auth forensics")
- **BIMI / VMC, dedicated SES IP, GlockApps placement testing** — overkill at
current scale
- **Case-sensitivity bug in portfolio invitations** (separate issue) — user
`Craig.Williams@…` invited with mixed case, then signed in lowercase,
creating two user records. The lookup at
[authOptions.ts:117](../../src/app/api/auth/[...nextauth]/authOptions.ts#L117)
normalises but the *invitation* path doesn't. Track separately — not in
this plan.
---
# PR 1 — Observability + logging fixes
**Goal:** when the next Atkins-shaped incident happens, we know within minutes
whether SES tried, whether SES accepted, and whether the recipient SMTP
accepted. Today we know none of these.
**Risk:** near-zero. Additive only.
**Effort:** ~half a day.
### Changes
1. **Add `X-SES-CONFIGURATION-SET` header** to outbound mail.
In [magic_link.ts:53-55](../../src/app/email_templates/magic_link.ts#L53-L55):
```ts
headers: {
...(process.env.SES_CONFIGURATION_SET && {
"X-SES-CONFIGURATION-SET": process.env.SES_CONFIGURATION_SET,
}),
"List-Unsubscribe": `<mailto:${provider.from}>`,
},
```
Conditional so CI (Mailpit) doesn't need the env var.
2. **Capture and return Nodemailer messageId** from `MagicLinksEmail`.
In [magic_link.ts:39-62](../../src/app/email_templates/magic_link.ts#L39-L62),
change the return type to `Promise<{ messageId: string }>` and return
`{ messageId: result.messageId }` after the `result.rejected` check.
3. **Fix the misleading log** in
[authOptions.ts:86-94](../../src/app/api/auth/[...nextauth]/authOptions.ts#L86-L94):
```ts
sendVerificationRequest: async ({ identifier, url, provider }) => {
try {
const { messageId } = await MagicLinksEmail({ identifier, url, provider });
console.log("EMAIL_MAGIC_LINK_SUCCESS", {
email: identifier,
messageId,
timestamp: new Date().toISOString(),
});
} catch (err) {
console.error("EMAIL_MAGIC_LINK_FAILURE", {
email: identifier,
error: err instanceof Error ? err.message : String(err),
timestamp: new Date().toISOString(),
});
throw err;
}
},
```
Now log presence proves "we tried", `EMAIL_MAGIC_LINK_SUCCESS` proves "SES
accepted at SMTP", and `messageId` correlates to SES events.
4. **Add `SES_CONFIGURATION_SET` env var** to the deployment platform
(Vercel / wherever). Value: `dev-ses-config`.
5. **Terraform — add `renderingFailure` event type and output the cfg-set
name.** In the SES module:
```hcl
resource "aws_ses_event_destination" "sns" {
# ...
matching_types = [
"send",
"bounce",
"reject",
"complaint",
"delivery",
"renderingFailure", # ← add
]
# ...
}
output "configuration_set_name" {
value = aws_ses_configuration_set.this.name
}
```
### Acceptance criteria
- Sending a magic link produces exactly one of: `EMAIL_MAGIC_LINK_SUCCESS`
(with messageId) or `EMAIL_MAGIC_LINK_FAILURE` (with error). No bare
`EMAIL MAGIC LINK SENT` lines.
- The `messageId` in app logs matches the `mail.messageId` SES uses (verify
once via SES console for one test send).
- Terraform plan is clean after the two SES module changes.
---
# IT track (parallel, out-of-band)
These happen in parallel to PR 1; no engineer dependency.
1. **Create `noreply@domna.homes` as a real shared mailbox** in M365.
Configure an auto-reply pointing to a monitored support address. Set
`Reply-To:` on outgoing mail in PR 2 once the mailbox exists, OR change
`EMAIL_FROM` to something like `accounts@domna.homes` and make *that*
monitored. Update `EMAIL_FROM` env var when ready.
2. **Update the DMARC TXT record** at `_dmarc.domna.homes` from:
```
v=DMARC1; p=none;
```
to:
```
v=DMARC1; p=none; rua=mailto:<reporting-address>; fo=1; adkim=r; aspf=r; pct=100;
```
Recommend Postmark DMARC Monitor (free) for the reporting address — they
provide a unique mailbox like `<uuid>@inbound-smtp.dmarc.postmarkapp.com`
and parse the XML into readable reports.
3. **Progression** (after ~4 weeks of clean reports): bump to
`p=quarantine; pct=10;`, then `p=quarantine; pct=100;`, then
`p=reject; pct=100;`. Don't skip the ramp — HubSpot / Outlook sends need
to be confirmed aligned first.
### Nice-to-have (do when convenient, not blocking)
- **Custom MAIL FROM domain** (`mail.domna.homes`) — adds SPF alignment via
*our* domain instead of `*.amazonses.com`. In Terraform:
```hcl
resource "aws_ses_domain_mail_from" "this" {
domain = aws_ses_domain_identity.this.domain
mail_from_domain = "mail.${var.domain_name}"
}
```
Plus the corresponding `MX 10 feedback-smtp.eu-west-2.amazonses.com` and
`TXT v=spf1 include:amazonses.com -all` at `mail.domna.homes`. Modest but
real deliverability win.
---
# PR 2 — Code fallback flow (behind feature flag)
**Goal:** every magic-link email now also contains a 6-digit code. The
post-submit UX leads with code entry; the link still works as a fast path.
**Risk:** medium — changes the user-facing auth flow. Feature flag mitigates.
**Effort:** ~2-3 days.
### Schema migration
Add to the existing `verificationToken` table in
[src/app/db/schema/users.ts](../../src/app/db/schema/users.ts):
```ts
export const verificationTokens = pgTable("verificationToken", {
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
codeHash: text("code_hash"), // ← new (nullable)
attempts: integer("attempts").notNull().default(0), // ← new
});
```
Also new table for rate-limit state:
```ts
export const authRateLimits = pgTable("authRateLimits", {
scope: text("scope").notNull(), // "email-send" | "ip-verify"
key: text("key").notNull(), // email or IP
count: integer("count").notNull().default(0),
windowStart: timestamp("window_start").notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.scope, t.key] }),
}));
```
Drizzle migration is additive — existing rows get `code_hash = NULL`,
`attempts = 0`. Pre-deploy magic-link emails (those without codes) continue
to work via the link path; users on the code-entry page will fail and need
to resend. Acceptable transient.
### Code utility — `src/lib/auth/code.ts`
```ts
import crypto from "crypto";
export function generateCode(): string {
return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export function hashCode(code: string, secret: string): string {
return crypto.createHash("sha256").update(code + secret).digest("hex");
}
```
`crypto.randomInt` not `Math.random` — must be CSPRNG.
### `sendVerificationRequest` update
In [authOptions.ts:86-94](../../src/app/api/auth/[...nextauth]/authOptions.ts#L86-L94):
1. Generate code via `generateCode()`
2. Hash with `NEXTAUTH_SECRET`
3. `UPDATE verificationToken SET code_hash = ?, attempts = 0 WHERE identifier = ? AND token = ?`
4. Pass `code` (plaintext) into `MagicLinksEmail`
5. Existing logging unchanged
### Email template update
[magic_link.ts](../../src/app/email_templates/magic_link.ts) takes a new
`code` argument. The HTML and plain-text bodies lead with:
> **Your sign-in code: 482 911**
>
> Enter this at `ara.domna.homes/auth/verify-code`
>
> Or click here to skip the code step: [Sign in to Ara] →
Code rendered large and monospace; the link is a smaller secondary button.
### NextAuth providers — add `CredentialsProvider`
In [authOptions.ts](../../src/app/api/auth/[...nextauth]/authOptions.ts):
```ts
import CredentialsProvider from "next-auth/providers/credentials";
CredentialsProvider({
id: "email-code",
name: "Email Code",
credentials: {
email: { type: "text" },
code: { type: "text" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.code) return null;
const email = credentials.email.toLowerCase();
const hashed = hashCode(credentials.code, process.env.NEXTAUTH_SECRET!);
// Look up the row by (identifier, code_hash, not-expired)
// If found: check attempts < 5, delete row, return user
// If found but attempts >= 5: delete row, return null
// If not found: increment attempts on the row matching identifier (if any), return null
// Rate-limit check before any of this; on exceed, return null silently
},
}),
```
Verify the `signIn` callback at
[authOptions.ts:114-206](../../src/app/api/auth/[...nextauth]/authOptions.ts#L114-L206)
handles `account.type === "credentials"` cleanly (it should fall through the
OAuth branches at lines 142 and 180, but worth a unit test).
### Drop magic-link maxAge
Change [authOptions.ts:84](../../src/app/api/auth/[...nextauth]/authOptions.ts#L84)
from `60 * 60` to `60 * 10`. Code and link now share the 10-min window.
### New page — `src/app/auth/verify-code/page.tsx`
Code-input form. POSTs `{email, code}` to NextAuth credentials endpoint via
`signIn("email-code", { email, code, redirect: false })`. Includes a
"Resend code" button (rate-limited via the new `auth_rate_limits` table).
### Redirect change
[authOptions.ts:103](../../src/app/api/auth/[...nextauth]/authOptions.ts#L103):
change `verifyRequest: "/auth/verify-request"` to
`verifyRequest: "/auth/verify-code?email=<encoded>"` so post-submit lands on
the code-entry page with the email pre-filled.
### Existing `/verify/[token]` page
Unchanged behaviour — the link path still works. Optionally add a small
"Type the code instead" link to the code-entry page for the rare user who
prefers it.
### Feature flag
Env var `AUTH_CODE_FALLBACK_ENABLED=true|false`. When `false`, skip the code
generation in `sendVerificationRequest` and redirect to the old
`/auth/verify-request` page. Lets us canary and roll back via env-var flip.
### Acceptance criteria
- Submitting email at `/` lands on `/auth/verify-code` (when flag enabled)
- The email body contains a clear 6-digit code AND a working link
- Typing the code on the verify-code page signs the user in
- Clicking the link still signs the user in (single artifact — using the
link invalidates the code, and vice versa)
- 5 wrong code attempts deletes the row; a 6th attempt with the correct code
fails
- 6th code request within an hour to the same email is silently no-op'd
- After successful sign-in, behaviour identical to today (lands on `/home`,
user record + `lastLogin` updated, etc.)
---
# Testing strategy
### CI (every PR)
- **Vitest** for pure-function unit tests (existing pattern — see
[src/app/email_templates/buildMailHeaders.test.ts](../../src/app/email_templates/buildMailHeaders.test.ts)
added in PR 1).
- **Cypress** for E2E (existing harness at `cypress/e2e/`) covering:
- Code path: submit → poll Mailpit → extract code → enter → land on `/home`
- Link path: submit → poll Mailpit → extract URL → visit → confirm → land on `/home`
- Wrong code: 5 wrong attempts invalidate; 6th with correct code fails
- Expired code: code submitted >10 min after generation fails
- **Mailpit** as a docker-compose service. SMTP at `:1025`, JSON API at
`:8025`. Cypress task helpers (`cy.task`) call the Mailpit JSON API to
extract codes/links from captured emails.
- **Unit tests** for `generateCode()` distribution, `hashCode()`
determinism, rate-limit math (Vitest).
### Pre-release (manual gate on template changes)
- Run the template through [mail-tester.com](https://mail-tester.com) before
shipping. Target >9/10. Free, 60 seconds.
### Production
- Eyeball the SES Account dashboard weekly: bounce rate, complaint rate,
reputation status.
- One CloudWatch alarm: bounce rate >2% sustained → SNS → email the team.
- When investigating an incident, temporarily subscribe an SQS queue or
email endpoint to `dev-ses-events` SNS topic to capture events for the
duration. Unsubscribe after.
When volume grows past ~50 sign-ups/day, pick up the backlog ticket for the
SES event-consumer Lambda.
---
# Files touched (summary)
| Phase | File | Change |
|---|---|---|
| PR 1 | [src/app/email_templates/magic_link.ts](../../src/app/email_templates/magic_link.ts) | Add `X-SES-CONFIGURATION-SET` header; return `messageId` |
| PR 1 | [src/app/api/auth/[...nextauth]/authOptions.ts](../../src/app/api/auth/[...nextauth]/authOptions.ts) | Replace `sendVerificationRequest` logging with try/catch + messageId |
| PR 1 | Terraform `modules/ses/` | Add `renderingFailure` event type + cfg-set name output |
| PR 1 | Deployment env vars | Add `SES_CONFIGURATION_SET=dev-ses-config` |
| IT | DNS — `_dmarc.domna.homes` | Update DMARC TXT value |
| IT | M365 admin | Create `noreply@domna.homes` shared mailbox |
| PR 2 | [src/app/db/schema/users.ts](../../src/app/db/schema/users.ts) | Add `code_hash`, `attempts` columns; new `authRateLimits` table |
| PR 2 | `src/lib/auth/code.ts` (new) | `generateCode()`, `hashCode()` |
| PR 2 | [src/app/email_templates/magic_link.ts](../../src/app/email_templates/magic_link.ts) | Accept `code` arg; render code prominently in HTML + plaintext |
| PR 2 | [src/app/api/auth/[...nextauth]/authOptions.ts](../../src/app/api/auth/[...nextauth]/authOptions.ts) | Add `CredentialsProvider`; generate+persist code in `sendVerificationRequest`; drop `maxAge` to 600; change `verifyRequest` redirect |
| PR 2 | `src/app/auth/verify-code/page.tsx` (new) | Code-input form + resend button |
| PR 2 | `src/lib/auth/rate-limit.ts` (new) | Per-email-send, per-code-attempt, per-IP-verify limiters backed by `authRateLimits` table |
| PR 2 | `.github/workflows/test.yml` | Mailpit service + Cypress E2E |
| PR 2 | `docker-compose.yml` | Mailpit local-dev service |
| PR 2 | Deployment env vars | Add `AUTH_CODE_FALLBACK_ENABLED` flag |
---
# Open questions
None blocking. Possibilities to revisit after PR 2 ships:
- Do enterprise customers (paid relationships, formal SLAs) want Microsoft
work-account OAuth as their primary sign-in?
- Does the `.homes` TLD continue to be a deliverability bottleneck after
MAIL FROM + DMARC `p=reject` are in place? If yes, evaluate moving sends
to a `.com` / `.co.uk` we own.
- Does the SES event volume justify the Lambda from the backlog ticket?

View file

@ -1,13 +1,14 @@
# Landlord override frontend — in-flight design notes
**Status:** Paused mid-grilling (2026-05-27)
**Status:** Grilling complete (2026-05-28) — Q1Q7 resolved; ready to promote to ADR
**Branch:** `feature/frontend_landlord_overrides`
**Author:** Jun-te (with Claude, via `/grill-me`)
This is a *design-in-progress* document, not an ADR. It captures decisions made
so far on the landlord-override frontend plan so the conversation can resume
without re-litigating settled questions. Promote to an ADR once the trigger
mechanism (Q4) is resolved — that's the decision worth permanent recording.
This is a _design-in-progress_ document, not an ADR. It captures the decisions
reached during grilling so the conversation can resume without re-litigating
settled questions. All seven questions are now resolved; the trigger +
state-machine integration (Q4) is ready to promote to a frontend ADR-0003, and
the work is ready to break into issues.
## Goal
@ -23,14 +24,22 @@ Build the front-end e2e for `landlord_description_override`, starting from the
rationale in [ADR-0002](../adr/0002-landlord-override-vocabulary.md).
- Nothing in Next.js reads or writes them yet.
- The Python lambda at
`/workspaces/home/github/Model/applications/landlord_description_overrides/handler.py`
is **not deployed** and **not wired** into the BulkUpload pipeline. It
hardcodes its trigger params (`portfolio_id`, `s3_uri`) and its source column
names (`"Property Type"`, `"Walls"`, `"Roofs"`).
- Note: ADR-0002 says writes come from Next.js POST, but the current backend
writes direct to Postgres. This drift may need to be revisited under Q4.
`Model/applications/landlord_description_overrides/handler.py` (branch
`feature/landlord_data`) is **not deployed** and **not wired** into the
BulkUpload pipeline. It hardcodes the trigger fields (handler.py:55-60) and
builds a hardcoded `ClassifiableColumn` list with `source_column`s
`"Property Type"` (→ both `property_type` **and** `built_form_type`),
`"Walls"`, `"Roofs"`. It also caps the batch to 20 rows while under test.
- **Resolved drift (was a Q4 risk):** the backend's
[ADR-0003 (python-writes-landlord-overrides-directly)](https://github.com/Hestia-Homes/Model/blob/main/docs/adr/0003-python-writes-landlord-overrides-directly.md),
accepted 2026-05-26, **supersedes** ADR-0002's "writes happen from Next.js"
clause. The lambda computes **and** persists the classification directly to
Postgres via a SQLAlchemy `LandlordOverrideRepository[E]`, with an
`ON CONFLICT … WHERE source = 'classifier'` upsert. There is **no** Next.js
POST-back. Drizzle stays schema source-of-truth; the Python `SQLModel`
shadows it.
## Decided so far
## Decided
### Q1 — Scope
@ -41,8 +50,7 @@ to the lambda → lambda needs edits to work when deployed.
### Q2 — Categories
**All four classifier categories** get independent optional slots in
[`INTERNAL_FIELDS`](../../src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx#L14-L21):
**All four classifier categories** are independent optional fields:
`property_type`, `built_form_type`, `wall_type`, `roof_type`.
Rejected alternatives: (a) start with only PT+BF — wasted plumbing churn for
@ -50,58 +58,118 @@ the same migration cost; (c) collapse PT+BF into one UI slot — bakes a
backend coincidence (they read the same CSV column today) into the
user-facing model.
### Q2.1 — No `autoDetect` for the new slots
### Q2.1 — No `autoDetect` for classifier fields
The four new slots default to `"skip"`. The user must explicitly map them.
`autoDetect()` regex patterns are for required address-ish fields only.
The four classifier fields default to unmapped ("Not provided"). The user must
explicitly map them. `autoDetect()` is for required address-ish fields only.
**Why:** Address headers are unambiguous and required, so guessing is safe and
useful. Landlord-description columns are ambiguous (a "type" column could be
PropertyType or BuiltFormType or something else) and they are optional —
useful. Landlord-description columns are ambiguous and optional —
auto-detecting them would silently opt the landlord into classifier runs they
didn't intend.
## Open — resume here
### Q2.2 — Mapping shape: unified `field → header` (refines Q2's mechanism)
### Q3 (in flight) — Uniqueness validation on the mapping
The mapping is **one unified map keyed by internal field, valued by source CSV
header** (`{ address_1: "Addr 1", property_type: "Property Type",
built_form_type: "Property Type", wall_type: "Walls", … }`), replacing the
current `header → field` shape
([MapColumnsClient.tsx:62-64](<../../src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx#L62-L64>)).
Today validation only checks required fields exist
([MapColumnsClient.tsx:67-68](../../src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx#L67-L68));
two CSV headers can both map to `address_1` silently.
**Why:** the backend's `ClassifiableColumn` is a `(name, source_column)` pair
where **one `source_column` feeds many `name`s**`"Property Type"` feeds both
`property_type` and `built_form_type`
([classifiable_column.py:28-31](https://github.com/Hestia-Homes/Model/blob/feature/landlord_data/orchestration/classifiable_column.py)).
A `header → field` map (one value per header) **cannot express** that; the
chosen mechanism in Q2 (extending the single `INTERNAL_FIELDS` dropdown) would
leave `built_form_type` unmappable. `field → header` matches the backend model
1:1 and lets multiple categories share a header.
- (a) Leave it alone — backend last-wins.
- (b) Enforce uniqueness only on the four new slots.
- (c) Enforce uniqueness everywhere except `skip`. **Recommended.**
**Consequences:** UI inverts to one row per internal field with a header-picker;
`autoDetect` inverts to "best header per field"; "skip" becomes a per-field
"Not provided"; rewrite
[`transformFile`](<../../src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/start-address-matching/route.ts#L17-L54>)
(currently iterates `header → field`), the `z.record` route schema, and
**migrate existing persisted `columnMapping` rows** (semantics flip) — see Q5.
### Q4 (queued — biggest) — Trigger mechanism
### Q3 — Sharing / uniqueness rule (reframed)
How does Next.js invoke the lambda once mapping is complete? SQS message?
Direct lambda invoke? HTTP endpoint? And what's the state-machine integration —
new `BulkUpload` status, or runs orthogonally to address matching?
A source header may feed **at most one address/reference field**, but **any
number of classifier fields** (PT + BF → `"Property Type"` is required, so
classifier sharing must be allowed). Required-field validation stays:
`address_1` and `postcode` must each be assigned a header.
This drives both the deployment work and the lambda edits. Likely worth its
own ADR once decided.
This **replaces** the original "enforce uniqueness everywhere except skip"
framing, which was wrong once classifier header-sharing became a requirement.
### Q5 (queued) — Persistence of the extended mapping
### Q4 — Trigger mechanism + state-machine integration (the big one)
Current `bulkAddressUploads.columnMapping` is a `Record<string, string>` and
naturally accommodates the new slots. Confirm no separate table is needed.
- **Transport:** reuse
[`triggerFastApiPipeline`](../../src/lib/bulkUpload/server.ts) with a **new
FastAPI endpoint**; payload `{ task_id, sub_task_id, s3_uri, portfolio_id,
column_mapping }`. FastAPI turns the POST into the SQS subtask envelope the
handler TODO (handler.py:51-54) references. Not a direct lambda invoke, not
Next.js → SQS.
- **`s3_uri` is the ORIGINAL upload** (`upload.s3Bucket/s3Key`), **not** the
address-matching transformed CSV — the description columns and their original
header names only survive in the original (the address transform strips every
non-address column).
- **Writes:** lambda writes overrides directly to Postgres (ADR-0003); no
POST-back.
- **State machine:** the classifier runs as a **subtask under the same address
task** (not a separate task, not a new `BulkUpload` status). Both subtasks
fire together at the **"Start address matching"** action. Safe: the combiner
globs S3 `ara_raw_outputs/{task_id}/`, which the classifier never writes to,
so the combined address output is not affected; `subtask_handler` does no
sibling-completion gating.
- **Progress honesty:** add a nullable **`service`/`kind` discriminator to
`sub_task`** (existing rows = address/legacy) so the progress view shows
address batches vs classification separately and attributes failures
correctly. Update the 3 subtask-count sites + `OnboardingProgress`.
### Q6 (queued) — Lambda edits
### Q5 — Persistence + migration of the inverted mapping
Handler hardcodes `source_column="Property Type" / "Walls" / "Roofs"`; needs
to read the mapping from the trigger body.
`LandlordDescriptionOverridesTriggerBody` already exists — check what fields
it has vs needs.
Reuse `bulkAddressUploads.columnMapping` (jsonb `Record<string, string>`); **no
separate table**. The Q2.2 inversion (`header → field``field → header`) is
handled by a **one-shot data migration**: invert each non-skip entry; on a
legacy duplicate (two headers → one field, which the new Q3 rule forbids
anyway) last-write-wins; `skip` entries drop. The migration is a no-op on empty
tables, so it's safe regardless of data volume. `validateMapping`
([server.ts:97-102](../../src/lib/bulkUpload/server.ts#L97-L102)) and the route
schema must be rewritten for the inverted shape (they currently check the
**values** for `address_1`/`postcode`).
### Q7 (queued) — Review/Edit UI for classified mappings
### Q6 — Lambda edits
Is the per-row review/edit surface in scope for this iteration, or deferred?
User has not addressed yet. ADR-0002 calls this "the future override
frontend" and treats it as deferred work — but "front end e2e from
bulk_upload" could reasonably include it.
`LandlordDescriptionOverridesTriggerBody` (task_id, sub_task_id, s3_uri,
portfolio_id; `extra="allow"`) gains a **`column_mapping: dict[str, str]`**
field carrying **only the classifier subset** (`category → source header`); the
frontend extracts the four classifier keys from its unified map before sending.
The handler keeps a fixed registry of the four category builders (each owns its
enum, repo, and any hint such as the wall construction-date one) and supplies
`source_column` from `column_mapping`, **skipping categories the user didn't
map**. Both `property_type` and `built_form_type` carry `"Property Type"`, so
PT+BF sharing falls out for free. Drop the hardcoded trigger and the 20-row cap.
## Resuming
### Q7 — Results UI: read-only this iteration
Re-read this file, then ask Q3. Don't re-litigate Q1/Q2 unless the user
reopens them.
This iteration ships a **read-only** results surface: it reads the four
`landlord_*_overrides` tables and shows each `description → value` (per
portfolio) so the pipeline is observably e2e. **No editing / write-back** — the
user-override write path (correcting a row to `source='user'`, which the
classifier upsert won't overwrite) is the deferred "future override frontend"
ADR-0002 anticipates.
## Next steps
Grilling is complete (Q1Q7). Suggested follow-ups:
1. Promote Q4 (trigger + state-machine integration) to a **frontend ADR-0003**;
cross-link the backend's ADR-0003 (which already superseded ADR-0002's
Next.js-writes clause).
2. Break the work into issues (`/to-issues`): (a) invert the mapping shape + UI
+ migration + validation; (b) `sub_task` discriminator + progress view;
(c) classifier trigger (new FastAPI endpoint + payload, fire at "Start");
(d) lambda edits (trigger body + registry-from-mapping); (e) read-only
results view.
3. Confirm the working branch (see header) before implementation starts.

View file

@ -12,6 +12,7 @@ import {
sessions as sessionsTable,
verificationTokens as verificationTokensTable,
} from "@/app/db/schema/users";
import { normaliseEmail } from "@/app/lib/email";
/**
* Custom Drizzle adapter for NextAuth v4
@ -48,8 +49,6 @@ export default function DrizzleEmailAdapter(
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
const normaliseEmail = (email: string) => email.trim().toLowerCase();
const toAdapterUser = (u: any): AdapterUser => ({
id: String(u.id),
dbId: String(u.id),

View file

@ -2,16 +2,25 @@ import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
import EmailProvider from "next-auth/providers/email";
import CredentialsProvider from "next-auth/providers/credentials";
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
import { MagicLinksEmail } from "@/app/email_templates/magic_link";
import {
evaluateCodeAttempt,
generateCode,
hashCode,
} from "@/app/lib/verificationCode";
import { createHash } from "crypto";
import { db } from "@/app/db/db";
import {
user as users,
accounts,
authRateLimits,
verificationTokens,
} from "@/app/db/schema/users";
import { eq, and } from "drizzle-orm";
import { normaliseEmail } from "@/app/lib/email";
import { eq, and, ne } from "drizzle-orm";
// ------------------------------------------------------------------
// Environment variables
@ -80,17 +89,222 @@ export const AuthOptions: NextAuthOptions = {
pass: EMAIL_SERVER_PASSWORD,
},
},
from: EMAIL_FROM, // noreply email
maxAge: 60 * 60, // magic link valid for 1 hour
// Slightly extended magic link email sender, to log sends
sendVerificationRequest: async ({ identifier, url, provider }) => {
console.log("EMAIL MAGIC LINK SENT:", {
email: identifier,
url,
timestamp: new Date().toISOString(),
from: EMAIL_FROM,
maxAge: 60 * 10, // code and link share a 10-minute window
sendVerificationRequest: async ({ identifier, url, provider, token }) => {
const secret = process.env.NEXTAUTH_SECRET!;
const hashedToken = createHash("sha256")
.update(`${token}${secret}`)
.digest("hex");
const now = new Date();
const oneHourMs = 60 * 60 * 1000;
const SEND_LIMIT = 5;
// Per-email send rate limit, fixed 1-hour window.
const limited = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(authRateLimits)
.where(
and(
eq(authRateLimits.scope, "send"),
eq(authRateLimits.key, identifier),
),
);
const inWindow =
existing &&
now.getTime() - existing.windowStart.getTime() < oneHourMs;
if (inWindow) {
if (existing.count >= SEND_LIMIT) return true;
await tx
.update(authRateLimits)
.set({ count: existing.count + 1 })
.where(
and(
eq(authRateLimits.scope, "send"),
eq(authRateLimits.key, identifier),
),
);
return false;
}
await tx
.insert(authRateLimits)
.values({
scope: "send",
key: identifier,
count: 1,
windowStart: now,
})
.onConflictDoUpdate({
target: [authRateLimits.scope, authRateLimits.key],
set: { count: 1, windowStart: now },
});
return false;
});
await MagicLinksEmail({ identifier, url, provider });
if (limited) {
await db
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, identifier),
eq(verificationTokens.token, hashedToken),
),
);
console.warn("EMAIL_RATE_LIMIT_EXCEEDED", {
email: identifier,
timestamp: now.toISOString(),
});
throw new Error(
"Too many sign-in attempts. Please wait an hour before requesting another code.",
);
}
// Generate code, attach to the just-created row, replace any older rows
// for this identifier so only the latest send is live.
const code = generateCode();
const codeHash = hashCode(code, secret);
await db.transaction(async (tx) => {
await tx
.update(verificationTokens)
.set({ codeHash, attempts: 0 })
.where(
and(
eq(verificationTokens.identifier, identifier),
eq(verificationTokens.token, hashedToken),
),
);
await tx
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, identifier),
ne(verificationTokens.token, hashedToken),
),
);
});
try {
const { messageId } = await MagicLinksEmail({
identifier,
url,
provider,
code,
});
console.log("EMAIL_MAGIC_LINK_SUCCESS", {
email: identifier,
messageId,
timestamp: new Date().toISOString(),
});
} catch (err) {
console.error("EMAIL_MAGIC_LINK_FAILURE", {
email: identifier,
error: err instanceof Error ? err.message : String(err),
timestamp: new Date().toISOString(),
});
throw err;
}
},
}),
// ------------------ Email code (typed at /auth/verify-code) ------------------
CredentialsProvider({
id: "email-code",
name: "Email Code",
credentials: {
email: { label: "Email", type: "email" },
code: { label: "Code", type: "text" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.code) return null;
const email = credentials.email.trim().toLowerCase();
const submitted = credentials.code.trim();
if (!/^\d{6}$/.test(submitted)) return null;
const secret = process.env.NEXTAUTH_SECRET!;
const submittedCodeHash = hashCode(submitted, secret);
const now = new Date();
return await db.transaction(async (tx) => {
const [row] = await tx
.select()
.from(verificationTokens)
.where(eq(verificationTokens.identifier, email))
.limit(1);
const outcome = evaluateCodeAttempt({
submittedCodeHash,
row: row
? {
codeHash: row.codeHash,
attempts: row.attempts,
expires: row.expires,
}
: null,
now,
});
if (outcome.outcome === "ok") {
await tx
.delete(verificationTokens)
.where(eq(verificationTokens.identifier, email));
let [dbUser] = await tx
.select()
.from(users)
.where(eq(users.email, email));
if (!dbUser) {
[dbUser] = await tx
.insert(users)
.values({ email, emailVerified: now })
.returning();
} else if (!dbUser.emailVerified) {
await tx
.update(users)
.set({ emailVerified: now })
.where(eq(users.id, dbUser.id));
}
console.log("EMAIL_CODE_SIGN_IN_SUCCESS", {
email,
userId: String(dbUser.id),
timestamp: now.toISOString(),
});
return {
id: String(dbUser.id),
email: dbUser.email,
name: dbUser.firstName ?? null,
dbId: String(dbUser.id),
onboarded: dbUser.onboarded ?? false,
};
}
if (outcome.outcome === "wrong") {
await tx
.update(verificationTokens)
.set({ attempts: outcome.newAttempts })
.where(eq(verificationTokens.identifier, email));
return null;
}
if (
outcome.outcome === "locked-out" ||
outcome.outcome === "expired"
) {
await tx
.delete(verificationTokens)
.where(eq(verificationTokens.identifier, email));
return null;
}
return null;
});
},
}),
],
@ -99,8 +313,8 @@ export const AuthOptions: NextAuthOptions = {
// Pages
// ------------------------------------------------------------------
pages: {
signIn: "/", // your landing/login page
verifyRequest: "/auth/verify-request",
signIn: "/",
verifyRequest: "/auth/verify-code",
error: "/api/auth/error",
},
@ -176,8 +390,9 @@ export const AuthOptions: NextAuthOptions = {
}
}
// Link OAuth ID if missing (helps for older accounts)
if (account && !dbUser.oauthId) {
// Link OAuth ID if missing (helps for older accounts). Only for OAuth
// providers — the email-code credentials flow has no provider identity.
if (account?.type === "oauth" && !dbUser.oauthId) {
console.log("Backfilling OAuth ID:", {
email: normalisedEmail,
provider: account.provider,
@ -195,6 +410,10 @@ export const AuthOptions: NextAuthOptions = {
.set({ lastLogin: new Date() })
.where(eq(users.id, dbUser.id));
// Pending portfolio invitations are NOT auto-applied here anymore.
// The invitee accepts/declines explicitly via the profile-menu
// notifications panel (POST /api/user/invitations).
// Pass bigint ID into NextAuth session/jwt
user.dbId = dbUser.id.toString();
user.onboarded = dbUser.onboarded ?? false;
@ -238,11 +457,18 @@ export const AuthOptions: NextAuthOptions = {
},
/**
* Attach dbId to session.user
* Attach dbId to session.user, and normalise the email so downstream
* lookups against `user.email` are case-insensitive without each call site
* remembering to lowercase.
*/
async session({ session, token }) {
if (session.user && token.dbId) {
session.user.dbId = token.dbId;
if (session.user) {
if (session.user.email) {
session.user.email = normaliseEmail(session.user.email);
}
if (token.dbId) {
session.user.dbId = token.dbId;
}
}
return session;
},
@ -251,13 +477,10 @@ export const AuthOptions: NextAuthOptions = {
* Redirect users after login
*/
async redirect({ url, baseUrl }) {
// If the user has not onboarded, send them to onboarding
// This logging is too noisy
// console.log("Redirect triggered:", {
// from: url,
// to: `${baseUrl}/home`,
// timestamp: new Date().toISOString(),
// });
// Respect internal callbackUrl so e.g. invitation emails can deep-link
// to /portfolio/<id> after sign-in. Default to /home for bare sign-ins.
if (url.startsWith("/")) return `${baseUrl}${url}`;
if (url.startsWith(baseUrl)) return url;
return `${baseUrl}/home`;
},
},

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { inviteRequestSchema } from "./inviteRequestSchema";
describe("inviteRequestSchema", () => {
it("accepts an invite request with just email and role (no name)", () => {
const result = inviteRequestSchema.parse({
email: "alice@example.com",
role: "read",
});
expect(result).toEqual({
email: "alice@example.com",
role: "read",
});
});
it("silently drops an unknown name field (in-flight clients still parse)", () => {
const result = inviteRequestSchema.parse({
email: "alice@example.com",
role: "write",
name: "Alice",
});
expect(result).toEqual({
email: "alice@example.com",
role: "write",
});
expect(result).not.toHaveProperty("name");
});
it("rejects an invite request with a malformed email", () => {
expect(() =>
inviteRequestSchema.parse({ email: "not-an-email", role: "read" }),
).toThrow();
});
it("rejects an invite request with a role outside the enum", () => {
expect(() =>
inviteRequestSchema.parse({
email: "alice@example.com",
role: "superuser",
}),
).toThrow();
});
});

View file

@ -0,0 +1,10 @@
import { z } from "zod";
import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
// Body schema for POST /api/portfolio/[portfolioId]/collaborators. Lives in
// its own file because Next.js 15 rejects non-handler named exports from
// route.ts. See route.test.ts for the contract tests.
export const inviteRequestSchema = z.object({
email: z.string().email(),
role: z.enum(ROLE_OPTIONS),
});

View file

@ -0,0 +1,349 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import {
portfolio,
portfolioInvitations,
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import {
recommendation,
recommendationMaterials,
planRecommendations,
plan,
scenario,
} from "@/app/db/schema/recommendations";
import {
propertyTargets,
propertyDetailsEpc,
property,
} from "@/app/db/schema/property";
import { and, eq, inArray, Name } from "drizzle-orm";
import { z } from "zod";
import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
import { normaliseEmail } from "@/app/lib/email";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { PortfolioInvitationEmail } from "@/app/email_templates/portfolio_invitation";
import {
denyIfNotAdmin,
resolvePortfolioPrivilege,
} from "@/app/lib/resolvePortfolioPrivilege";
import { inviteRequestSchema } from "./inviteRequestSchema";
// Get collaborators (users) that have access to the portfolio, plus the
// effective privilege of the requesting user (so the UI knows which actions
// to expose).
export async function GET(
_req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId || !session.user.email) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
try {
const pId = BigInt(portfolioId);
const rows = await db
.select({
portfolioUserId: portfolioUsers.id,
userId: portfolioUsers.userId,
role: portfolioUsers.role,
name: user.firstName,
email: user.email,
})
.from(portfolioUsers)
.leftJoin(user, eq(user.id, portfolioUsers.userId))
.where(eq(portfolioUsers.portfolioId, pId));
const collaborators = rows.map((r) => ({
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString() : null,
userId: r.userId ? r.userId.toString() : null,
role: r.role,
name: r.name ?? null,
email: r.email ?? "",
}));
const privilege = await resolvePortfolioPrivilege({
portfolioId: pId,
userId: BigInt(session.user.dbId),
userEmail: session.user.email,
});
return NextResponse.json(
{ users: collaborators, currentUser: { privilege } },
{ status: 200 },
);
} catch (err) {
console.error("GET /users error:", err);
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}
// DELETE: remove a collaborator from this portfolio.
export async function DELETE(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
const bodySchema = z.object({ portfolioUserId: z.string() });
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
const pId = BigInt(portfolioId);
const puId = BigInt(body.portfolioUserId);
// Refuse to remove the creator — they own the portfolio.
const [target] = await db
.select({ id: portfolioUsers.id, role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.id, puId),
eq(portfolioUsers.portfolioId, pId),
),
)
.limit(1);
if (!target) {
return NextResponse.json(
{ error: "Membership not found in this portfolio" },
{ status: 404 },
);
}
if (target.role === "creator") {
return NextResponse.json(
{ error: "Cannot remove the portfolio creator" },
{ status: 400 },
);
}
await db.delete(portfolioUsers).where(eq(portfolioUsers.id, puId));
return NextResponse.json(
{ success: true, portfolioUserId: body.portfolioUserId },
{ status: 200 },
);
} catch (err) {
console.error("DELETE /collaborators error:", err);
return NextResponse.json(
{ error: "Failed to remove user" },
{ status: 500 },
);
}
}
// PUT: update a collaborators role
export async function PUT(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
// Validate request body
const bodySchema = z.object({
portfolioUserId: z.string(),
role: z.enum(ROLE_OPTIONS), // adjust to your Role union
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch (err) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
// Update role for this portfolioUserId
await db
.update(portfolioUsers)
.set({ role: body.role })
.where(eq(portfolioUsers.id, BigInt(body.portfolioUserId)));
return NextResponse.json(
{ success: true, portfolioUserId: body.portfolioUserId, role: body.role },
{ status: 200 }
);
} catch (err) {
console.error("PUT /collaborators error:", err);
return NextResponse.json(
{ error: "Failed to update role" },
{ status: 500 }
);
}
}
// POST: invite a user by email.
//
// Unified flow: in nearly every case we write a pending portfolio_invitations
// row, and the invitee accepts/declines explicitly via the in-app dropdown.
// The only fast-path is when the invitee is *already* a member of this
// portfolio — then it's just a role update with no email or invitation.
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
let body: z.infer<typeof inviteRequestSchema>;
try {
body = inviteRequestSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const email = normaliseEmail(body.email);
const session = await getServerSession(AuthOptions);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
const inviterUserId = BigInt(session!.user!.dbId!);
try {
const pId = BigInt(portfolioId);
const [portfolioRow] = await db
.select({ name: portfolio.name })
.from(portfolio)
.where(eq(portfolio.id, pId))
.limit(1);
if (!portfolioRow) {
return NextResponse.json(
{ error: "Portfolio not found" },
{ status: 404 },
);
}
const [inviterRow] = await db
.select({ firstName: user.firstName, email: user.email })
.from(user)
.where(eq(user.id, inviterUserId))
.limit(1);
const inviterName =
inviterRow?.firstName ?? inviterRow?.email ?? "Someone at Domna";
const appOrigin =
process.env.NEXTAUTH_URL ?? `https://${req.headers.get("host")}`;
const [existingUser] = await db
.select({ id: user.id, firstName: user.firstName, email: user.email })
.from(user)
.where(eq(user.email, email))
.limit(1);
// Fast path: invitee is already a member of this portfolio. Just adjust
// their role if it changed — no invitation, no email.
if (existingUser) {
const [existingMembership] = await db
.select({ id: portfolioUsers.id, role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, pId),
eq(portfolioUsers.userId, existingUser.id),
),
)
.limit(1);
if (existingMembership) {
if (existingMembership.role !== body.role) {
await db
.update(portfolioUsers)
.set({ role: body.role })
.where(eq(portfolioUsers.id, existingMembership.id));
}
return NextResponse.json(
{
user: {
portfolioUserId: existingMembership.id.toString(),
userId: existingUser.id.toString(),
role: body.role,
name: existingUser.firstName ?? null,
email,
kind: "member" as const,
},
},
{ status: 200 },
);
}
}
// Standard path (whether or not the user already exists): write a pending
// invitation. The invitee accepts/declines via their in-app dropdown.
const [invitation] = await db
.insert(portfolioInvitations)
.values({
portfolioId: pId,
email,
role: body.role,
invitedByUserId: inviterUserId,
})
.onConflictDoUpdate({
target: [
portfolioInvitations.portfolioId,
portfolioInvitations.email,
],
set: { role: body.role },
})
.returning({
id: portfolioInvitations.id,
role: portfolioInvitations.role,
});
try {
await PortfolioInvitationEmail({
identifier: email,
portfolioName: portfolioRow.name,
inviterName,
linkUrl: appOrigin,
mode: existingUser ? "existing-user" : "new-user",
});
} catch (mailErr) {
console.error("PORTFOLIO_INVITATION_EMAIL_FAILURE", {
email,
error: mailErr instanceof Error ? mailErr.message : String(mailErr),
});
}
return NextResponse.json(
{
user: {
portfolioUserId: null,
userId: null,
invitationId: invitation.id.toString(),
role: invitation.role,
name: null,
email,
kind: "invitation" as const,
},
},
{ status: 200 },
);
} catch (err) {
console.error("POST /collaborators error:", err);
return NextResponse.json(
{ error: "Failed to invite user" },
{ status: 500 }
);
}
}

View file

@ -1,207 +0,0 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import {
recommendation,
recommendationMaterials,
planRecommendations,
plan,
scenario,
} from "@/app/db/schema/recommendations";
import {
propertyTargets,
propertyDetailsEpc,
property,
} from "@/app/db/schema/property";
import { eq, inArray, Name } from "drizzle-orm";
import { z } from "zod";
import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
// Get colloborators (users) that have access to the portfolio
export async function GET(
_req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
try {
const rows = await db
.select({
portfolioUserId: portfolioUsers.id,
userId: portfolioUsers.userId,
role: portfolioUsers.role,
name: user.firstName,
email: user.email,
})
.from(portfolioUsers)
.leftJoin(user, eq(user.id, portfolioUsers.userId))
.where(eq(portfolioUsers.portfolioId, BigInt(portfolioId)));
// Explicitly normalize BigInts to strings
const collaborators = rows.map((r) => ({
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString() : null,
userId: r.userId ? r.userId.toString() : null,
role: r.role,
name: r.name ?? null,
email: r.email ?? "",
}));
return NextResponse.json({ users: collaborators }, { status: 200 });
} catch (err) {
console.error("GET /users error:", err);
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}
// PUT: update a collaborators role
export async function PUT(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
// Validate request body
const bodySchema = z.object({
portfolioUserId: z.string(),
role: z.enum(ROLE_OPTIONS), // adjust to your Role union
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch (err) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
// Update role for this portfolioUserId
await db
.update(portfolioUsers)
.set({ role: body.role })
.where(eq(portfolioUsers.id, BigInt(body.portfolioUserId)));
return NextResponse.json(
{ success: true, portfolioUserId: body.portfolioUserId, role: body.role },
{ status: 200 }
);
} catch (err) {
console.error("PUT /colloborators error:", err);
return NextResponse.json(
{ error: "Failed to update role" },
{ status: 500 }
);
}
}
// POST: invite a user by email (find-or-create user, then add to portfolio with role)
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
// 1) Validate payload
const bodySchema = z.object({
email: z.string().email(),
role: z.enum(ROLE_OPTIONS),
name: z.string(),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
const pId = BigInt(portfolioId);
// 2) Find or create the user by email
// Try to find existing user
let existing = await db
.select({ id: user.id, firstName: user.firstName, email: user.email })
.from(user)
.where(eq(user.email, body.email))
.limit(1);
let createdUserId: bigint | null = existing[0]?.id ?? null;
// If not found, create. Prefer Postgres upsert to avoid race.
if (!createdUserId) {
// If youre on Postgres, this is ideal:
const inserted = await db
.insert(user)
.values({
email: body.email,
firstName: body.name,
oauthProvider: "credentials",
})
.onConflictDoNothing() // relies on a UNIQUE(email) constraint
.returning({ id: user.id });
if (inserted.length > 0) {
createdUserId = inserted[0].id;
} else {
// Someone else created the user concurrently; fetch it
const fetched = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, body.email))
.limit(1);
if (!fetched[0]) {
return NextResponse.json(
{ error: "Failed to create or fetch user" },
{ status: 500 }
);
}
createdUserId = fetched[0].id;
}
}
// 3) Link user to portfolio with role (upsert)
// Assumes a UNIQUE index on (portfolioId, userId) in portfolioUsers.
const linkResult = await db
.insert(portfolioUsers)
.values({
portfolioId: pId,
userId: createdUserId!,
role: body.role,
})
.returning({
portfolioUserId: portfolioUsers.id,
userId: portfolioUsers.userId,
role: portfolioUsers.role,
});
const row = linkResult[0];
if (!row) {
return NextResponse.json(
{ error: "Failed to create portfolio user" },
{ status: 500 }
);
}
const collaborator = {
portfolioUserId: row.portfolioUserId?.toString() ?? null,
userId: row.userId?.toString() ?? null,
role: row.role,
name: body.name ?? null,
email: body.email,
};
// 201 if it was a new link, 200 if it was an update — we cant easily
// tell from .onConflictDoUpdate return, so just use 200 OK.
return NextResponse.json({ user: collaborator }, { status: 200 });
} catch (err) {
console.error("POST /colloborators error:", err);
return NextResponse.json(
{ error: "Failed to invite user" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,104 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolioInvitations } from "@/app/db/schema/portfolio";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { denyIfNotAdmin } from "@/app/lib/resolvePortfolioPrivilege";
// GET: list pending invitations for a portfolio. Invitations are consumed
// (deleted) when the invitee signs in, so anything returned here is still
// pending.
export async function GET(
_req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
try {
const pId = BigInt(portfolioId);
const rows = await db
.select({
id: portfolioInvitations.id,
email: portfolioInvitations.email,
role: portfolioInvitations.role,
createdAt: portfolioInvitations.createdAt,
})
.from(portfolioInvitations)
.where(eq(portfolioInvitations.portfolioId, pId));
const invitations = rows.map((r) => ({
invitationId: r.id.toString(),
email: r.email,
role: r.role,
createdAt: r.createdAt.toISOString(),
}));
return NextResponse.json({ invitations }, { status: 200 });
} catch (err) {
console.error("GET /invitations error:", err);
return NextResponse.json(
{ error: "Failed to fetch invitations" },
{ status: 500 },
);
}
}
// DELETE: revoke a pending invitation. Idempotent — 404 if it's already
// been consumed or revoked.
export async function DELETE(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
const bodySchema = z.object({ invitationId: z.string() });
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
const pId = BigInt(portfolioId);
const invId = BigInt(body.invitationId);
const result = await db
.delete(portfolioInvitations)
.where(
and(
eq(portfolioInvitations.id, invId),
eq(portfolioInvitations.portfolioId, pId),
),
)
.returning({ id: portfolioInvitations.id });
if (result.length === 0) {
return NextResponse.json(
{ error: "Invitation not found in this portfolio" },
{ status: 404 },
);
}
return NextResponse.json(
{ success: true, invitationId: body.invitationId },
{ status: 200 },
);
} catch (err) {
console.error("DELETE /invitations error:", err);
return NextResponse.json(
{ error: "Failed to revoke invitation" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,190 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import {
portfolio,
portfolioInvitations,
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { normaliseEmail } from "@/app/lib/email";
import { planInvitationApplication } from "@/app/lib/portfolioInvitations";
// GET: list pending portfolio invitations addressed to the current user's
// email, across all portfolios. Used by the profile-menu notifications panel.
export async function GET() {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId || !session.user.email) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const email = normaliseEmail(session.user.email);
try {
const rows = await db
.select({
id: portfolioInvitations.id,
portfolioId: portfolioInvitations.portfolioId,
portfolioName: portfolio.name,
role: portfolioInvitations.role,
invitedByName: user.firstName,
invitedByEmail: user.email,
createdAt: portfolioInvitations.createdAt,
})
.from(portfolioInvitations)
.innerJoin(portfolio, eq(portfolio.id, portfolioInvitations.portfolioId))
.leftJoin(user, eq(user.id, portfolioInvitations.invitedByUserId))
.where(eq(portfolioInvitations.email, email));
return NextResponse.json(
{
invitations: rows.map((r) => ({
invitationId: r.id.toString(),
portfolioId: r.portfolioId.toString(),
portfolioName: r.portfolioName,
role: r.role,
invitedByName: r.invitedByName ?? r.invitedByEmail ?? null,
createdAt: r.createdAt.toISOString(),
})),
},
{ status: 200 },
);
} catch (err) {
console.error("GET /user/invitations error:", err);
return NextResponse.json(
{ error: "Failed to fetch invitations" },
{ status: 500 },
);
}
}
// POST: accept or decline an invitation addressed to the current user.
// { invitationId, action: "accept" | "decline" }
//
// Accept: writes the portfolioUsers row (skipped if already a member) and
// deletes the invitation atomically.
// Decline: deletes the invitation. Silent — no inviter notification.
export async function POST(req: NextRequest) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId || !session.user.email) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const sessionEmail = normaliseEmail(session.user.email);
const sessionUserId = BigInt(session.user.dbId);
const bodySchema = z.object({
invitationId: z.string(),
action: z.enum(["accept", "decline"]),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
const invId = BigInt(body.invitationId);
const [invitation] = await db
.select({
id: portfolioInvitations.id,
portfolioId: portfolioInvitations.portfolioId,
email: portfolioInvitations.email,
role: portfolioInvitations.role,
})
.from(portfolioInvitations)
.where(eq(portfolioInvitations.id, invId))
.limit(1);
if (!invitation) {
return NextResponse.json(
{ error: "Invitation not found" },
{ status: 404 },
);
}
if (invitation.email !== sessionEmail) {
// Either someone else's invitation or address mismatch — treat as
// not-found so we don't leak existence of other users' invitations.
return NextResponse.json(
{ error: "Invitation not found" },
{ status: 404 },
);
}
if (body.action === "decline") {
await db
.delete(portfolioInvitations)
.where(eq(portfolioInvitations.id, invId));
console.log("INVITATION_DECLINED", {
email: sessionEmail,
invitationId: body.invitationId,
portfolioId: invitation.portfolioId.toString(),
});
return NextResponse.json(
{ success: true, action: "declined" },
{ status: 200 },
);
}
// Accept: load existing memberships so we don't double-insert, then
// delegate to the shared planning function.
const existing = await db
.select({ portfolioId: portfolioUsers.portfolioId })
.from(portfolioUsers)
.where(eq(portfolioUsers.userId, sessionUserId));
const plan = planInvitationApplication({
userId: sessionUserId,
invitations: [
{
id: invitation.id,
portfolioId: invitation.portfolioId,
role: invitation.role as "creator" | "admin" | "read" | "write",
},
],
existingPortfolioIds: new Set(existing.map((m) => m.portfolioId)),
});
await db.transaction(async (tx) => {
if (plan.memberships.length > 0) {
await tx.insert(portfolioUsers).values(plan.memberships);
}
for (const id of plan.invitationsToDelete) {
await tx
.delete(portfolioInvitations)
.where(eq(portfolioInvitations.id, id));
}
});
console.log("INVITATION_ACCEPTED", {
email: sessionEmail,
invitationId: body.invitationId,
portfolioId: invitation.portfolioId.toString(),
newMembership: plan.memberships.length > 0,
});
// /home renders the user's portfolio list from the DB in a server
// component; invalidate so the next navigation there picks up the new
// membership. router.refresh() handles the in-place case client-side.
revalidatePath("/home");
return NextResponse.json(
{
success: true,
action: "accepted",
portfolioId: invitation.portfolioId.toString(),
},
{ status: 200 },
);
} catch (err) {
console.error("POST /user/invitations error:", err);
return NextResponse.json(
{ error: "Failed to update invitation" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,136 @@
"use client";
import { useRef, useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
const RESEND_COOLDOWN_MS = 30_000;
type VerifyStatus = "idle" | "verifying";
type ResendStatus = "idle" | "resending" | "cooldown";
export default function VerifyCodeForm({ email }: { email: string }) {
const router = useRouter();
const [code, setCode] = useState("");
const [verifyStatus, setVerifyStatus] = useState<VerifyStatus>("idle");
const [resendStatus, setResendStatus] = useState<ResendStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [resendNotice, setResendNotice] = useState<string | null>(null);
const inFlightRef = useRef(false);
const isWorking =
verifyStatus === "verifying" || resendStatus === "resending";
async function submitCode(value: string) {
if (inFlightRef.current) return;
if (!/^\d{6}$/.test(value)) return;
inFlightRef.current = true;
setVerifyStatus("verifying");
setError(null);
const res = await signIn("email-code", {
email,
code: value,
redirect: false,
});
inFlightRef.current = false;
if (res?.ok) {
router.push("/home");
return;
}
setVerifyStatus("idle");
setCode("");
setError(
"That code didn't match. Check the latest email — older codes stop working as soon as you request a new one.",
);
}
function handleChange(next: string) {
const digits = next.replace(/\D/g, "").slice(0, 6);
setCode(digits);
if (error) setError(null);
if (resendNotice) setResendNotice(null);
if (digits.length === 6) void submitCode(digits);
}
async function handleResend() {
if (isWorking || resendStatus === "cooldown" || !email) return;
setResendStatus("resending");
setError(null);
setResendNotice(null);
const res = await signIn("email", { email, redirect: false });
if (res?.error) {
setResendStatus("idle");
setError(
"We couldn't send a new code right now. Wait a minute and try again.",
);
return;
}
setResendNotice("A new code is on its way. Older codes stop working.");
setResendStatus("cooldown");
setTimeout(() => setResendStatus("idle"), RESEND_COOLDOWN_MS);
}
const resendLabel =
resendStatus === "resending"
? "Sending…"
: resendStatus === "cooldown"
? "Code sent — wait a moment"
: "Resend code";
return (
<div className="space-y-4 text-left">
<div>
<label
htmlFor="verify-code"
className="block text-xs font-medium text-gray-600 mb-2 text-center"
>
Sign-in code
</label>
<Input
id="verify-code"
inputMode="numeric"
autoComplete="one-time-code"
value={code}
onChange={(e) => handleChange(e.target.value)}
placeholder="••••••"
className="h-12 text-center text-2xl tracking-[0.5em] font-mono"
disabled={verifyStatus === "verifying"}
autoFocus
/>
</div>
<Button
type="button"
onClick={() => submitCode(code)}
disabled={isWorking || code.length !== 6}
className="bg-brandbrown hover:bg-hoverblue w-full text-base py-3"
>
{verifyStatus === "verifying" ? "Verifying…" : "Sign in"}
</Button>
<div className="text-center text-sm space-y-2">
{error && <p className="text-red-500">{error}</p>}
{resendNotice && !error && (
<p className="text-green-600">{resendNotice}</p>
)}
<button
type="button"
onClick={handleResend}
disabled={isWorking || resendStatus === "cooldown" || !email}
className="text-brandblue hover:underline disabled:text-gray-400 disabled:no-underline"
>
{resendLabel}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { Card } from "@/app/shadcn_components/ui/card";
import { ShieldCheck } from "lucide-react";
import VerifyCodeForm from "./VerifyCodeForm";
export default async function VerifyCodePage({
searchParams,
}: {
searchParams: Promise<{ email?: string }>;
}) {
const { email } = await searchParams;
return (
<div className="relative min-h-screen flex flex-col bg-gradient-to-b from-gray-50 to-white overflow-hidden">
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute -top-24 -left-24 w-[28rem] h-[28rem] bg-brandblue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 w-[30rem] h-[30rem] bg-midblue/10 rounded-full blur-3xl" />
</div>
<div className="relative bg-gradient-to-r from-brandblue to-midblue text-white py-16 px-8">
<div className="max-w-5xl mx-auto text-center">
<h1 className="text-4xl font-bold mb-4">Sign in to Ara</h1>
<p className="text-white/90 text-lg max-w-xl mx-auto">
Enter the 6-digit code we just emailed you to continue.
</p>
</div>
</div>
<div className="relative flex-1 flex items-center justify-center px-6">
<div className="w-full max-w-md">
<Card className="p-10 shadow-xl border border-gray-100 backdrop-blur-sm text-center space-y-6">
<div className="flex justify-center">
<div className="bg-brandblue/10 p-3 rounded-full">
<ShieldCheck className="w-7 h-7 text-brandblue" />
</div>
</div>
<h2 className="text-xl font-semibold text-brandblue">
Enter your sign-in code
</h2>
<p className="text-sm text-gray-600 leading-relaxed">
We sent a 6-digit code to{" "}
<span className="font-medium text-brandblue">
{email ?? "your email"}
</span>
. It expires in 10 minutes.
</p>
<VerifyCodeForm email={email ?? ""} />
</Card>
</div>
</div>
<div className="pb-10 text-center text-xs text-gray-400 space-y-1">
<p>Secure authentication powered by Ara</p>
<p>© {new Date().getFullYear()} Domna Homes</p>
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
import type { ReactNode } from "react";
// Controlled confirmation dialog. Pass `open` + `onOpenChange` to control
// visibility (so the parent can stash any context needed by onConfirm),
// and `onConfirm` is called when the user clicks the destructive action.
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
destructive = false,
isPending = false,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
isPending?: boolean;
onConfirm: () => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
{cancelLabel}
</Button>
<Button
variant={destructive ? "destructive" : "default"}
className={destructive ? "bg-red-700 hover:bg-red-800" : ""}
onClick={onConfirm}
disabled={isPending}
>
{isPending ? "Working…" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,14 +1,142 @@
"use client";
import { useState } from "react";
import { Menu } from "@headlessui/react";
import { signOut } from "next-auth/react";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/app/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
type PendingInvitation = {
invitationId: string;
portfolioId: string;
portfolioName: string;
role: string;
invitedByName: string | null;
createdAt: string;
};
async function fetchPendingInvitations(): Promise<PendingInvitation[]> {
const res = await fetch("/api/user/invitations");
if (!res.ok) throw new Error("Failed to fetch invitations");
const json = await res.json();
const invitations = json?.invitations ?? [];
return Array.isArray(invitations) ? invitations : [];
}
async function respondToInvitation(
invitationId: string,
action: "accept" | "decline",
): Promise<{ portfolioId?: string }> {
const res = await fetch("/api/user/invitations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invitationId, action }),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(msg || `Failed to ${action} invitation`);
}
return res.json().catch(() => ({}));
}
const INVITATIONS_KEY = ["userInvitations"] as const;
function ProfileDropDown({ userImage }: { userImage: string }) {
const { data: session } = useSession();
const email = session?.user?.email ?? null;
const isAuthenticated = !!session?.user;
const queryClient = useQueryClient();
const { toast } = useToast();
const router = useRouter();
const [acceptedInfo, setAcceptedInfo] = useState<{
portfolioId: string;
portfolioName: string;
} | null>(null);
const { data: invitations = [], isLoading } = useQuery({
queryKey: INVITATIONS_KEY,
queryFn: fetchPendingInvitations,
enabled: isAuthenticated,
refetchOnWindowFocus: false,
});
const pendingCount = invitations.length;
const respondMutation = useMutation({
mutationFn: ({
invitationId,
action,
}: {
invitationId: string;
action: "accept" | "decline";
}) => respondToInvitation(invitationId, action),
onMutate: async ({ invitationId }) => {
await queryClient.cancelQueries({ queryKey: INVITATIONS_KEY });
const previous =
queryClient.getQueryData<PendingInvitation[]>(INVITATIONS_KEY);
queryClient.setQueryData<PendingInvitation[]>(INVITATIONS_KEY, (old) =>
(old ?? []).filter((i) => i.invitationId !== invitationId),
);
return { previous };
},
onError: (err, vars, context) => {
if (context?.previous) {
queryClient.setQueryData(INVITATIONS_KEY, context.previous);
}
toast({
title: `Couldn't ${vars.action} invitation`,
description: err instanceof Error ? err.message : "Please try again.",
variant: "destructive",
});
},
onSuccess: (data, vars) => {
const inv = invitations.find((i) => i.invitationId === vars.invitationId);
if (vars.action === "accept") {
const portfolioId = data?.portfolioId ?? inv?.portfolioId;
const portfolioName = inv?.portfolioName ?? "the portfolio";
// /home's server-rendered portfolio list won't pick up the new
// membership without an explicit refresh; the API handler also calls
// revalidatePath("/home") so any later navigation is fresh too.
router.refresh();
if (portfolioId) {
setAcceptedInfo({ portfolioId, portfolioName });
} else {
toast({
title: "Joined portfolio",
description: `You now have access to ${portfolioName}.`,
});
}
} else {
const portfolioLabel = inv ? `the ${inv.portfolioName} portfolio` : "the portfolio";
toast({
title: "Invitation declined",
description: `You've declined the invitation to ${portfolioLabel}.`,
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: INVITATIONS_KEY });
},
});
return (
<>
<Menu as="div" className="relative">
<Menu.Button className="rounded-full">
<Menu.Button className="rounded-full relative">
{userImage ? (
<Image
src={userImage}
@ -28,25 +156,157 @@ function ProfileDropDown({ userImage }: { userImage: string }) {
</svg>
</span>
)}
{pendingCount > 0 && (
<span
className="absolute -top-0.5 -right-0.5 min-w-[1.25rem] h-5 px-1 inline-flex items-center justify-center rounded-full bg-red-600 text-white text-[10px] font-bold ring-2 ring-brandblue"
aria-label={`${pendingCount} pending invitation${pendingCount === 1 ? "" : "s"}`}
>
{pendingCount > 9 ? "9+" : pendingCount}
</span>
)}
</Menu.Button>
<Menu.Items className="z-[100] absolute right-0 mt-2 w-48 origin-top-right overflow-hidden rounded-md border bg-white shadow-lg focus:outline-none">
<Menu.Items className="z-[100] absolute right-0 mt-2 w-80 origin-top-right overflow-hidden rounded-md border bg-white shadow-lg focus:outline-none">
{/* Signed-in identity */}
{email && (
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-[10px] uppercase tracking-wider text-gray-400 font-medium">
Signed in as
</p>
<p className="text-sm text-gray-800 truncate" title={email}>
{email}
</p>
</div>
)}
{/* Pending invitations */}
{isAuthenticated && (
<div className="border-b border-gray-100">
<p className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-wider text-gray-400 font-medium">
Pending invitations
</p>
{isLoading ? (
<p className="px-4 py-2 text-sm text-gray-500">Loading</p>
) : invitations.length === 0 ? (
<p className="px-4 py-2 text-sm text-gray-400">
No pending invitations.
</p>
) : (
<ul className="max-h-72 overflow-y-auto">
{invitations.map((inv) => (
<li
key={inv.invitationId}
className="px-4 py-3 border-t border-gray-50 first:border-t-0"
>
<p className="text-sm font-medium text-gray-800">
{inv.portfolioName}
</p>
<p className="text-xs text-gray-500 mb-2">
{inv.invitedByName
? `Invited by ${inv.invitedByName}`
: "Invited"}{" "}
· <span className="capitalize">{inv.role}</span>
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
respondMutation.mutate({
invitationId: inv.invitationId,
action: "accept",
})
}
disabled={
respondMutation.isPending &&
respondMutation.variables?.invitationId ===
inv.invitationId
}
className="flex-1 px-3 py-1.5 rounded-md bg-brandblue text-white text-xs font-medium hover:bg-hoverblue disabled:opacity-50"
>
Accept
</button>
<button
type="button"
onClick={() =>
respondMutation.mutate({
invitationId: inv.invitationId,
action: "decline",
})
}
disabled={
respondMutation.isPending &&
respondMutation.variables?.invitationId ===
inv.invitationId
}
className="flex-1 px-3 py-1.5 rounded-md border border-gray-200 text-gray-600 text-xs font-medium hover:bg-gray-50 disabled:opacity-50"
>
Decline
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
<Menu.Item>
<Link href="/help" className="flex px-4 py-2 text-sm text-gray-700">
Help
</Link>
{({ active }) => (
<Link
href="/help"
className={`flex px-4 py-2 text-sm text-gray-700 ${active ? "bg-gray-50" : ""}`}
>
Help
</Link>
)}
</Menu.Item>
<Menu.Item>
<a>
{({ active }) => (
<button
className="flex px-4 py-2 text-sm text-gray-700"
type="button"
onClick={() => signOut()}
className={`w-full text-left flex px-4 py-2 text-sm text-gray-700 ${active ? "bg-gray-50" : ""}`}
>
Sign Out
Sign out
</button>
</a>
)}
</Menu.Item>
</Menu.Items>
</Menu>
<Dialog
open={!!acceptedInfo}
onOpenChange={(open) => {
if (!open) setAcceptedInfo(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
You&apos;ve joined {acceptedInfo?.portfolioName}
</DialogTitle>
<DialogDescription>
You now have access. Head over when you&apos;re ready.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setAcceptedInfo(null)}>
Close
</Button>
<Button
onClick={() => {
if (acceptedInfo) {
router.push(`/portfolio/${acceptedInfo.portfolioId}`);
}
setAcceptedInfo(null);
}}
>
Go to {acceptedInfo?.portfolioName ?? "portfolio"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -2,6 +2,7 @@
import { signIn } from "next-auth/react";
import { useState, useEffect, SetStateAction } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@/app/shadcn_components/ui/input";
import { Button } from "@/app/shadcn_components/ui/button";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
@ -11,28 +12,24 @@ export default function EmailSignInButton({
}: {
error: string | undefined;
}) {
const router = useRouter();
const [email, setEmail] = useState("");
const [error, setError] = useState(initialError);
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [status, setStatus] = useState<"idle" | "sending">("idle");
const handleSubmit = async (e: { preventDefault: () => void }) => {
e.preventDefault();
setStatus("sending");
console.log("BEFOERE SIGN IN");
console.log("window.location.origin:", window.location.origin);
const res = await signIn("email", { email, redirect: false });
console.log("AFTER SIGN IN");
if (res?.error) {
setError("You are not a valid user.");
setStatus("idle");
console.log("Error signing in:", res.error);
} else {
console.log("Sign-in link sent to:", email);
setError(undefined);
setStatus("sent");
return;
}
router.push(`/auth/verify-code?email=${encodeURIComponent(email)}`);
};
const handleEmailChange = (e: {
@ -67,13 +64,8 @@ export default function EmailSignInButton({
<div className="min-h-[3rem] text-center">
{error && <p className="text-red-500">{error}</p>}
{status === "sent" && (
<p className="text-green-600">
A login link has been sent to your email.
</p>
)}
{status === "sending" && (
<p className="text-gray-500">Sending login link...</p>
<p className="text-gray-500">Sending sign-in code...</p>
)}
</div>
</form>

View file

@ -0,0 +1,2 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "batch_description" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "nonfunded_measures" text;

View file

@ -0,0 +1,10 @@
CREATE TABLE "authRateLimits" (
"scope" text NOT NULL,
"key" text NOT NULL,
"count" integer DEFAULT 0 NOT NULL,
"window_start" timestamp NOT NULL,
CONSTRAINT "authRateLimits_scope_key_pk" PRIMARY KEY("scope","key")
);
--> statement-breakpoint
ALTER TABLE "verificationToken" ADD COLUMN "code_hash" text;--> statement-breakpoint
ALTER TABLE "verificationToken" ADD COLUMN "attempts" integer DEFAULT 0 NOT NULL;

View file

@ -0,0 +1,10 @@
CREATE TABLE "hubspot_project_data" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" text NOT NULL,
"name" text,
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hubspot_project_data_project_id_unique" UNIQUE("project_id")
);
--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "project_id" text;

View file

@ -0,0 +1,12 @@
CREATE TABLE "portfolioInvitations" (
"id" bigserial PRIMARY KEY NOT NULL,
"portfolio_id" bigint NOT NULL,
"email" text NOT NULL,
"role" "role" NOT NULL,
"invited_by_user_id" bigint NOT NULL,
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "portfolio_invitations_portfolio_email_unique" UNIQUE("portfolio_id","email")
);
--> statement-breakpoint
ALTER TABLE "portfolioInvitations" ADD CONSTRAINT "portfolioInvitations_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolioInvitations" ADD CONSTRAINT "portfolioInvitations_invited_by_user_id_user_id_fk" FOREIGN KEY ("invited_by_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,3 @@
ALTER TABLE "hubspot_project_data" RENAME TO "hubspot_projects_data";--> statement-breakpoint
ALTER TABLE "hubspot_projects_data" DROP CONSTRAINT "hubspot_project_data_project_id_unique";--> statement-breakpoint
ALTER TABLE "hubspot_projects_data" ADD CONSTRAINT "hubspot_projects_data_project_id_unique" UNIQUE("project_id");

View file

@ -0,0 +1 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "booking_status" text;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1464,6 +1464,48 @@
"when": 1779811629325,
"tag": "0208_noisy_arclight",
"breakpoints": true
},
{
"idx": 209,
"version": "7",
"when": 1779877591391,
"tag": "0209_third_klaw",
"breakpoints": true
},
{
"idx": 210,
"version": "7",
"when": 1779889030729,
"tag": "0210_absent_dark_phoenix",
"breakpoints": true
},
{
"idx": 211,
"version": "7",
"when": 1779898075572,
"tag": "0211_lovely_sue_storm",
"breakpoints": true
},
{
"idx": 212,
"version": "7",
"when": 1779900843875,
"tag": "0212_sweet_the_anarchist",
"breakpoints": true
},
{
"idx": 213,
"version": "7",
"when": 1779909562600,
"tag": "0213_tired_victor_mancha",
"breakpoints": true
},
{
"idx": 214,
"version": "7",
"when": 1779969672088,
"tag": "0214_superb_maelstrom",
"breakpoints": true
}
]
}

View file

@ -8,6 +8,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
dealname: text("dealname"),
dealstage: text("dealstage"),
companyId: text("company_id"),
projectId: text("project_id"),
projectCode: text("project_code"),
landlordPropertyId: text("landlord_property_id"),
@ -22,6 +23,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
coordinationStatus: text("coordination_status"),
designStatus: text("design_status"),
bookingStatus: text("booking_status"),
pashubLink: text("pashub_link"),
sharepointLink: text("sharepoint_link"),
@ -47,7 +49,9 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
surveyor: text("surveyor"),
damnpMouldAndRepairComments: text("damp_mould_and_repairs_comments"),
batch: text("batch"),
batchDescription: text("batch_description"),
blockReference: text("block_reference"),
nonfundedMeasures: text("nonfunded_measures"),
epcPrn: text("epc_prn"),
potentialPostSapScoreDropdown: text("potential_post_sap_score_dropdown"),
eiScore: text("ei_score"),

View file

@ -0,0 +1,21 @@
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
export const hubspotProjectsData = pgTable("hubspot_projects_data", {
id: uuid("id").defaultRandom().primaryKey(),
projectId: text("project_id").notNull().unique(),
name: text("name"),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export type HubspotProjectsData = InferModel<typeof hubspotProjectsData, "select">;
export type NewHubspotProjectsData = InferModel<typeof hubspotProjectsData, "insert">;

View file

@ -125,6 +125,32 @@ export const portfolioUsers = pgTable("portfolioUsers", {
.notNull(),
});
// Pending invitations to portfolios for emails that don't yet correspond to a
// user. Once the invitee signs in, the signIn callback consumes the row and
// creates a portfolioUsers entry atomically. Existing users skip this table and
// get a portfolioUsers row written at invite time.
export const portfolioInvitations = pgTable(
"portfolioInvitations",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: roleEnum("role").notNull(),
invitedByUserId: bigint("invited_by_user_id", { mode: "bigint" })
.notNull()
.references(() => user.id),
createdAt: timestamp("created_at", {
precision: 6,
withTimezone: true,
})
.defaultNow()
.notNull(),
},
(t) => [unique("portfolio_invitations_portfolio_email_unique").on(t.portfolioId, t.email)],
);
export const PortfolioCapability: [string, ...string[]] = [
"approver",
"contractor",
@ -161,6 +187,14 @@ export type Portfolio = InferModel<typeof portfolio, "select">;
export type NewPortfolio = InferModel<typeof portfolio, "insert">;
export type PortfolioUsers = InferModel<typeof portfolioUsers, "select">;
export type NewPortfolioUsers = InferModel<typeof portfolioUsers, "insert">;
export type PortfolioInvitation = InferModel<
typeof portfolioInvitations,
"select"
>;
export type NewPortfolioInvitation = InferModel<
typeof portfolioInvitations,
"insert"
>;
export type PortfolioCapabilities = InferModel<
typeof portfolioCapabilities,
"select"

View file

@ -75,10 +75,23 @@ export const verificationTokens = pgTable(
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
codeHash: text("code_hash"),
attempts: integer("attempts").notNull().default(0),
},
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
);
export const authRateLimits = pgTable(
"authRateLimits",
{
scope: text("scope").notNull(),
key: text("key").notNull(),
count: integer("count").notNull().default(0),
windowStart: timestamp("window_start", { mode: "date" }).notNull(),
},
(rl) => [primaryKey({ columns: [rl.scope, rl.key] })]
);
export const UserType: [string, ...string[]] = [
"private_landlord",
"private_tenant",

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { buildMailHeaders } from "./buildMailHeaders";
describe("buildMailHeaders", () => {
it("includes X-SES-CONFIGURATION-SET when a configuration set is provided", () => {
const headers = buildMailHeaders({
fromAddress: "test-sender@example.test",
sesConfigurationSet: "test-config-set",
});
expect(headers["X-SES-CONFIGURATION-SET"]).toBe("test-config-set");
});
it("omits X-SES-CONFIGURATION-SET entirely when no configuration set is provided", () => {
const headers = buildMailHeaders({
fromAddress: "test-sender@example.test",
sesConfigurationSet: undefined,
});
expect(headers).not.toHaveProperty("X-SES-CONFIGURATION-SET");
});
it("always includes a List-Unsubscribe mailto pointing at the from address", () => {
const headers = buildMailHeaders({
fromAddress: "test-sender@example.test",
sesConfigurationSet: undefined,
});
expect(headers["List-Unsubscribe"]).toBe("<mailto:test-sender@example.test>");
});
});

View file

@ -0,0 +1,12 @@
export function buildMailHeaders(opts: {
fromAddress: string;
sesConfigurationSet: string | undefined;
}): Record<string, string> {
const headers: Record<string, string> = {
"List-Unsubscribe": `<mailto:${opts.fromAddress}>`,
};
if (opts.sesConfigurationSet) {
headers["X-SES-CONFIGURATION-SET"] = opts.sesConfigurationSet;
}
return headers;
}

View file

@ -1,92 +1,90 @@
// Contains the email template for user sign in via magic links. A user will be asked to
// click a verification email to sign in to the app, should they choose to sign in with magic
// links
// Email template for user sign-in. Body contains a 6-digit code only — the
// magic-link path is still wired (see /verify/[token] and the EmailProvider
// callback) but the URL is intentionally omitted from the email to reduce
// the content-scanner surface area for corporate email gateways.
import { createTransport } from "nodemailer";
import { buildMailHeaders } from "./buildMailHeaders";
export async function MagicLinksEmail({
identifier,
url,
provider,
code,
}: {
identifier: string;
url: string;
provider: { server: any; from: string };
}) {
code: string;
}): Promise<{ messageId: string }> {
const parsed = new URL(url);
const host = parsed.host;
const baseUrl = parsed.origin;
const logoUrl = `${baseUrl}/domna-email-logo.png`;
const token = parsed.searchParams.get("token");
const email = parsed.searchParams.get("email");
if (!token || !email) {
throw new Error("Magic link token or email missing");
}
// Create a clean login link instead of the NextAuth callback
const loginUrl = `${parsed.origin}/verify/${token}`;
const logoUrl = `${parsed.origin}/domna-email-logo.png`;
const transport = createTransport(provider.server);
const brandColor = "#14163d"; // brand blue
const accentColor = "#2d348f"; // deep blue
const brown = "#c4a47c"; // brand brown
const brandColor = "#14163d";
const accentColor = "#2d348f";
const background = "#F9F9F9";
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: "Sign in to Ara",
text: plainText({ url: loginUrl, host }),
text: plainText({ code, host }),
html: domnaHtml({
url: loginUrl,
code,
logoUrl,
host,
brandColor,
accentColor,
brown,
background,
}),
headers: {
"List-Unsubscribe": `<mailto:${provider.from}>`,
},
headers: buildMailHeaders({
fromAddress: provider.from,
sesConfigurationSet: process.env.SES_CONFIGURATION_SET,
}),
});
const failed = result.rejected.filter(Boolean);
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
}
return { messageId: result.messageId };
}
function formatCodeForDisplay(code: string): string {
// Insert a non-breaking space for readability without breaking copy-paste
// semantics in mail clients that strip the visual grouping.
return `${code.slice(0, 3)} ${code.slice(3)}`;
}
function domnaHtml({
url,
code,
logoUrl,
host,
brandColor,
accentColor,
brown,
background,
}: {
url: string;
code: string;
logoUrl: string;
host: string;
brandColor: string;
accentColor: string;
brown: string;
background: string;
}) {
const escapedHost = host.replace(/\./g, "&#8203;.");
const codeDisplay = formatCodeForDisplay(code);
return `
<body style="background: ${background}; font-family: Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0"
<table width="100%" border="0" cellspacing="0" cellpadding="0"
style="max-width: 600px; margin: 40px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">
<tr>
<td align="center" style="background: linear-gradient(90deg, ${brandColor}, ${accentColor}); padding: 12px 8px;">
<img
<img
src="${logoUrl}"
alt="Domna Logo"
width="120"
@ -96,16 +94,18 @@ function domnaHtml({
</td>
</tr>
<tr>
<td align="center" style="padding: 10px 10px 10px; color: #333;">
<h2 style="color: ${brandColor}; font-size: 22px; margin-bottom: 16px;">Welcome back to Ara by Domna</h2>
<p style="font-size: 16px; line-height: 1.6; color: #444; margin-bottom: 32px;">
Click below to securely sign in to your account and continue your retrofit journey.
<td align="center" style="padding: 28px 24px 28px; color: #333;">
<h2 style="color: ${brandColor}; font-size: 22px; margin: 0 0 8px;">Your sign-in code</h2>
<p style="font-size: 14px; line-height: 1.5; color: #666; margin: 0 0 20px;">
Enter this code at <span style="color: ${accentColor};">${escapedHost}</span> to sign in to Ara.
</p>
<a href="${url}" target="_blank"
style="display: inline-block; padding: 14px 28px; background: ${brown}; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">
Sign in to Ara
</a>
<p style="margin-top: 36px; font-size: 13px; color: #777;">
<div style="font-family: 'Courier New', Courier, monospace; font-size: 36px; font-weight: 700; letter-spacing: 4px; color: ${brandColor}; padding: 18px 24px; background: #f3f4f8; border-radius: 8px; display: inline-block;">
${codeDisplay}
</div>
<p style="font-size: 12px; color: #888; margin: 12px 0 0;">
This code expires in 10 minutes.
</p>
<p style="margin-top: 28px; font-size: 13px; color: #777;">
If you didnt request this email, you can safely ignore it.
</p>
</td>
@ -120,6 +120,13 @@ function domnaHtml({
`;
}
function plainText({ url, host }: { url: string; host: string }) {
return `Sign in to Ara by Domna\n${url}\n\nIf you did not request this email, you can safely ignore it.\n`;
function plainText({ code, host }: { code: string; host: string }) {
return `Sign in to Ara by Domna
Your sign-in code: ${code}
Enter this code at ${host}/auth/verify-code to sign in.
This code expires in 10 minutes. If you did not request this email, you can safely ignore it.
`;
}

View file

@ -0,0 +1,167 @@
// Notification email sent when a user is invited to a portfolio.
//
// Two modes:
// "existing-user" — recipient already has an Ara account; the membership was
// written directly, the email is just an FYI with a link to the portfolio.
// "new-user" — recipient has no account; a pending portfolio_invitations row
// was written. They need to sign in (which creates the user and triggers the
// signIn callback that applies the invitation).
import { createTransport } from "nodemailer";
import { buildMailHeaders } from "./buildMailHeaders";
export type InvitationMode = "existing-user" | "new-user";
export async function PortfolioInvitationEmail({
identifier,
portfolioName,
inviterName,
linkUrl,
mode,
}: {
identifier: string;
portfolioName: string;
inviterName: string;
linkUrl: string;
mode: InvitationMode;
}): Promise<{ messageId: string }> {
const from = process.env.EMAIL_FROM!;
const transport = createTransport({
host: process.env.EMAIL_SERVER_HOST,
port: Number(process.env.EMAIL_SERVER_PORT),
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
});
const parsed = new URL(linkUrl);
const host = parsed.host;
const logoUrl = `${parsed.origin}/domna-email-logo.png`;
const subject = `${inviterName} invited you to join ${portfolioName} on Ara`;
const ctaLabel = "Sign in to Ara";
const result = await transport.sendMail({
to: identifier,
from,
subject,
text: plainText({
portfolioName,
inviterName,
linkUrl,
ctaLabel,
mode,
}),
html: domnaHtml({
portfolioName,
inviterName,
linkUrl,
ctaLabel,
mode,
logoUrl,
host,
}),
headers: buildMailHeaders({
fromAddress: from,
sesConfigurationSet: process.env.SES_CONFIGURATION_SET,
}),
});
const failed = result.rejected.filter(Boolean);
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
}
return { messageId: result.messageId };
}
function domnaHtml({
portfolioName,
inviterName,
linkUrl,
ctaLabel,
mode,
logoUrl,
host,
}: {
portfolioName: string;
inviterName: string;
linkUrl: string;
ctaLabel: string;
mode: InvitationMode;
logoUrl: string;
host: string;
}) {
const escapedHost = host.replace(/\./g, "&#8203;.");
const brandColor = "#14163d";
const accentColor = "#2d348f";
const brown = "#c4a47c";
const background = "#F9F9F9";
const heading = `${inviterName} invited you to ${portfolioName}`;
const explainer =
mode === "existing-user"
? `${inviterName} invited you to the <strong>${portfolioName}</strong> portfolio on Ara. Sign in and accept the invitation from your profile menu to start collaborating.`
: `${inviterName} invited you to join the <strong>${portfolioName}</strong> portfolio on Ara. Sign in with this email address to create your account, then accept the invitation from your profile menu.`;
return `
<body style="background: ${background}; font-family: Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0"
style="max-width: 600px; margin: 40px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">
<tr>
<td align="center" style="background: linear-gradient(90deg, ${brandColor}, ${accentColor}); padding: 12px 8px;">
<img src="${logoUrl}" alt="Domna Logo" width="120" height="auto" style="margin-bottom: 4px;" />
</td>
</tr>
<tr>
<td align="center" style="padding: 28px 24px 12px; color: #333;">
<h2 style="color: ${brandColor}; font-size: 22px; margin: 0 0 12px;">${heading}</h2>
<p style="font-size: 15px; line-height: 1.5; color: #555; margin: 0 0 24px;">${explainer}</p>
<a href="${linkUrl}" target="_blank"
style="display: inline-block; padding: 12px 24px; background: ${brown}; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">
${ctaLabel}
</a>
<p style="margin-top: 28px; font-size: 13px; color: #777;">
If you weren't expecting this email, you can safely ignore it.
</p>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px; font-size: 12px; color: #999; border-top: 1px solid #eee;">
&copy; ${new Date().getFullYear()} Domna Homes <span style="color: ${accentColor};">${escapedHost}</span>
</td>
</tr>
</table>
</body>
`;
}
function plainText({
portfolioName,
inviterName,
linkUrl,
ctaLabel,
mode,
}: {
portfolioName: string;
inviterName: string;
linkUrl: string;
ctaLabel: string;
mode: InvitationMode;
}) {
const heading = `${inviterName} invited you to ${portfolioName}`;
const explainer =
mode === "existing-user"
? `${inviterName} invited you to the ${portfolioName} portfolio on Ara. Sign in and accept from your profile menu to start collaborating.`
: `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email to create your account, then accept from your profile menu.`;
return `${heading}
${explainer}
${ctaLabel}: ${linkUrl}
If you weren't expecting this email, you can safely ignore it.
`;
}

15
src/app/lib/email.test.ts Normal file
View file

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { normaliseEmail } from "./email";
describe("normaliseEmail", () => {
it("lowercases mixed-case addresses", () => {
expect(normaliseEmail("Craig.Williams@Example.com")).toBe(
"craig.williams@example.com",
);
});
it("trims surrounding whitespace (common from copy-paste into invite forms)", () => {
expect(normaliseEmail(" user@example.com ")).toBe("user@example.com");
expect(normaliseEmail("\tuser@example.com\n")).toBe("user@example.com");
});
});

3
src/app/lib/email.ts Normal file
View file

@ -0,0 +1,3 @@
export function normaliseEmail(email: string): string {
return email.trim().toLowerCase();
}

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { canAdminister, isDomnaEmail } from "./portfolioAdmin";
describe("isDomnaEmail", () => {
it("identifies @domna.homes addresses as internal", () => {
expect(isDomnaEmail("khalim@domna.homes")).toBe(true);
});
it("is case-insensitive on the domain", () => {
expect(isDomnaEmail("Khalim@Domna.Homes")).toBe(true);
});
it("rejects look-alike domains and prefixes", () => {
expect(isDomnaEmail("user@example.com")).toBe(false);
expect(isDomnaEmail("user@domna.homes.attacker.com")).toBe(false);
expect(isDomnaEmail("user@notdomna.homes")).toBe(false);
});
});
describe("canAdminister", () => {
it("grants admin powers to portfolio creator", () => {
expect(canAdminister("creator")).toBe(true);
});
it("grants admin powers to portfolio admins and Domna employees", () => {
expect(canAdminister("admin")).toBe(true);
expect(canAdminister("domna")).toBe(true);
});
it("denies admin powers to read/write members and non-members", () => {
expect(canAdminister("write")).toBe(false);
expect(canAdminister("read")).toBe(false);
expect(canAdminister("none")).toBe(false);
});
});

View file

@ -0,0 +1,19 @@
export type PortfolioPrivilege =
| "creator"
| "admin"
| "domna"
| "write"
| "read"
| "none";
export function isDomnaEmail(email: string): boolean {
return email.toLowerCase().endsWith("@domna.homes");
}
export function canAdminister(privilege: PortfolioPrivilege): boolean {
return (
privilege === "creator" ||
privilege === "admin" ||
privilege === "domna"
);
}

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { planInvitationApplication } from "./portfolioInvitations";
describe("planInvitationApplication", () => {
it("translates a single pending invitation into one membership insert + invitation delete", () => {
const plan = planInvitationApplication({
userId: 100n,
invitations: [
{ id: 1n, portfolioId: 200n, role: "read" },
],
existingPortfolioIds: new Set<bigint>(),
});
expect(plan.memberships).toEqual([
{ portfolioId: 200n, userId: 100n, role: "read" },
]);
expect(plan.invitationsToDelete).toEqual([1n]);
});
it("skips the membership insert if the user is already a member of that portfolio, but still deletes the stale invitation", () => {
const plan = planInvitationApplication({
userId: 100n,
invitations: [
{ id: 1n, portfolioId: 200n, role: "read" },
],
existingPortfolioIds: new Set<bigint>([200n]),
});
expect(plan.memberships).toEqual([]);
expect(plan.invitationsToDelete).toEqual([1n]);
});
it("handles invitations to several portfolios in one application", () => {
const plan = planInvitationApplication({
userId: 100n,
invitations: [
{ id: 1n, portfolioId: 200n, role: "read" },
{ id: 2n, portfolioId: 300n, role: "write" },
{ id: 3n, portfolioId: 400n, role: "admin" },
],
existingPortfolioIds: new Set<bigint>([300n]),
});
expect(plan.memberships).toEqual([
{ portfolioId: 200n, userId: 100n, role: "read" },
{ portfolioId: 400n, userId: 100n, role: "admin" },
]);
expect(plan.invitationsToDelete).toEqual([1n, 2n, 3n]);
});
});

View file

@ -0,0 +1,42 @@
export type InvitationRecord = {
id: bigint;
portfolioId: bigint;
role: "creator" | "admin" | "read" | "write";
};
export type MembershipPayload = {
portfolioId: bigint;
userId: bigint;
role: "creator" | "admin" | "read" | "write";
};
export type InvitationApplicationPlan = {
memberships: MembershipPayload[];
invitationsToDelete: bigint[];
};
export function planInvitationApplication({
userId,
invitations,
existingPortfolioIds,
}: {
userId: bigint;
invitations: InvitationRecord[];
existingPortfolioIds: Set<bigint>;
}): InvitationApplicationPlan {
const memberships: MembershipPayload[] = [];
const invitationsToDelete: bigint[] = [];
for (const inv of invitations) {
invitationsToDelete.push(inv.id);
if (!existingPortfolioIds.has(inv.portfolioId)) {
memberships.push({
portfolioId: inv.portfolioId,
userId,
role: inv.role,
});
}
}
return { memberships, invitationsToDelete };
}

View file

@ -0,0 +1,65 @@
import { db } from "@/app/db/db";
import { portfolioUsers } from "@/app/db/schema/portfolio";
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import type { Session } from "next-auth";
import {
canAdminister,
isDomnaEmail,
type PortfolioPrivilege,
} from "./portfolioAdmin";
// Resolves the effective privilege a session has on a given portfolio.
// Highest-wins: an explicit "creator" or "admin" membership ranks above the
// implicit "domna" employee privilege; otherwise Domna employees get admin
// powers without being a member; otherwise the membership role is returned.
export async function resolvePortfolioPrivilege({
portfolioId,
userId,
userEmail,
}: {
portfolioId: bigint;
userId: bigint;
userEmail: string;
}): Promise<PortfolioPrivilege> {
const [membership] = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, portfolioId),
eq(portfolioUsers.userId, userId),
),
)
.limit(1);
if (membership?.role === "creator") return "creator";
if (membership?.role === "admin") return "admin";
if (isDomnaEmail(userEmail)) return "domna";
if (membership?.role === "write") return "write";
if (membership?.role === "read") return "read";
return "none";
}
// Convenience: returns an HTTP response if the session can't administer the
// portfolio, otherwise null. Use at the top of mutating route handlers:
//
// const denied = await denyIfNotAdmin(portfolioId, session);
// if (denied) return denied;
export async function denyIfNotAdmin(
portfolioId: bigint,
session: Session | null,
): Promise<NextResponse | null> {
if (!session?.user?.dbId || !session.user.email) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const privilege = await resolvePortfolioPrivilege({
portfolioId,
userId: BigInt(session.user.dbId),
userEmail: session.user.email,
});
if (!canAdminister(privilege)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
evaluateCodeAttempt,
generateCode,
hashCode,
} from "./verificationCode";
const FUTURE = new Date("2030-01-01T00:00:00Z");
const NOW = new Date("2026-05-27T12:00:00Z");
const CORRECT_HASH = "correct-hash";
const WRONG_HASH = "wrong-hash";
describe("generateCode", () => {
it("returns a 6-digit numeric string", () => {
const code = generateCode();
expect(code).toMatch(/^\d{6}$/);
});
});
describe("hashCode", () => {
it("produces the same hash for the same code+secret", () => {
const a = hashCode("482911", "secret-x");
const b = hashCode("482911", "secret-x");
expect(a).toBe(b);
});
it("produces different hashes when the secret differs", () => {
const a = hashCode("482911", "secret-x");
const b = hashCode("482911", "secret-y");
expect(a).not.toBe(b);
});
});
describe("evaluateCodeAttempt", () => {
it("rejects a wrong code and increments attempts", () => {
const result = evaluateCodeAttempt({
submittedCodeHash: WRONG_HASH,
row: { codeHash: CORRECT_HASH, attempts: 0, expires: FUTURE },
now: NOW,
});
expect(result).toEqual({ outcome: "wrong", newAttempts: 1 });
});
it("accepts a correct code", () => {
const result = evaluateCodeAttempt({
submittedCodeHash: CORRECT_HASH,
row: { codeHash: CORRECT_HASH, attempts: 0, expires: FUTURE },
now: NOW,
});
expect(result).toEqual({ outcome: "ok" });
});
it("locks out on the 5th consecutive wrong attempt", () => {
const result = evaluateCodeAttempt({
submittedCodeHash: WRONG_HASH,
row: { codeHash: CORRECT_HASH, attempts: 4, expires: FUTURE },
now: NOW,
});
expect(result).toEqual({ outcome: "locked-out" });
});
it("still rejects a correct code submitted after lock-out", () => {
const result = evaluateCodeAttempt({
submittedCodeHash: CORRECT_HASH,
row: { codeHash: CORRECT_HASH, attempts: 5, expires: FUTURE },
now: NOW,
});
expect(result).toEqual({ outcome: "locked-out" });
});
it("reports no-such-row when there's no row for the email", () => {
const result = evaluateCodeAttempt({
submittedCodeHash: CORRECT_HASH,
row: null,
now: NOW,
});
expect(result).toEqual({ outcome: "no-such-row" });
});
it("reports expired when the row's expiry is in the past", () => {
const expired = new Date(NOW.getTime() - 1000);
const result = evaluateCodeAttempt({
submittedCodeHash: CORRECT_HASH,
row: { codeHash: CORRECT_HASH, attempts: 0, expires: expired },
now: NOW,
});
expect(result).toEqual({ outcome: "expired" });
});
});

View file

@ -0,0 +1,43 @@
import crypto from "crypto";
export function generateCode(): string {
return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export function hashCode(code: string, secret: string): string {
return crypto.createHash("sha256").update(code + secret).digest("hex");
}
export type VerificationRowState = {
codeHash: string | null;
attempts: number;
expires: Date;
};
export type CodeAttemptOutcome =
| { outcome: "ok" }
| { outcome: "wrong"; newAttempts: number }
| { outcome: "locked-out" }
| { outcome: "expired" }
| { outcome: "no-such-row" };
const MAX_ATTEMPTS = 5;
export function evaluateCodeAttempt({
submittedCodeHash,
row,
now,
}: {
submittedCodeHash: string;
row: VerificationRowState | null;
now: Date;
}): CodeAttemptOutcome {
if (!row) return { outcome: "no-such-row" };
if (row.expires.getTime() <= now.getTime()) return { outcome: "expired" };
if (row.attempts >= MAX_ATTEMPTS) return { outcome: "locked-out" };
if (row.codeHash === submittedCodeHash) return { outcome: "ok" };
const newAttempts = row.attempts + 1;
if (newAttempts >= MAX_ATTEMPTS) return { outcome: "locked-out" };
return { outcome: "wrong", newAttempts };
}

View file

@ -11,6 +11,10 @@ import {
import { Button } from "@/app/shadcn_components/ui/button";
import { Badge } from "@/app/shadcn_components/ui/badge";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
COLLABORATORS_QUERY_KEY,
fetchCollaborators,
} from "./collaboratorsClient";
type Capability = "approver" | "contractor";
@ -30,20 +34,6 @@ async function getCapabilities(portfolioId: string): Promise<CapabilityEntry[]>
return res.json();
}
async function getCollaborators(
portfolioId: string,
): Promise<{ userId: string; name: string | null; email: string }[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`);
if (!res.ok) throw new Error("Failed to fetch collaborators");
const json = await res.json();
const users = Array.isArray(json) ? json : json.users ?? [];
return users.map((u: any) => ({
userId: String(u.userId),
name: u.name ?? null,
email: u.email ?? "",
}));
}
async function assignCapability(
portfolioId: string,
userId: string,
@ -81,19 +71,20 @@ export function CapabilitiesCard({ portfolioId }: { portfolioId: string }) {
refetchOnWindowFocus: false,
});
const { data: collaborators = [], isLoading: loadingCollabs } = useQuery({
queryKey: ["portfolioUsers", portfolioId],
queryFn: () => getCollaborators(portfolioId),
const { data: collaboratorsResponse, isLoading: loadingCollabs } = useQuery({
queryKey: COLLABORATORS_QUERY_KEY(portfolioId),
queryFn: () => fetchCollaborators(portfolioId),
enabled: !!portfolioId,
refetchOnWindowFocus: false,
});
const collaborators = collaboratorsResponse?.users ?? [];
const isLoading = loadingCaps || loadingCollabs;
// Build a map: userId -> { capabilities: [] }
const capMap: CapabilityMap = {};
for (const c of collaborators) {
capMap[c.userId] = { name: c.name, email: c.email, capabilities: [] };
capMap[c.userId] = { name: c.name ?? null, email: c.email, capabilities: [] };
}
for (const e of entries) {
if (capMap[e.userId]) {

View file

@ -12,37 +12,57 @@ import { Input } from "@/app/shadcn_components/ui/input";
import { Button } from "@/app/shadcn_components/ui/button";
import { useState } from "react";
import { Role, RoleDropdown, Collaborator } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
import {
Role,
RoleDropdown,
Collaborator,
} from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
canAdminister,
type PortfolioPrivilege,
} from "@/app/lib/portfolioAdmin";
import {
COLLABORATORS_QUERY_KEY,
fetchCollaborators,
type CollaboratorsResponse,
} from "./collaboratorsClient";
import { ConfirmDialog } from "@/app/components/ConfirmDialog";
import { useToast } from "@/app/hooks/use-toast";
type PendingInvitation = {
invitationId: string;
email: string;
role: Role | "creator";
createdAt: string;
};
async function getPortfolioUsers(portfolioId: string): Promise<Collaborator[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, {
async function getPortfolioInvitations(
portfolioId: string,
): Promise<PendingInvitation[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/invitations`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error("Failed to fetch users");
if (!res.ok) throw new Error("Failed to fetch invitations");
const json = await res.json();
const users = Array.isArray(json) ? json : json.users; // support both shapes
// Guard + shape to Collaborator[]
return Array.isArray(users)
? users.map((u: any) => ({
portfolioUserId: String(u.portfolioUserId),
userId: String(u.userId),
name: u.name ?? null,
email: u.email ?? "",
role: u.role,
}))
const invitations = json?.invitations ?? [];
return Array.isArray(invitations)
? invitations.map((i: any) => ({
invitationId: String(i.invitationId),
email: i.email ?? "",
role: i.role,
createdAt: i.createdAt,
}))
: [];
}
async function updatePortfolioUserRole(
portfolioId: string,
portfolioUserId: string,
role: Role
role: Role,
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, {
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ portfolioUserId, role }),
@ -57,12 +77,11 @@ async function invitePortfolioUser(
portfolioId: string,
email: string,
role: Role,
name: string
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, {
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, role, name }),
body: JSON.stringify({ email, role }),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
@ -70,97 +89,259 @@ async function invitePortfolioUser(
}
}
async function removePortfolioUser(
portfolioId: string,
portfolioUserId: string,
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ portfolioUserId }),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(msg || "Failed to remove user");
}
}
async function revokePortfolioInvitation(
portfolioId: string,
invitationId: string,
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/invitations`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invitationId }),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(msg || "Failed to revoke invitation");
}
}
export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<Role>("read");
const [inviteName, setInviteName] = useState("");
const [pendingRemoval, setPendingRemoval] =
useState<{ portfolioUserId: string; email: string } | null>(null);
const [pendingRevoke, setPendingRevoke] =
useState<{ invitationId: string; email: string } | null>(null);
const { toast } = useToast();
const queryClient = useQueryClient();
const usersKey = COLLABORATORS_QUERY_KEY(portfolioId);
const invitationsKey = ["portfolioInvitations", portfolioId];
const {
data: collaborators = [],
data: collaboratorsData,
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ["portfolioUsers", portfolioId],
queryFn: () => getPortfolioUsers(portfolioId),
enabled: !!portfolioId, // only run when id is present
refetchOnWindowFocus: false, // optional: avoid surprise refetch logs
onSuccess: (data) => {
console.log("Fetched users for portfolio:", data);
},
onError: (err) => {
console.error("Error fetching users:", err);
},
queryKey: usersKey,
queryFn: () => fetchCollaborators(portfolioId),
enabled: !!portfolioId,
refetchOnWindowFocus: false,
});
const collaborators = collaboratorsData?.users ?? [];
const currentPrivilege: PortfolioPrivilege =
collaboratorsData?.currentUser?.privilege ?? "none";
const isAdmin = canAdminister(currentPrivilege);
const {
data: invitations = [],
isLoading: invitationsLoading,
isFetching: invitationsFetching,
} = useQuery({
queryKey: invitationsKey,
queryFn: () => getPortfolioInvitations(portfolioId),
// Only admins can see pending invitations — the GET endpoint also enforces
// this; gating here avoids the unauthorised network request.
enabled: !!portfolioId && isAdmin,
refetchOnWindowFocus: false,
});
const invalidateBoth = () => {
queryClient.invalidateQueries({ queryKey: usersKey });
queryClient.invalidateQueries({ queryKey: invitationsKey });
};
const changeRoleMutation = useMutation({
mutationFn: ({ portfolioUserId, role }: { portfolioUserId: string; role: Role }) =>
updatePortfolioUserRole(portfolioId, portfolioUserId, role),
mutationFn: ({
portfolioUserId,
role,
}: {
portfolioUserId: string;
role: Role;
}) => updatePortfolioUserRole(portfolioId, portfolioUserId, role),
// Optimistic update
onMutate: async ({ portfolioUserId, role }) => {
await queryClient.cancelQueries({ queryKey: ["portfolioUsers", portfolioId] });
const previous = queryClient.getQueryData<Collaborator[]>(["portfolioUsers", portfolioId]);
// Optimistically update cache
queryClient.setQueryData<Collaborator[]>(
["portfolioUsers", portfolioId],
(old) =>
(old ?? []).map((c) =>
c.portfolioUserId === portfolioUserId ? { ...c, role } : c
)
await queryClient.cancelQueries({ queryKey: usersKey });
const previous =
queryClient.getQueryData<CollaboratorsResponse>(usersKey);
queryClient.setQueryData<CollaboratorsResponse>(usersKey, (old) =>
old
? {
...old,
users: old.users.map((c) =>
c.portfolioUserId === portfolioUserId ? { ...c, role } : c,
),
}
: old,
);
// Return context to rollback on error
return { previous };
},
// Rollback on error
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(["portfolioUsers", portfolioId], context.previous);
queryClient.setQueryData(usersKey, context.previous);
}
console.error("Failed to update role:", err);
},
// Always revalidate after success/error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] });
queryClient.invalidateQueries({ queryKey: usersKey });
},
});
// ADD: mutation for inviting a user
const inviteUserMutation = useMutation({
mutationFn: ({ email, role, name }: { email: string; role: Role; name: string }) =>
invitePortfolioUser(portfolioId, email, role, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] });
mutationFn: ({
email,
role,
}: {
email: string;
role: Role;
}) => invitePortfolioUser(portfolioId, email, role),
onSuccess: (_data, vars) => {
invalidateBoth();
setInviteEmail("");
setInviteName(""); // clear name after success
// setInviteRole("read");
toast({
title: "Invitation sent",
description: `We've emailed ${vars.email} an invitation to this portfolio.`,
});
},
onError: (err) => {
console.error("Invite failed:", err);
toast({
title: "Couldn't send invitation",
description: err instanceof Error ? err.message : "Please try again.",
variant: "destructive",
});
},
});
const removeUserMutation = useMutation({
mutationFn: (portfolioUserId: string) =>
removePortfolioUser(portfolioId, portfolioUserId),
onMutate: async (portfolioUserId) => {
await queryClient.cancelQueries({ queryKey: usersKey });
const previous =
queryClient.getQueryData<CollaboratorsResponse>(usersKey);
queryClient.setQueryData<CollaboratorsResponse>(usersKey, (old) =>
old
? {
...old,
users: old.users.filter(
(c) => c.portfolioUserId !== portfolioUserId,
),
}
: old,
);
return { previous };
},
onSuccess: (_data, _portfolioUserId) => {
const email = pendingRemoval?.email;
toast({
title: "User removed",
description: email
? `${email} no longer has access to this portfolio.`
: "User no longer has access to this portfolio.",
});
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(usersKey, context.previous);
}
console.error("Failed to remove user:", err);
toast({
title: "Couldn't remove user",
description: err instanceof Error ? err.message : "Please try again.",
variant: "destructive",
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: usersKey });
setPendingRemoval(null);
},
});
const revokeInvitationMutation = useMutation({
mutationFn: (invitationId: string) =>
revokePortfolioInvitation(portfolioId, invitationId),
onMutate: async (invitationId) => {
await queryClient.cancelQueries({ queryKey: invitationsKey });
const previous =
queryClient.getQueryData<PendingInvitation[]>(invitationsKey);
queryClient.setQueryData<PendingInvitation[]>(invitationsKey, (old) =>
(old ?? []).filter((i) => i.invitationId !== invitationId),
);
return { previous };
},
onSuccess: () => {
const email = pendingRevoke?.email;
toast({
title: "Invitation revoked",
description: email
? `${email}'s invitation has been cancelled.`
: "The invitation has been cancelled.",
});
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(invitationsKey, context.previous);
}
console.error("Failed to revoke invitation:", err);
toast({
title: "Couldn't revoke invitation",
description: err instanceof Error ? err.message : "Please try again.",
variant: "destructive",
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: invitationsKey });
setPendingRevoke(null);
},
});
function handleInvite() {
inviteUserMutation.mutate({ email: inviteEmail, role: inviteRole, name: inviteName });
inviteUserMutation.mutate({
email: inviteEmail,
role: inviteRole,
});
}
function onChangeRole(portfolioUserId: string, role: Role) {
console.log(`Change portfolioUserId ${portfolioUserId} to ${role}`);
changeRoleMutation.mutate({ portfolioUserId, role });
}
function onRemove(portfolioUserId: string) {
console.log(`This button will delete the row portoflioUserId ${portfolioUserId}`);
console.log("This was not implemented as Jun-te wanted to avoid Delete via drizzle before Database integrirty")
// TODO: DELETE user -> then refetch()
function onRemove(portfolioUserId: string, email: string) {
setPendingRemoval({ portfolioUserId, email });
}
function onRevokeInvitation(invitationId: string, email: string) {
setPendingRevoke({ invitationId, email });
}
function confirmRemove() {
if (!pendingRemoval) return;
removeUserMutation.mutate(pendingRemoval.portfolioUserId);
}
function confirmRevoke() {
if (!pendingRevoke) return;
revokeInvitationMutation.mutate(pendingRevoke.invitationId);
}
return (
@ -173,53 +354,59 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<p className="text-xs text-gray-500">Add users and manage roles</p>
</TableHead>
<TableCell className="text-right">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching || isLoading}>
<Button
variant="outline"
onClick={() => refetch()}
disabled={isFetching || isLoading}
>
{isFetching || isLoading ? "Loading..." : "Refresh Users"}
</Button>
</TableCell>
</TableRow>
{/* Invite row */}
<TableRow>
<TableHead className="text-brandblue">
Add a user
<p className="text-xs text-gray-500">
Invite by email and choose a role
</p>
</TableHead>
<TableCell className="flex gap-2 items-center">
<Input
type="text"
placeholder="Full name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<Input
type="email"
placeholder="email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<div className="min-w-40">
<RoleDropdown value={inviteRole} onChange={setInviteRole} />
</div>
</TableCell>
<TableCell className="text-right">
<Button
className="w-28"
onClick={handleInvite}
disabled={!inviteEmail || !inviteName || inviteUserMutation.isPending}
>
{inviteUserMutation.isPending ? "Inviting..." : "Invite"}
</Button>
</TableCell>
</TableRow>
{/* Invite row — admin-only */}
{isAdmin && (
<TableRow>
<TableHead className="text-brandblue">
Add a user
<p className="text-xs text-gray-500">
Invite by email and choose a role
</p>
</TableHead>
<TableCell className="flex gap-2 items-center">
<Input
type="email"
placeholder="email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<div className="min-w-40">
<RoleDropdown
value={inviteRole}
onChange={setInviteRole}
allowAdminPromotion
/>
</div>
</TableCell>
<TableCell className="text-right">
<Button
className="w-28"
onClick={handleInvite}
disabled={!inviteEmail || inviteUserMutation.isPending}
>
{inviteUserMutation.isPending ? "Inviting..." : "Invite"}
</Button>
</TableCell>
</TableRow>
)}
{/* Current collaborators list */}
<TableRow>
<TableHead className="text-brandblue">
Current users
<p className="text-xs text-gray-500">Update roles or remove access</p>
<p className="text-xs text-gray-500">
Update roles or remove access
</p>
</TableHead>
<TableCell colSpan={2}>
<div className="rounded-md border border-gray-200">
@ -235,7 +422,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">Loading</TableCell>
<TableCell colSpan={4} className="text-sm text-gray-500">
Loading
</TableCell>
</TableRow>
) : collaborators.length === 0 ? (
<TableRow>
@ -249,18 +438,35 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<TableCell>{c.name || "—"}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell className="min-w-40">
{c.role === "creator" || c.role === "admin" ? (
{c.role === "creator" || !isAdmin ? (
<span className="text-xs font-medium text-gray-500 px-2 py-1 bg-gray-100 rounded-md capitalize">
{c.role}
</span>
) : (
<RoleDropdown value={c.role as "read" | "write"} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
<RoleDropdown
value={c.role as Role}
onChange={(r) =>
onChangeRole(c.portfolioUserId, r)
}
allowAdminPromotion
/>
)}
</TableCell>
<TableCell className="text-right">
{c.role !== "creator" && (
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
Remove
{c.role !== "creator" && isAdmin && (
<Button
variant="destructive"
className="bg-red-700"
onClick={() =>
onRemove(c.portfolioUserId, c.email)
}
disabled={removeUserMutation.isPending}
>
{removeUserMutation.isPending &&
removeUserMutation.variables ===
c.portfolioUserId
? "Removing..."
: "Remove"}
</Button>
)}
</TableCell>
@ -272,8 +478,112 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
</div>
</TableCell>
</TableRow>
{/* Pending invitations list — admin-only */}
{isAdmin && (
<TableRow>
<TableHead className="text-brandblue">
Pending invitations
<p className="text-xs text-gray-500">
Emails invited but not yet signed in
</p>
</TableHead>
<TableCell colSpan={2}>
<div className="rounded-md border border-gray-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Invited</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitationsLoading || invitationsFetching ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">
Loading
</TableCell>
</TableRow>
) : invitations.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">
No pending invitations.
</TableCell>
</TableRow>
) : (
invitations.map((i) => (
<TableRow key={i.invitationId}>
<TableCell>{i.email}</TableCell>
<TableCell className="capitalize">{i.role}</TableCell>
<TableCell className="text-sm text-gray-500">
{new Date(i.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
className="bg-red-700"
onClick={() =>
onRevokeInvitation(i.invitationId, i.email)
}
disabled={revokeInvitationMutation.isPending}
>
{revokeInvitationMutation.isPending &&
revokeInvitationMutation.variables ===
i.invitationId
? "Revoking..."
: "Revoke"}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<ConfirmDialog
open={pendingRemoval !== null}
onOpenChange={(open) => !open && setPendingRemoval(null)}
title="Remove user from this portfolio?"
description={
pendingRemoval ? (
<>
<span className="font-medium">{pendingRemoval.email}</span> will
immediately lose access. They can be re-invited later.
</>
) : null
}
confirmLabel="Remove"
destructive
isPending={removeUserMutation.isPending}
onConfirm={confirmRemove}
/>
<ConfirmDialog
open={pendingRevoke !== null}
onOpenChange={(open) => !open && setPendingRevoke(null)}
title="Revoke this pending invitation?"
description={
pendingRevoke ? (
<>
<span className="font-medium">{pendingRevoke.email}</span> won&apos;t
be able to accept this invitation. You can invite them again
later.
</>
) : null
}
confirmLabel="Revoke"
destructive
isPending={revokeInvitationMutation.isPending}
onConfirm={confirmRevoke}
/>
</div>
);
}
}

View file

@ -0,0 +1,35 @@
import type { Collaborator } from "./roles";
import type { PortfolioPrivilege } from "@/app/lib/portfolioAdmin";
export type CollaboratorsResponse = {
users: Collaborator[];
currentUser?: { privilege: PortfolioPrivilege };
};
// Shared fetcher used by every component that queries the portfolio user
// list. Keeping a single function (and a single response shape) means
// useQuery deduping behaves correctly when the user-access page mounts
// both UsersPermissionsCard and CapabilitiesCard against the same key.
export async function fetchCollaborators(
portfolioId: string,
): Promise<CollaboratorsResponse> {
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`);
if (!res.ok) throw new Error("Failed to fetch collaborators");
const json = await res.json();
const rawUsers = Array.isArray(json) ? json : (json?.users ?? []);
const users: Collaborator[] = Array.isArray(rawUsers)
? rawUsers.map((u: any) => ({
portfolioUserId: String(u.portfolioUserId),
userId: String(u.userId),
name: u.name ?? null,
email: u.email ?? "",
role: u.role,
}))
: [];
const privilege: PortfolioPrivilege | undefined =
json?.currentUser?.privilege;
return privilege ? { users, currentUser: { privilege } } : { users };
}
export const COLLABORATORS_QUERY_KEY = (portfolioId: string) =>
["portfolioUsers", portfolioId] as const;

View file

@ -7,34 +7,50 @@ import {
SelectItem,
} from "@/app/shadcn_components/ui/select";
// Roles you support in your app (adjust as needed)
export const ROLE_OPTIONS = ["read", "write"] as const;
export type Role = typeof ROLE_OPTIONS[number];
// Roles a portfolio admin can assign via the UI. "creator" is set on portfolio
// creation only and is not assignable.
export const ROLE_OPTIONS = ["read", "write", "admin"] as const;
export type Role = (typeof ROLE_OPTIONS)[number];
// Roles a non-admin viewer would see in the assignable dropdown — not used
// for backend validation, just shapes the dropdown when promotion isn't
// permitted.
const BASIC_ROLE_OPTIONS = ["read", "write"] as const;
export type Collaborator = {
portfolioUserId: string;
userId: string;
userId: string;
name?: string | null;
email: string;
role: Role | "creator" | "admin";
role: Role | "creator";
};
// Small role dropdown using shadcn Select
// Small role dropdown using shadcn Select. Pass `allowAdminPromotion` when the
// viewer can promote/demote to/from "admin".
export function RoleDropdown({
value,
onChange,
allowAdminPromotion = false,
disabled = false,
}: {
value: Role;
onChange: (role: Role) => void;
allowAdminPromotion?: boolean;
disabled?: boolean;
}) {
const options = allowAdminPromotion ? ROLE_OPTIONS : BASIC_ROLE_OPTIONS;
return (
<Select value={value} onValueChange={(v) => onChange(v as Role)}>
<Select
value={value}
onValueChange={(v) => onChange(v as Role)}
disabled={disabled}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={value} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLE_OPTIONS.map((r) => (
{options.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
@ -43,4 +59,4 @@ export function RoleDropdown({
</SelectContent>
</Select>
);
}
}

View file

@ -8,6 +8,7 @@ import SurveyedResultsPieChart from "./SurveyedResultsPieChart";
import DampMouldRiskPanel from "./DampMouldRiskPanel";
import CompletionTrendsChart from "./CompletionTrendsChart";
import SurveyIssuesPanel from "./SurveyIssuesPanel";
import GroupFilter, { type GroupNode } from "./GroupFilter";
import { STAGE_COLORS, STAGE_ORDER } from "./types";
import type {
ProjectData,
@ -312,6 +313,10 @@ interface AnalyticsViewProps {
) => void;
majorConditionDeals: ClassifiedDeal[];
totalDeals: number;
availableGroups: GroupNode[];
groupFilter: string[];
onGroupFilterChange: (next: string[]) => void;
groupFilterActive: boolean;
}
export default function AnalyticsView({
@ -322,11 +327,20 @@ export default function AnalyticsView({
onOpenTable,
majorConditionDeals,
totalDeals,
availableGroups,
groupFilter,
onGroupFilterChange,
groupFilterActive,
}: AnalyticsViewProps) {
const showGroupFilter = availableGroups.length > 0;
return (
<div className="space-y-6">
{/* Row 1: project selector + stat card (Properties in project) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Row 1: project selector + (optional) group filter + properties count */}
<div
className={`grid grid-cols-1 gap-4 ${
showGroupFilter ? "sm:grid-cols-3" : "sm:grid-cols-2"
}`}
>
{/* Project selector */}
<Card className="flex flex-col justify-center items-center border border-brandblue/10 bg-gradient-to-br from-brandlightblue/20 to-white shadow-sm hover:shadow-md transition-shadow p-5">
<div className="w-full flex flex-col">
@ -355,10 +369,19 @@ export default function AnalyticsView({
</div>
</Card>
{/* Properties in project */}
{/* Group filter — only when current project has more than one group */}
{showGroupFilter && (
<GroupFilter
options={availableGroups}
selected={groupFilter}
onChange={onGroupFilterChange}
/>
)}
{/* Properties in project (label swaps when group filter is active) */}
<StatCard
icon={Home}
title="Properties in Project"
title={groupFilterActive ? "Properties in Group" : "Properties in Project"}
value={currentProject.allDeals.length}
onClick={() =>
onOpenTable(

View file

@ -36,7 +36,7 @@ type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
interface DocumentTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null, batch: string | null, batchDescription: string | null) => void;
docStatusMap: DocStatusMap;
portfolioId: string;
userCapability: PortfolioCapabilityType;
@ -112,7 +112,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const downloadCsv = () => {
const rows = table.getFilteredRowModel().rows;
const header = "Address,Landlord ID,Retrofit Assessment Status,Install Docs Status";
const header = "Address,Landlord ID,Retrofit Assessment Status,Install Docs Status,Group,Group Description";
const body = rows
.map((row) => {
const status = docStatusMap[row.original.dealId];
@ -133,6 +133,8 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
escapeCell(row.original.landlordPropertyId),
retroStatus,
installStatus,
escapeCell(row.original.batch),
escapeCell(row.original.batchDescription),
].join(",");
})
.join("\n");

View file

@ -101,7 +101,7 @@ function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
}
export function createDocumentTableColumns(
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null, batch: string | null, batchDescription: string | null) => void,
docStatusMap: DocStatusMap = {},
onUpload?: (deal: ClassifiedDeal) => void,
): ColumnDef<ClassifiedDeal>[] {
@ -204,6 +204,8 @@ export function createDocumentTableColumns(
row.original.uprn,
row.original.landlordPropertyId,
row.original.dealname,
row.original.batch,
row.original.batchDescription,
)
}
className={className}

View file

@ -0,0 +1,240 @@
"use client";
import { Fragment } from "react";
import { Check, ChevronDown, Layers, Minus } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { Card } from "@/app/shadcn_components/ui/card";
export type GroupLeaf = {
value: string;
// Full label, used in the trigger when this leaf is selected outside the
// context of a fully-checked parent.
label: string;
// Short label rendered under a parent header (the parent already provides
// context). Falls back to `label`.
shortLabel?: string;
muted?: boolean;
};
export type GroupNode =
| { kind: "leaf"; leaf: GroupLeaf }
| {
kind: "parent";
label: string;
muted?: boolean;
children: GroupLeaf[];
};
interface GroupFilterProps {
options: GroupNode[];
selected: string[];
onChange: (next: string[]) => void;
variant?: "card" | "inline";
}
function GroupDropdown({
options,
selected,
onChange,
triggerClassName,
align = "start",
}: GroupFilterProps & {
triggerClassName: string;
align?: "start" | "end";
}) {
const selectedSet = new Set(selected);
const toggleLeaf = (value: string, checked: boolean) => {
if (checked) {
onChange([...selected, value]);
} else {
onChange(selected.filter((v) => v !== value));
}
};
const toggleParent = (childValues: string[]) => {
const allChecked = childValues.every((v) => selectedSet.has(v));
if (allChecked) {
const remove = new Set(childValues);
onChange(selected.filter((v) => !remove.has(v)));
} else {
const merged = new Set(selected);
for (const v of childValues) merged.add(v);
onChange(Array.from(merged));
}
};
// Trigger chunks: fully-selected parents collapse to the parent's label;
// standalone leaves and partial-parent children contribute their own labels.
const triggerChunks: string[] = [];
for (const node of options) {
if (node.kind === "leaf") {
if (selectedSet.has(node.leaf.value)) triggerChunks.push(node.leaf.label);
} else {
const childValues = node.children.map((c) => c.value);
const allSelected =
childValues.length > 0 && childValues.every((v) => selectedSet.has(v));
if (allSelected) {
triggerChunks.push(node.label);
} else {
for (const child of node.children) {
if (selectedSet.has(child.value)) triggerChunks.push(child.label);
}
}
}
}
const triggerLabel =
triggerChunks.length === 0
? "All groups"
: triggerChunks.length === 1
? triggerChunks[0]
: `${triggerChunks[0]} +${triggerChunks.length - 1}`;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={triggerClassName}>
<span className="flex items-center gap-2 min-w-0">
<Layers className="h-3.5 w-3.5 shrink-0 text-brandblue" />
<span className="truncate">{triggerLabel}</span>
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
className="min-w-[240px] max-w-[360px] w-[--radix-dropdown-menu-trigger-width]"
>
<DropdownMenuLabel className="text-xs text-gray-500 flex items-center justify-between">
<span>Groups</span>
{selected.length > 0 && (
<button
type="button"
onClick={() => onChange([])}
className="text-[11px] text-brandblue hover:underline font-medium"
>
Clear
</button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-72 overflow-y-auto">
{options.map((node, i) => {
const needsSeparator = i > 0;
if (node.kind === "leaf") {
return (
<Fragment key={`leaf-${node.leaf.value}`}>
{needsSeparator && <DropdownMenuSeparator />}
<DropdownMenuCheckboxItem
checked={selectedSet.has(node.leaf.value)}
onCheckedChange={(v) => toggleLeaf(node.leaf.value, !!v)}
onSelect={(e) => e.preventDefault()}
className="text-sm"
>
{node.leaf.muted ? (
<span className="italic text-gray-500">
{node.leaf.label}
</span>
) : (
node.leaf.label
)}
</DropdownMenuCheckboxItem>
</Fragment>
);
}
const childValues = node.children.map((c) => c.value);
const selectedCount = childValues.filter((v) =>
selectedSet.has(v),
).length;
const parentState: boolean | "indeterminate" =
selectedCount === 0
? false
: selectedCount === childValues.length
? true
: "indeterminate";
return (
<Fragment key={`parent-${node.label}`}>
{needsSeparator && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
toggleParent(childValues);
}}
className="relative flex items-center py-1.5 pl-8 pr-2 text-sm font-semibold"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{parentState === true ? (
<Check className="h-4 w-4" />
) : parentState === "indeterminate" ? (
<Minus className="h-4 w-4" />
) : null}
</span>
{node.muted ? (
<span className="italic text-gray-500">{node.label}</span>
) : (
node.label
)}
</DropdownMenuItem>
{node.children.map((child) => (
<DropdownMenuCheckboxItem
key={child.value}
checked={selectedSet.has(child.value)}
onCheckedChange={(v) => toggleLeaf(child.value, !!v)}
onSelect={(e) => e.preventDefault()}
className="text-sm pl-12"
>
{child.muted ? (
<span className="italic text-gray-500">
{child.shortLabel ?? child.label}
</span>
) : (
(child.shortLabel ?? child.label)
)}
</DropdownMenuCheckboxItem>
))}
</Fragment>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function GroupFilter(props: GroupFilterProps) {
const { variant = "card" } = props;
if (variant === "inline") {
return (
<GroupDropdown
{...props}
triggerClassName="h-9 px-3 pr-2 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-sm focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all flex items-center gap-2 min-w-[160px] max-w-[280px]"
/>
);
}
return (
<Card className="flex flex-col justify-center items-center border border-brandblue/10 bg-gradient-to-br from-brandlightblue/20 to-white shadow-sm hover:shadow-md transition-shadow p-5">
<div className="w-full flex flex-col">
<p className="text-xs uppercase tracking-wide text-gray-600 mb-3 font-semibold">
Filter by Group
</p>
<GroupDropdown
{...props}
triggerClassName="w-full px-4 py-2.5 pr-3 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-sm focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all flex items-center justify-between gap-2"
/>
</div>
</Card>
);
}

View file

@ -14,7 +14,7 @@ import { Input } from "@/app/shadcn_components/ui/input";
import { Badge } from "@/app/shadcn_components/ui/badge";
import { Button } from "@/app/shadcn_components/ui/button";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import { Search, CheckSquare, ListChecks, Loader2, X } from "lucide-react";
import { Search, CheckSquare, ListChecks, Loader2, X, Layers } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, ApprovalsByDeal } from "./types";
import { parseMeasures } from "@/app/lib/parseMeasures";
@ -319,6 +319,7 @@ export default function MeasuresTable({
const [showApproveModal, setShowApproveModal] = useState(false);
const [showInstructModal, setShowInstructModal] = useState(false);
const [showBatchColumns, setShowBatchColumns] = useState(false);
const dealsWithMeasures = useMemo(
() => data.filter((d) => d.proposedMeasures || (instructedMeasuresByDeal[d.dealId]?.length ?? 0) > 0),
@ -404,7 +405,7 @@ export default function MeasuresTable({
);
}
const colSpan = mode === "instruct" ? 7 : 6;
const colSpan = (mode === "instruct" ? 7 : 6) + (showBatchColumns ? 2 : 0);
return (
<div className="space-y-4">
@ -425,6 +426,19 @@ export default function MeasuresTable({
{filtered.length} of {dealsWithMeasures.length} properties
</span>
<button
onClick={() => setShowBatchColumns((v) => !v)}
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border text-xs font-medium transition-colors ${
showBatchColumns
? "border-brandblue/40 bg-brandblue/5 text-brandblue"
: "border-gray-200 bg-white text-gray-600 hover:border-brandblue/30 hover:text-brandblue"
}`}
aria-pressed={showBatchColumns}
>
<Layers className="h-3.5 w-3.5" />
Group
</button>
{isApprover && (
<>
<Button
@ -531,6 +545,16 @@ export default function MeasuresTable({
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Status
</TableHead>
{showBatchColumns && (
<>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Group
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Group Description
</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
@ -665,6 +689,21 @@ export default function MeasuresTable({
<TableCell className="py-3">
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
</TableCell>
{showBatchColumns && (
<>
<TableCell className="py-3">
<span className="text-xs font-mono text-gray-600">
{deal.batch ?? <span className="text-gray-300"></span>}
</span>
</TableCell>
<TableCell className="py-3">
<span className="text-xs text-gray-600 max-w-[220px] line-clamp-2 leading-snug">
{deal.batchDescription ?? <span className="text-gray-300"></span>}
</span>
</TableCell>
</>
)}
</TableRow>
);
})}

View file

@ -122,6 +122,7 @@ export default function PropertyDetailDrawer({
{TABS.map((tab) => (
<button
key={tab}
role="tab"
data-testid={`drawer-tab-${tab}`}
aria-selected={activeTab === tab}
onClick={() => setActiveTab(tab)}

View file

@ -23,6 +23,8 @@ interface PropertyDrawerProps {
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
batch?: string | null;
batchDescription?: string | null;
docStatus?: DocStatus;
onClose: () => void;
}
@ -33,6 +35,8 @@ export default function PropertyDrawer({
uprn,
landlordPropertyId,
dealname,
batch,
batchDescription,
docStatus,
onClose,
}: PropertyDrawerProps) {
@ -85,6 +89,15 @@ export default function PropertyDrawer({
Ref: {landlordPropertyId}
</DrawerDescription>
) : null}
{batch && (
<p className="text-xs text-gray-500 mt-1 truncate">
Group:{" "}
<span className="font-mono text-gray-700">{batch}</span>
{batchDescription ? (
<span className="text-gray-500"> {batchDescription}</span>
) : null}
</p>
)}
</div>
<DrawerClose asChild>
<button

View file

@ -59,6 +59,8 @@ const COLUMN_LABELS: Record<string, string> = {
lodgementStatus: "Lodgement Status",
designDate: "Design Date",
fullLodgementDate: "Lodgement Date",
batch: "Group",
batchDescription: "Group Description",
};
type DocFilter = "all" | "has_docs" | "incomplete" | "none";
@ -66,7 +68,7 @@ type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_additio
interface PropertyTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null, batch: string | null, batchDescription: string | null) => void;
portfolioId?: string;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
@ -93,6 +95,8 @@ const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
{ key: "lodgementStatus", label: "Lodgement Status" },
{ key: "designDate", label: "Design Date" },
{ key: "fullLodgementDate", label: "Lodgement Date" },
{ key: "batch", label: "Group" },
{ key: "batchDescription", label: "Group Description" },
];
function escapeCell(value: unknown): string {
@ -130,6 +134,8 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh
lodgementStatus: false,
designDate: false,
fullLodgementDate: false,
batch: false,
batchDescription: false,
});
// Pre-filter by stage, doc status, and removal status before TanStack gets it

View file

@ -31,7 +31,7 @@ function SortableHeader({
// docStatusMap provides per-deal document status for status indicators
// -----------------------------------------------------------------------
export function createPropertyTableColumns(
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null, batch: string | null, batchDescription: string | null) => void,
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
portfolioId: string = "",
@ -320,6 +320,30 @@ export function createPropertyTableColumns(
},
},
// ── Group ────────────────────────────────────────────────────────────
{
accessorKey: "batch",
id: "batch",
header: ({ column }) => <SortableHeader label="Group" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.batch ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Group description ────────────────────────────────────────────────
{
accessorKey: "batchDescription",
id: "batchDescription",
header: ({ column }) => <SortableHeader label="Group Description" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[220px] line-clamp-2 leading-snug">
{row.original.batchDescription ?? <span className="text-gray-300"></span>}
</span>
),
},
];
if (showDocuments) {
@ -352,7 +376,16 @@ export function createPropertyTableColumns(
return (
<button
onClick={() => onOpenDrawer(row.original.dealId, row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
onClick={() =>
onOpenDrawer(
row.original.dealId,
row.original.uprn,
row.original.landlordPropertyId,
row.original.dealname,
row.original.batch,
row.original.batchDescription,
)
}
className={className}
>
{icon}

View file

@ -163,6 +163,16 @@ export default function DealPage({
{/* Key details */}
<div className="space-y-0.5 divide-y divide-gray-50">
<InfoRow label="Project" value={deal.projectCode} />
<InfoRow
label="Group"
value={
deal.batch
? deal.batchDescription
? `${deal.batch}${deal.batchDescription}`
: deal.batch
: null
}
/>
<InfoRow label="Coordinator" value={deal.coordinator} />
<InfoRow label="Designer" value={deal.designer} />
<InfoRow label="Installer" value={deal.installer} />
@ -210,6 +220,7 @@ export default function DealPage({
{VALID_TABS.map((tab) => (
<button
key={tab}
role="tab"
data-testid={`deal-page-tab-${tab}`}
aria-selected={activeTab === tab}
onClick={() => switchTab(tab)}

View file

@ -67,6 +67,8 @@ export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
domnaSurveyType: d.domnaSurveyType,
domnaSurveyDate: d.domnaSurveyDate,
batch: d.batch,
batchDescription: d.batchDescription,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
};

View file

@ -56,6 +56,8 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
technicalApprovedMeasuresForInstall: null,
domnaSurveyType: null,
domnaSurveyDate: null,
batch: null,
batchDescription: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,

View file

@ -55,6 +55,8 @@ function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
technicalApprovedMeasuresForInstall: "Solar PV",
domnaSurveyType: null,
domnaSurveyDate: null,
batch: null,
batchDescription: null,
createdAt: new Date(),
updatedAt: new Date(),
displayStage: "Coordination in Progress",

View file

@ -63,6 +63,8 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
technicalApprovedMeasuresForInstall: null,
domnaSurveyType: null,
domnaSurveyDate: null,
batch: null,
batchDescription: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,

View file

@ -64,6 +64,9 @@ export type HubspotDeal = {
domnaSurveyType: string | null;
domnaSurveyDate: Date | null;
batch: string | null;
batchDescription: string | null;
createdAt: Date;
updatedAt: Date;
};
@ -297,6 +300,8 @@ export type DocumentDrawerState = {
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
batch: string | null;
batchDescription: string | null;
};
// -----------------------------------------------------------------------

View file

@ -146,8 +146,8 @@ function EmptyPropertyState() {
<div className="text-center text-gray-400">
<HomeIcon className="h-16 w-16 mx-auto mb-4 text-gray-200" />
<p>
Hover over <strong>&ldquo;New Property&rdquo;</strong> to start adding properties
to your portfolio.
Hover over <strong>&ldquo;New Property&rdquo;</strong> to start adding
properties to your portfolio.
</p>
</div>
</div>
@ -388,7 +388,10 @@ export default function PropertyTable({
filterGroups: allFilterGroups,
});
const queryData = useMemo(() => filteredResponse?.data ?? [], [filteredResponse?.data]);
const queryData = useMemo(
() => filteredResponse?.data ?? [],
[filteredResponse?.data],
);
const filteredTotal = filteredResponse?.total ?? 0;
// Second query for total (no filters) — React Query dedupes when filters are empty
@ -500,7 +503,7 @@ export default function PropertyTable({
const [previewError] = useState<string | null>(null);
return (
<div className="py-4">
<div className="py-4 mx-4">
{/* Action bar */}
<div className="flex items-center justify-between mb-3">
{/* Left: results count */}
@ -594,14 +597,18 @@ export default function PropertyTable({
{/* Export */}
{filteredTotal > EXPORT_LIMIT ? (
<Tooltip content={`Export is limited to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}>
<Tooltip
content={`Export is limited to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}
>
<button
disabled
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-400 cursor-not-allowed opacity-60"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
<span className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-amber-400 text-white text-[9px] font-black leading-none">!</span>
<span className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-amber-400 text-white text-[9px] font-black leading-none">
!
</span>
</button>
</Tooltip>
) : (
@ -680,7 +687,8 @@ export default function PropertyTable({
<span className="font-semibold text-primary">
{filteredTotal.toLocaleString()}
</span>{" "}
properties more load automatically as you navigate to the last page.
properties more load automatically as you navigate to the last
page.
</span>
</div>
)}