Merge pull request #289 from Hestia-Homes/main

Dev deploy
This commit is contained in:
KhalimCK 2026-05-28 14:04:12 +01:00 committed by GitHub
commit 56e24a788e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 42722 additions and 516 deletions

View file

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

View file

@ -19,6 +19,7 @@ import {
authRateLimits,
verificationTokens,
} from "@/app/db/schema/users";
import { normaliseEmail } from "@/app/lib/email";
import { eq, and, ne } from "drizzle-orm";
// ------------------------------------------------------------------
@ -409,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;
@ -452,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;
},
@ -465,13 +477,10 @@ export const AuthOptions: NextAuthOptions = {
* Redirect users after login
*/
async redirect({ url, baseUrl }) {
// If the user has not onboarded, send them to onboarding
// This logging is too noisy
// console.log("Redirect triggered:", {
// from: url,
// to: `${baseUrl}/home`,
// timestamp: new Date().toISOString(),
// });
// Respect internal callbackUrl so e.g. invitation emails can deep-link
// to /portfolio/<id> after sign-in. Default to /home for bare sign-ins.
if (url.startsWith("/")) return `${baseUrl}${url}`;
if (url.startsWith(baseUrl)) return url;
return `${baseUrl}/home`;
},
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1478,6 +1478,34 @@
"when": 1779889030729,
"tag": "0210_absent_dark_phoenix",
"breakpoints": true
},
{
"idx": 211,
"version": "7",
"when": 1779898075572,
"tag": "0211_lovely_sue_storm",
"breakpoints": true
},
{
"idx": 212,
"version": "7",
"when": 1779900843875,
"tag": "0212_sweet_the_anarchist",
"breakpoints": true
},
{
"idx": 213,
"version": "7",
"when": 1779909562600,
"tag": "0213_tired_victor_mancha",
"breakpoints": true
},
{
"idx": 214,
"version": "7",
"when": 1779969672088,
"tag": "0214_superb_maelstrom",
"breakpoints": true
}
]
}

View file

@ -8,6 +8,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
dealname: text("dealname"),
dealstage: text("dealstage"),
companyId: text("company_id"),
projectId: text("project_id"),
projectCode: text("project_code"),
landlordPropertyId: text("landlord_property_id"),
@ -22,6 +23,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
coordinationStatus: text("coordination_status"),
designStatus: text("design_status"),
bookingStatus: text("booking_status"),
pashubLink: text("pashub_link"),
sharepointLink: text("sharepoint_link"),

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@ import SurveyedResultsPieChart from "./SurveyedResultsPieChart";
import DampMouldRiskPanel from "./DampMouldRiskPanel";
import CompletionTrendsChart from "./CompletionTrendsChart";
import SurveyIssuesPanel from "./SurveyIssuesPanel";
import BatchFilter from "./BatchFilter";
import GroupFilter, { type GroupNode } from "./GroupFilter";
import { STAGE_COLORS, STAGE_ORDER } from "./types";
import type {
ProjectData,
@ -313,10 +313,10 @@ interface AnalyticsViewProps {
) => void;
majorConditionDeals: ClassifiedDeal[];
totalDeals: number;
availableBatches: string[];
batchFilter: string[];
onBatchFilterChange: (next: string[]) => void;
batchFilterActive: boolean;
availableGroups: GroupNode[];
groupFilter: string[];
onGroupFilterChange: (next: string[]) => void;
groupFilterActive: boolean;
}
export default function AnalyticsView({
@ -327,18 +327,18 @@ export default function AnalyticsView({
onOpenTable,
majorConditionDeals,
totalDeals,
availableBatches,
batchFilter,
onBatchFilterChange,
batchFilterActive,
availableGroups,
groupFilter,
onGroupFilterChange,
groupFilterActive,
}: AnalyticsViewProps) {
const showBatchFilter = availableBatches.length > 0;
const showGroupFilter = availableGroups.length > 0;
return (
<div className="space-y-6">
{/* Row 1: project selector + (optional) batch filter + properties count */}
{/* Row 1: project selector + (optional) group filter + properties count */}
<div
className={`grid grid-cols-1 gap-4 ${
showBatchFilter ? "sm:grid-cols-3" : "sm:grid-cols-2"
showGroupFilter ? "sm:grid-cols-3" : "sm:grid-cols-2"
}`}
>
{/* Project selector */}
@ -369,19 +369,19 @@ export default function AnalyticsView({
</div>
</Card>
{/* Batch filter — only when current project has batched deals */}
{showBatchFilter && (
<BatchFilter
options={availableBatches}
selected={batchFilter}
onChange={onBatchFilterChange}
{/* 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 batch filter is active) */}
{/* Properties in project (label swaps when group filter is active) */}
<StatCard
icon={Home}
title={batchFilterActive ? "Properties in Group" : "Properties in Project"}
title={groupFilterActive ? "Properties in Group" : "Properties in Project"}
value={currentProject.allDeals.length}
onClick={() =>
onOpenTable(

View file

@ -1,123 +0,0 @@
"use client";
import { ChevronDown, Layers } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { Card } from "@/app/shadcn_components/ui/card";
export const UNBATCHED_KEY = "__UNBATCHED__" as const;
interface BatchFilterProps {
options: string[]; // batch codes present in the current project; may include UNBATCHED_KEY
selected: string[]; // empty array = no filter applied (show everything)
onChange: (next: string[]) => void;
variant?: "card" | "inline";
}
function BatchDropdown({
options,
selected,
onChange,
triggerClassName,
align = "start",
}: BatchFilterProps & {
triggerClassName: string;
align?: "start" | "end";
}) {
const toggle = (value: string, checked: boolean) => {
if (checked) {
onChange([...selected, value]);
} else {
onChange(selected.filter((v) => v !== value));
}
};
const label =
selected.length === 0
? "All groups"
: selected
.map((s) => (s === UNBATCHED_KEY ? "(Ungrouped)" : s))
.join(", ");
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">{label}</span>
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
className="min-w-[220px] 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((opt) => (
<DropdownMenuCheckboxItem
key={opt}
checked={selected.includes(opt)}
onCheckedChange={(val) => toggle(opt, !!val)}
onSelect={(e) => e.preventDefault()}
className="text-sm"
>
{opt === UNBATCHED_KEY ? (
<span className="italic text-gray-500">(Ungrouped)</span>
) : (
opt
)}
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function BatchFilter(props: BatchFilterProps) {
const { variant = "card" } = props;
if (variant === "inline") {
return (
<BatchDropdown
{...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>
<BatchDropdown
{...props}
triggerClassName="w-full px-4 py-2.5 pr-3 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-sm focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all flex items-center justify-between gap-2"
/>
</div>
</Card>
);
}

View file

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

View file

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

View file

@ -220,6 +220,7 @@ export default function DealPage({
{VALID_TABS.map((tab) => (
<button
key={tab}
role="tab"
data-testid={`deal-page-tab-${tab}`}
aria-selected={activeTab === tab}
onClick={() => switchTab(tab)}

View file

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