initial implementation for portfolio invitations. A user can send an invitation to a user and they will receive an invitation email

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 16:18:21 +00:00
parent 5ea7e00fbe
commit c921db7d9c
7 changed files with 534 additions and 86 deletions

View file

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

View file

@ -19,6 +19,12 @@ import {
authRateLimits,
verificationTokens,
} from "@/app/db/schema/users";
import {
portfolioInvitations,
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { planInvitationApplication } from "@/app/lib/portfolioInvitations";
import { normaliseEmail } from "@/app/lib/email";
import { eq, and, ne } from "drizzle-orm";
// ------------------------------------------------------------------
@ -409,6 +415,52 @@ export const AuthOptions: NextAuthOptions = {
.set({ lastLogin: new Date() })
.where(eq(users.id, dbUser.id));
// Apply any pending portfolio invitations addressed to this email.
// Idempotent: runs every sign-in; no-op when there are no pending rows.
const pending = await db
.select({
id: portfolioInvitations.id,
portfolioId: portfolioInvitations.portfolioId,
role: portfolioInvitations.role,
})
.from(portfolioInvitations)
.where(eq(portfolioInvitations.email, normalisedEmail));
if (pending.length > 0) {
const existing = await db
.select({ portfolioId: portfolioUsers.portfolioId })
.from(portfolioUsers)
.where(eq(portfolioUsers.userId, dbUser.id));
const plan = planInvitationApplication({
userId: dbUser.id,
invitations: pending.map((p) => ({
id: p.id,
portfolioId: p.portfolioId,
role: p.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("APPLIED_PENDING_INVITATIONS", {
email: normalisedEmail,
userId: dbUser.id.toString(),
count: plan.memberships.length,
staleDeleted: plan.invitationsToDelete.length - plan.memberships.length,
});
}
// Pass bigint ID into NextAuth session/jwt
user.dbId = dbUser.id.toString();
user.onboarded = dbUser.onboarded ?? false;
@ -452,11 +504,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 +524,10 @@ export const AuthOptions: NextAuthOptions = {
* Redirect users after login
*/
async redirect({ url, baseUrl }) {
// If the user has not onboarded, send them to onboarding
// This logging is too noisy
// console.log("Redirect triggered:", {
// from: url,
// to: `${baseUrl}/home`,
// timestamp: new Date().toISOString(),
// });
// Respect internal callbackUrl so e.g. invitation emails can deep-link
// to /portfolio/<id> after sign-in. Default to /home for bare sign-ins.
if (url.startsWith("/")) return `${baseUrl}${url}`;
if (url.startsWith(baseUrl)) return url;
return `${baseUrl}/home`;
},
},

View file

@ -1,6 +1,10 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio";
import {
portfolio,
portfolioInvitations,
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import {
recommendation,
@ -14,9 +18,13 @@ import {
propertyDetailsEpc,
property,
} from "@/app/db/schema/property";
import { eq, inArray, Name } from "drizzle-orm";
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";
// Get colloborators (users) that have access to the portfolio
export async function GET(
@ -97,14 +105,17 @@ export async function PUT(
}
}
// POST: invite a user by email (find-or-create user, then add to portfolio with role)
// POST: invite a user by email.
//
// If the email already corresponds to a user, link them to the portfolio
// directly (existing user case). Otherwise create a pending invitation that
// gets consumed by the signIn callback the first time the invitee signs in.
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),
@ -118,85 +129,162 @@ export async function POST(
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const email = normaliseEmail(body.email);
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const inviterUserId = BigInt(session.user.dbId);
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))
const [portfolioRow] = await db
.select({ name: portfolio.name })
.from(portfolio)
.where(eq(portfolio.id, pId))
.limit(1);
let createdUserId: bigint | null = existing[0]?.id ?? null;
// If not found, create. Prefer Postgres upsert to avoid race.
if (!createdUserId) {
// If youre on Postgres, this is ideal:
const inserted = await db
.insert(user)
.values({
email: body.email,
firstName: body.name,
oauthProvider: "credentials",
})
.onConflictDoNothing() // relies on a UNIQUE(email) constraint
.returning({ id: user.id });
if (inserted.length > 0) {
createdUserId = inserted[0].id;
} else {
// Someone else created the user concurrently; fetch it
const fetched = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, body.email))
.limit(1);
if (!fetched[0]) {
return NextResponse.json(
{ error: "Failed to create or fetch user" },
{ status: 500 }
);
}
createdUserId = fetched[0].id;
}
}
// 3) Link user to portfolio with role (upsert)
// Assumes a UNIQUE index on (portfolioId, userId) in portfolioUsers.
const linkResult = await db
.insert(portfolioUsers)
.values({
portfolioId: pId,
userId: createdUserId!,
role: body.role,
})
.returning({
portfolioUserId: portfolioUsers.id,
userId: portfolioUsers.userId,
role: portfolioUsers.role,
});
const row = linkResult[0];
if (!row) {
if (!portfolioRow) {
return NextResponse.json(
{ error: "Failed to create portfolio user" },
{ status: 500 }
{ error: "Portfolio not found" },
{ status: 404 },
);
}
const collaborator = {
portfolioUserId: row.portfolioUserId?.toString() ?? null,
userId: row.userId?.toString() ?? null,
role: row.role,
name: body.name ?? null,
email: body.email,
};
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";
// 201 if it was a new link, 200 if it was an update — we cant easily
// tell from .onConflictDoUpdate return, so just use 200 OK.
return NextResponse.json({ user: collaborator }, { status: 200 });
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);
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);
let portfolioUserId: bigint;
if (existingMembership) {
if (existingMembership.role !== body.role) {
await db
.update(portfolioUsers)
.set({ role: body.role })
.where(eq(portfolioUsers.id, existingMembership.id));
}
portfolioUserId = existingMembership.id;
} else {
const [inserted] = await db
.insert(portfolioUsers)
.values({
portfolioId: pId,
userId: existingUser.id,
role: body.role,
})
.returning({ id: portfolioUsers.id });
portfolioUserId = inserted.id;
}
try {
await PortfolioInvitationEmail({
identifier: email,
portfolioName: portfolioRow.name,
inviterName,
linkUrl: `${appOrigin}/portfolio/${pId.toString()}`,
mode: "existing-user",
});
} catch (mailErr) {
console.error("PORTFOLIO_INVITATION_EMAIL_FAILURE", {
email,
error: mailErr instanceof Error ? mailErr.message : String(mailErr),
});
// The membership write succeeded — surface the email failure to the
// client but don't roll back the membership.
}
return NextResponse.json(
{
user: {
portfolioUserId: portfolioUserId.toString(),
userId: existingUser.id.toString(),
role: body.role,
name: existingUser.firstName ?? body.name ?? null,
email,
kind: "member" as const,
},
},
{ status: 200 },
);
}
// No user with this email yet — record a pending invitation. The signIn
// callback applies it the first time the invitee signs in.
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: "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: body.name ?? null,
email,
kind: "invitation" as const,
},
},
{ status: 200 },
);
} catch (err) {
console.error("POST /colloborators error:", err);
return NextResponse.json(

View file

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

View file

@ -0,0 +1,179 @@
// 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 =
mode === "existing-user"
? `You've been added to ${portfolioName} on Ara`
: `${inviterName} invited you to join ${portfolioName} on Ara`;
const ctaLabel =
mode === "existing-user" ? "Open portfolio" : "Sign in to Ara";
const result = await transport.sendMail({
to: identifier,
from,
subject,
text: plainText({
portfolioName,
inviterName,
linkUrl,
ctaLabel,
mode,
}),
html: domnaHtml({
portfolioName,
inviterName,
linkUrl,
ctaLabel,
mode,
logoUrl,
host,
}),
headers: buildMailHeaders({
fromAddress: from,
sesConfigurationSet: process.env.SES_CONFIGURATION_SET,
}),
});
const failed = result.rejected.filter(Boolean);
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
}
return { messageId: result.messageId };
}
function domnaHtml({
portfolioName,
inviterName,
linkUrl,
ctaLabel,
mode,
logoUrl,
host,
}: {
portfolioName: string;
inviterName: string;
linkUrl: string;
ctaLabel: string;
mode: InvitationMode;
logoUrl: string;
host: string;
}) {
const escapedHost = host.replace(/\./g, "&#8203;.");
const brandColor = "#14163d";
const accentColor = "#2d348f";
const brown = "#c4a47c";
const background = "#F9F9F9";
const heading =
mode === "existing-user"
? `You've been added to ${portfolioName}`
: `${inviterName} invited you to ${portfolioName}`;
const explainer =
mode === "existing-user"
? `${inviterName} added you to the <strong>${portfolioName}</strong> portfolio on Ara. Open it below to start collaborating.`
: `${inviterName} invited you to join the <strong>${portfolioName}</strong> portfolio on Ara. Sign in with this email address to accept the invitation.`;
return `
<body style="background: ${background}; font-family: Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0"
style="max-width: 600px; margin: 40px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">
<tr>
<td align="center" style="background: linear-gradient(90deg, ${brandColor}, ${accentColor}); padding: 12px 8px;">
<img src="${logoUrl}" alt="Domna Logo" width="120" height="auto" style="margin-bottom: 4px;" />
</td>
</tr>
<tr>
<td align="center" style="padding: 28px 24px 12px; color: #333;">
<h2 style="color: ${brandColor}; font-size: 22px; margin: 0 0 12px;">${heading}</h2>
<p style="font-size: 15px; line-height: 1.5; color: #555; margin: 0 0 24px;">${explainer}</p>
<a href="${linkUrl}" target="_blank"
style="display: inline-block; padding: 12px 24px; background: ${brown}; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">
${ctaLabel}
</a>
<p style="margin-top: 28px; font-size: 13px; color: #777;">
If you weren't expecting this email, you can safely ignore it.
</p>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px; font-size: 12px; color: #999; border-top: 1px solid #eee;">
&copy; ${new Date().getFullYear()} Domna Homes <span style="color: ${accentColor};">${escapedHost}</span>
</td>
</tr>
</table>
</body>
`;
}
function plainText({
portfolioName,
inviterName,
linkUrl,
ctaLabel,
mode,
}: {
portfolioName: string;
inviterName: string;
linkUrl: string;
ctaLabel: string;
mode: InvitationMode;
}) {
const heading =
mode === "existing-user"
? `You've been added to ${portfolioName}`
: `${inviterName} invited you to ${portfolioName}`;
const explainer =
mode === "existing-user"
? `${inviterName} added you to the ${portfolioName} portfolio on Ara.`
: `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email address to accept.`;
return `${heading}
${explainer}
${ctaLabel}: ${linkUrl}
If you weren't expecting this email, you can safely ignore it.
`;
}

View file

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

View file

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