Система приглашень користувачів по email
Invitation flow: користувач запрошує колегу → той отримує листа зі посиланням → реєструється та автоматично додається в потрібну організацію. Без запрошення — не потрапити (для закритих B2B продуктів).
Схема даних
model Invitation {
id String @id @default(cuid())
email String
organizationId String
role OrganizationRole @default(MEMBER)
token String @unique @default(cuid())
invitedById String
status InvitationStatus @default(PENDING)
expiresAt DateTime
createdAt DateTime @default(now())
acceptedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id])
invitedBy User @relation(fields: [invitedById], references: [id])
}
enum InvitationStatus {
PENDING
ACCEPTED
EXPIRED
REVOKED
}
enum OrganizationRole {
OWNER
ADMIN
MEMBER
VIEWER
}
Server Action: відправка запрошення
// app/organization/invite/actions.ts
'use server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { sendEmail } from '@/lib/email';
import { z } from 'zod';
const inviteSchema = z.object({
email: z.string().email(),
role: z.enum(['ADMIN', 'MEMBER', 'VIEWER']).default('MEMBER'),
});
export async function inviteUser(
organizationId: string,
formData: FormData
) {
const session = await auth();
if (!session) throw new Error('Unauthorized');
// Перевіряємо права на запрошення
const membership = await db.organizationMember.findFirst({
where: {
organizationId,
userId: session.user.id,
role: { in: ['OWNER', 'ADMIN'] },
}
});
if (!membership) throw new Error('Insufficient permissions');
const { email, role } = inviteSchema.parse(Object.fromEntries(formData));
// Перевіряємо: вже учасник?
const existingMember = await db.user.findFirst({
where: {
email,
organizations: { some: { organizationId } }
}
});
if (existingMember) throw new Error('User already a member');
// Перевіряємо: вже є активне запрошення?
const existingInvite = await db.invitation.findFirst({
where: {
email,
organizationId,
status: 'PENDING',
expiresAt: { gt: new Date() },
}
});
if (existingInvite) throw new Error('Invitation already sent');
// Створюємо запрошення
const invitation = await db.invitation.create({
data: {
email,
organizationId,
role,
invitedById: session.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 днів
},
include: {
organization: true,
invitedBy: true,
}
});
// Відправляємо листа
await sendEmail({
to: email,
subject: `Вас запрошують в ${invitation.organization.name}`,
template: 'invitation',
variables: {
organizationName: invitation.organization.name,
inviterName: invitation.invitedBy.name!,
inviteUrl: `${process.env.APP_URL}/invite/${invitation.token}`,
expiresAt: invitation.expiresAt.toLocaleDateString('uk-UA'),
role: ROLE_LABELS[role],
},
});
return { success: true, invitationId: invitation.id };
}
Сторінка прийняття запрошення
// app/invite/[token]/page.tsx
import { db } from '@/lib/db';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function InvitationPage({
params,
}: {
params: { token: string };
}) {
const invitation = await db.invitation.findUnique({
where: { token: params.token },
include: { organization: true, invitedBy: true },
});
if (!invitation || invitation.status !== 'PENDING') {
return <InvitationExpired />;
}
if (invitation.expiresAt < new Date()) {
await db.invitation.update({
where: { id: invitation.id },
data: { status: 'EXPIRED' }
});
return <InvitationExpired />;
}
return (
<InvitationAcceptPage
invitation={invitation}
token={params.token}
/>
);
}
// Прийняття запрошення
export async function acceptInvitation(token: string) {
'use server';
const session = await auth();
const invitation = await db.invitation.findUnique({
where: { token, status: 'PENDING' },
});
if (!invitation || invitation.expiresAt < new Date()) {
throw new Error('Invalid or expired invitation');
}
// Якщо користувач не авторизован — редиректим на реєстрацію з збереженням токена
if (!session) {
redirect(`/register?invite=${token}&email=${encodeURIComponent(invitation.email)}`);
}
// Перевіряємо відповідність email
if (session.user.email !== invitation.email) {
throw new Error('This invitation was sent to a different email');
}
await db.$transaction([
// Додаємо в організацію
db.organizationMember.create({
data: {
organizationId: invitation.organizationId,
userId: session.user.id,
role: invitation.role,
}
}),
// Оновлюємо статус
db.invitation.update({
where: { id: invitation.id },
data: {
status: 'ACCEPTED',
acceptedAt: new Date(),
}
}),
]);
redirect(`/org/${invitation.organizationId}/dashboard`);
}
Email шаблон (React Email)
// emails/invitation.tsx
import {
Body, Button, Container, Head, Html,
Preview, Section, Text,
} from '@react-email/components';
export function InvitationEmail({
organizationName,
inviterName,
inviteUrl,
expiresAt,
role,
}: InvitationEmailProps) {
return (
<Html>
<Head />
<Preview>{inviterName} запрошує вас в {organizationName}</Preview>
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f5f5f5' }}>
<Container style={{ maxWidth: '560px', margin: '40px auto' }}>
<Text>Привіт!</Text>
<Text>
<strong>{inviterName}</strong> запрошує вас присоединиться до{' '}
<strong>{organizationName}</strong> в ролі <strong>{role}</strong>.
</Text>
<Section style={{ textAlign: 'center', margin: '32px 0' }}>
<Button
href={inviteUrl}
style={{
backgroundColor: '#6366f1',
color: '#fff',
padding: '12px 32px',
borderRadius: '8px',
}}
>
Прийняти запрошення
</Button>
</Section>
<Text style={{ color: '#666', fontSize: '14px' }}>
Посилання дійсне до {expiresAt}. Якщо ви не очікували це запрошення — просто проігноруйте листа.
</Text>
</Container>
</Body>
</Html>
);
}
Налаштування invitation flow з email через React Email/Resend та обробкою edge cases — 2–3 робочі дні.







