mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
commit
56e24a788e
41 changed files with 42722 additions and 516 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
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;
|
||||
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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
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"
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
@ -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,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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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