mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +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,
|
||||
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,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`;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
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