mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge branch 'main' into feature/frontend_landlord_overrides
# Conflicts: # docs/wip/landlord-override-frontend-plan.md
This commit is contained in:
commit
f1a794ccbe
66 changed files with 64011 additions and 506 deletions
451
docs/wip/auth-email-code-fallback-plan.md
Normal file
451
docs/wip/auth-email-code-fallback-plan.md
Normal 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?
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
# Landlord override frontend — in-flight design notes
|
||||
|
||||
**Status:** Paused mid-grilling (2026-05-27)
|
||||
**Status:** Grilling complete (2026-05-28) — Q1–Q7 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 (Q1–Q7). 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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
349
src/app/api/portfolio/[portfolioId]/collaborators/route.ts
Normal file
349
src/app/api/portfolio/[portfolioId]/collaborators/route.ts
Normal 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 collaborator’s 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 collaborator’s 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 you’re 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 can’t 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
104
src/app/api/portfolio/[portfolioId]/invitations/route.ts
Normal file
104
src/app/api/portfolio/[portfolioId]/invitations/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
190
src/app/api/user/invitations/route.ts
Normal file
190
src/app/api/user/invitations/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/app/auth/verify-code/VerifyCodeForm.tsx
Normal file
136
src/app/auth/verify-code/VerifyCodeForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/app/auth/verify-code/page.tsx
Normal file
60
src/app/auth/verify-code/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/app/components/ConfirmDialog.tsx
Normal file
65
src/app/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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've joined {acceptedInfo?.portfolioName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
You now have access. Head over when you'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
2
src/app/db/migrations/0209_third_klaw.sql
Normal file
2
src/app/db/migrations/0209_third_klaw.sql
Normal 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;
|
||||
10
src/app/db/migrations/0210_absent_dark_phoenix.sql
Normal file
10
src/app/db/migrations/0210_absent_dark_phoenix.sql
Normal 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;
|
||||
10
src/app/db/migrations/0211_lovely_sue_storm.sql
Normal file
10
src/app/db/migrations/0211_lovely_sue_storm.sql
Normal 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;
|
||||
12
src/app/db/migrations/0212_sweet_the_anarchist.sql
Normal file
12
src/app/db/migrations/0212_sweet_the_anarchist.sql
Normal 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;
|
||||
3
src/app/db/migrations/0213_tired_victor_mancha.sql
Normal file
3
src/app/db/migrations/0213_tired_victor_mancha.sql
Normal 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");
|
||||
1
src/app/db/migrations/0214_superb_maelstrom.sql
Normal file
1
src/app/db/migrations/0214_superb_maelstrom.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "booking_status" text;
|
||||
9913
src/app/db/migrations/meta/0209_snapshot.json
Normal file
9913
src/app/db/migrations/meta/0209_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9972
src/app/db/migrations/meta/0210_snapshot.json
Normal file
9972
src/app/db/migrations/meta/0210_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
10032
src/app/db/migrations/meta/0211_snapshot.json
Normal file
10032
src/app/db/migrations/meta/0211_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
10119
src/app/db/migrations/meta/0212_snapshot.json
Normal file
10119
src/app/db/migrations/meta/0212_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
10119
src/app/db/migrations/meta/0213_snapshot.json
Normal file
10119
src/app/db/migrations/meta/0213_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
10125
src/app/db/migrations/meta/0214_snapshot.json
Normal file
10125
src/app/db/migrations/meta/0214_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
21
src/app/db/schema/crm/hubspot_projects_table.ts
Normal file
21
src/app/db/schema/crm/hubspot_projects_table.ts
Normal 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">;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
31
src/app/email_templates/buildMailHeaders.test.ts
Normal file
31
src/app/email_templates/buildMailHeaders.test.ts
Normal 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>");
|
||||
});
|
||||
});
|
||||
12
src/app/email_templates/buildMailHeaders.ts
Normal file
12
src/app/email_templates/buildMailHeaders.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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, "​.");
|
||||
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 didn’t 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.
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
167
src/app/email_templates/portfolio_invitation.ts
Normal file
167
src/app/email_templates/portfolio_invitation.ts
Normal 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, "​.");
|
||||
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;">
|
||||
© ${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
15
src/app/lib/email.test.ts
Normal 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
3
src/app/lib/email.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function normaliseEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
35
src/app/lib/portfolioAdmin.test.ts
Normal file
35
src/app/lib/portfolioAdmin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
src/app/lib/portfolioAdmin.ts
Normal file
19
src/app/lib/portfolioAdmin.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
50
src/app/lib/portfolioInvitations.test.ts
Normal file
50
src/app/lib/portfolioInvitations.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
42
src/app/lib/portfolioInvitations.ts
Normal file
42
src/app/lib/portfolioInvitations.ts
Normal 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 };
|
||||
}
|
||||
65
src/app/lib/resolvePortfolioPrivilege.ts
Normal file
65
src/app/lib/resolvePortfolioPrivilege.ts
Normal 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;
|
||||
}
|
||||
95
src/app/lib/verificationCode.test.ts
Normal file
95
src/app/lib/verificationCode.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
43
src/app/lib/verificationCode.ts
Normal file
43
src/app/lib/verificationCode.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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't
|
||||
be able to accept this invitation. You can invite them again
|
||||
later.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
confirmLabel="Revoke"
|
||||
destructive
|
||||
isPending={revokeInvitationMutation.isPending}
|
||||
onConfirm={confirmRevoke}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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>“New Property”</strong> to start adding properties
|
||||
to your portfolio.
|
||||
Hover over <strong>“New Property”</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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue