Реалізація запрошення користувачів по email/посиланню

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація запрошення користувачів по email/посиланню
Проста
від 1 робочого дня до 3 робочих днів
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Система приглашень користувачів по 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 робочі дні.