diff --git a/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts b/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts index 0e7cbab..da2c4ad 100644 --- a/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts +++ b/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts @@ -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), diff --git a/src/app/api/auth/[...nextauth]/authOptions.ts b/src/app/api/auth/[...nextauth]/authOptions.ts index b7a450b..4adeaae 100644 --- a/src/app/api/auth/[...nextauth]/authOptions.ts +++ b/src/app/api/auth/[...nextauth]/authOptions.ts @@ -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/ 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`; }, }, diff --git a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts index 309fee6..5420c4c 100644 --- a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts +++ b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts @@ -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 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) { + 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 can’t 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( diff --git a/src/app/db/schema/portfolio.ts b/src/app/db/schema/portfolio.ts index 8e231bc..85469a2 100644 --- a/src/app/db/schema/portfolio.ts +++ b/src/app/db/schema/portfolio.ts @@ -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; export type NewPortfolio = InferModel; export type PortfolioUsers = InferModel; export type NewPortfolioUsers = InferModel; +export type PortfolioInvitation = InferModel< + typeof portfolioInvitations, + "select" +>; +export type NewPortfolioInvitation = InferModel< + typeof portfolioInvitations, + "insert" +>; export type PortfolioCapabilities = InferModel< typeof portfolioCapabilities, "select" diff --git a/src/app/email_templates/portfolio_invitation.ts b/src/app/email_templates/portfolio_invitation.ts new file mode 100644 index 0000000..738e7d8 --- /dev/null +++ b/src/app/email_templates/portfolio_invitation.ts @@ -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, "​."); + 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 ${portfolioName} portfolio on Ara. Open it below to start collaborating.` + : `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email address to accept the invitation.`; + + return ` + + + + + + + + + + + +
+ Domna Logo +
+

${heading}

+

${explainer}

+ + ${ctaLabel} + +

+ If you weren't expecting this email, you can safely ignore it. +

+
+ © ${new Date().getFullYear()} Domna Homes • ${escapedHost} +
+ + `; +} + +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. +`; +} diff --git a/src/app/lib/portfolioInvitations.test.ts b/src/app/lib/portfolioInvitations.test.ts new file mode 100644 index 0000000..f5dc951 --- /dev/null +++ b/src/app/lib/portfolioInvitations.test.ts @@ -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(), + }); + + 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([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([300n]), + }); + + expect(plan.memberships).toEqual([ + { portfolioId: 200n, userId: 100n, role: "read" }, + { portfolioId: 400n, userId: 100n, role: "admin" }, + ]); + expect(plan.invitationsToDelete).toEqual([1n, 2n, 3n]); + }); +}); diff --git a/src/app/lib/portfolioInvitations.ts b/src/app/lib/portfolioInvitations.ts new file mode 100644 index 0000000..9291953 --- /dev/null +++ b/src/app/lib/portfolioInvitations.ts @@ -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; +}): 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 }; +}