mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
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:
parent
5ea7e00fbe
commit
c921db7d9c
7 changed files with 534 additions and 86 deletions
|
|
@ -12,6 +12,7 @@ import {
|
||||||
sessions as sessionsTable,
|
sessions as sessionsTable,
|
||||||
verificationTokens as verificationTokensTable,
|
verificationTokens as verificationTokensTable,
|
||||||
} from "@/app/db/schema/users";
|
} from "@/app/db/schema/users";
|
||||||
|
import { normaliseEmail } from "@/app/lib/email";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Drizzle adapter for NextAuth v4
|
* Custom Drizzle adapter for NextAuth v4
|
||||||
|
|
@ -48,8 +49,6 @@ export default function DrizzleEmailAdapter(
|
||||||
//----------------------------------------------------------------------
|
//----------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
//----------------------------------------------------------------------
|
//----------------------------------------------------------------------
|
||||||
const normaliseEmail = (email: string) => email.trim().toLowerCase();
|
|
||||||
|
|
||||||
const toAdapterUser = (u: any): AdapterUser => ({
|
const toAdapterUser = (u: any): AdapterUser => ({
|
||||||
id: String(u.id),
|
id: String(u.id),
|
||||||
dbId: String(u.id),
|
dbId: String(u.id),
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ import {
|
||||||
authRateLimits,
|
authRateLimits,
|
||||||
verificationTokens,
|
verificationTokens,
|
||||||
} from "@/app/db/schema/users";
|
} 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";
|
import { eq, and, ne } from "drizzle-orm";
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -409,6 +415,52 @@ export const AuthOptions: NextAuthOptions = {
|
||||||
.set({ lastLogin: new Date() })
|
.set({ lastLogin: new Date() })
|
||||||
.where(eq(users.id, dbUser.id));
|
.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
|
// Pass bigint ID into NextAuth session/jwt
|
||||||
user.dbId = dbUser.id.toString();
|
user.dbId = dbUser.id.toString();
|
||||||
user.onboarded = dbUser.onboarded ?? false;
|
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 }) {
|
async session({ session, token }) {
|
||||||
if (session.user && token.dbId) {
|
if (session.user) {
|
||||||
session.user.dbId = token.dbId;
|
if (session.user.email) {
|
||||||
|
session.user.email = normaliseEmail(session.user.email);
|
||||||
|
}
|
||||||
|
if (token.dbId) {
|
||||||
|
session.user.dbId = token.dbId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|
@ -465,13 +524,10 @@ export const AuthOptions: NextAuthOptions = {
|
||||||
* Redirect users after login
|
* Redirect users after login
|
||||||
*/
|
*/
|
||||||
async redirect({ url, baseUrl }) {
|
async redirect({ url, baseUrl }) {
|
||||||
// If the user has not onboarded, send them to onboarding
|
// Respect internal callbackUrl so e.g. invitation emails can deep-link
|
||||||
// This logging is too noisy
|
// to /portfolio/<id> after sign-in. Default to /home for bare sign-ins.
|
||||||
// console.log("Redirect triggered:", {
|
if (url.startsWith("/")) return `${baseUrl}${url}`;
|
||||||
// from: url,
|
if (url.startsWith(baseUrl)) return url;
|
||||||
// to: `${baseUrl}/home`,
|
|
||||||
// timestamp: new Date().toISOString(),
|
|
||||||
// });
|
|
||||||
return `${baseUrl}/home`;
|
return `${baseUrl}/home`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { db } from "@/app/db/db";
|
import { db } from "@/app/db/db";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
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 { user } from "@/app/db/schema/users";
|
||||||
import {
|
import {
|
||||||
recommendation,
|
recommendation,
|
||||||
|
|
@ -14,9 +18,13 @@ import {
|
||||||
propertyDetailsEpc,
|
propertyDetailsEpc,
|
||||||
property,
|
property,
|
||||||
} from "@/app/db/schema/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 { z } from "zod";
|
||||||
import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
|
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
|
// Get colloborators (users) that have access to the portfolio
|
||||||
export async function GET(
|
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(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
props: { params: Promise<{ portfolioId: string }> }
|
props: { params: Promise<{ portfolioId: string }> }
|
||||||
) {
|
) {
|
||||||
const { portfolioId } = await props.params;
|
const { portfolioId } = await props.params;
|
||||||
|
|
||||||
// 1) Validate payload
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
role: z.enum(ROLE_OPTIONS),
|
role: z.enum(ROLE_OPTIONS),
|
||||||
|
|
@ -118,85 +129,162 @@ export async function POST(
|
||||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
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 {
|
try {
|
||||||
const pId = BigInt(portfolioId);
|
const pId = BigInt(portfolioId);
|
||||||
|
|
||||||
// 2) Find or create the user by email
|
const [portfolioRow] = await db
|
||||||
// Try to find existing user
|
.select({ name: portfolio.name })
|
||||||
let existing = await db
|
.from(portfolio)
|
||||||
.select({ id: user.id, firstName: user.firstName, email: user.email })
|
.where(eq(portfolio.id, pId))
|
||||||
.from(user)
|
|
||||||
.where(eq(user.email, body.email))
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
if (!portfolioRow) {
|
||||||
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(
|
return NextResponse.json(
|
||||||
{ error: "Failed to create portfolio user" },
|
{ error: "Portfolio not found" },
|
||||||
{ status: 500 }
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const collaborator = {
|
const [inviterRow] = await db
|
||||||
portfolioUserId: row.portfolioUserId?.toString() ?? null,
|
.select({ firstName: user.firstName, email: user.email })
|
||||||
userId: row.userId?.toString() ?? null,
|
.from(user)
|
||||||
role: row.role,
|
.where(eq(user.id, inviterUserId))
|
||||||
name: body.name ?? null,
|
.limit(1);
|
||||||
email: body.email,
|
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
|
const appOrigin =
|
||||||
// tell from .onConflictDoUpdate return, so just use 200 OK.
|
process.env.NEXTAUTH_URL ?? `https://${req.headers.get("host")}`;
|
||||||
return NextResponse.json({ user: collaborator }, { status: 200 });
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
console.error("POST /colloborators error:", err);
|
console.error("POST /colloborators error:", err);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,32 @@ export const portfolioUsers = pgTable("portfolioUsers", {
|
||||||
.notNull(),
|
.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[]] = [
|
export const PortfolioCapability: [string, ...string[]] = [
|
||||||
"approver",
|
"approver",
|
||||||
"contractor",
|
"contractor",
|
||||||
|
|
@ -161,6 +187,14 @@ export type Portfolio = InferModel<typeof portfolio, "select">;
|
||||||
export type NewPortfolio = InferModel<typeof portfolio, "insert">;
|
export type NewPortfolio = InferModel<typeof portfolio, "insert">;
|
||||||
export type PortfolioUsers = InferModel<typeof portfolioUsers, "select">;
|
export type PortfolioUsers = InferModel<typeof portfolioUsers, "select">;
|
||||||
export type NewPortfolioUsers = InferModel<typeof portfolioUsers, "insert">;
|
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<
|
export type PortfolioCapabilities = InferModel<
|
||||||
typeof portfolioCapabilities,
|
typeof portfolioCapabilities,
|
||||||
"select"
|
"select"
|
||||||
|
|
|
||||||
179
src/app/email_templates/portfolio_invitation.ts
Normal file
179
src/app/email_templates/portfolio_invitation.ts
Normal 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, "​.");
|
||||||
|
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;">
|
||||||
|
© ${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.
|
||||||
|
`;
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue