diff --git a/apps/api/src/auth/session.service.ts b/apps/api/src/auth/session.service.ts index 89e1bbb4..edfa16f1 100644 --- a/apps/api/src/auth/session.service.ts +++ b/apps/api/src/auth/session.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { randomBytes } from 'crypto'; import Redis from 'ioredis'; import { PrismaService } from '../database/prisma.service'; -import { User } from '@repo/db'; +import type { Prisma, User } from '@repo/db'; const SESSION_PREFIX = 'session:'; const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days @@ -24,12 +24,16 @@ export class SessionService { /** * Creates a new session in both DB and Redis */ - async createSession(userId: string): Promise { + async createSession( + userId: string, + tx?: Prisma.TransactionClient, + ): Promise { const sessionId = this.generateSessionId(); const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000); + const prisma = tx ?? this.prisma; // Store in database (source of truth) - await this.prisma.session.create({ + await prisma.session.create({ data: { id: sessionId, userId, @@ -37,16 +41,53 @@ export class SessionService { }, }); + if (tx) { + return sessionId; + } + // Cache in Redis with TTL - const sessionData: SessionData = { userId, expiresAt }; + await this.cacheSessionData(sessionId, { userId, expiresAt }); + + this.logger.log(`Session created for user ${userId}`); + return sessionId; + } + + /** + * Caches a committed database session in Redis. + */ + async cacheSession(sessionId: string): Promise { + const session = await this.prisma.session.findUnique({ + where: { id: sessionId }, + select: { userId: true, expiresAt: true }, + }); + + if (!session || session.expiresAt < new Date()) { + return; + } + + await this.cacheSessionData(sessionId, session); + } + + /** + * Writes session data to Redis using the remaining database TTL. + */ + private async cacheSessionData( + sessionId: string, + sessionData: SessionData, + ): Promise { + const ttlSeconds = Math.floor( + (sessionData.expiresAt.getTime() - Date.now()) / 1000, + ); + + if (ttlSeconds <= 0) { + return; + } + await this.redis.setex( `${SESSION_PREFIX}${sessionId}`, - SESSION_TTL_SECONDS, + ttlSeconds, JSON.stringify(sessionData), ); - - this.logger.log(`Session created for user ${userId}`); - return sessionId; } /** diff --git a/apps/api/src/members/members.controller.ts b/apps/api/src/members/members.controller.ts index b91ec6b9..bcbcc81f 100644 --- a/apps/api/src/members/members.controller.ts +++ b/apps/api/src/members/members.controller.ts @@ -1,18 +1,127 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Res, +} from '@nestjs/common'; +import type { Response } from 'express'; import { MembersService } from './members.service'; -import { Roles, CurrentUser } from '../auth/decorators'; +import { Roles, CurrentUser, Public } from '../auth/decorators'; +import { SESSION_COOKIE_NAME } from '../auth/guards/auth.guard'; import { ZodValidationPipe } from '../common/pipes'; import type { User } from '@repo/db'; import { + memberCreateRequestSchema, + memberInvitationAcceptRequestSchema, + memberInviteRequestSchema, memberListQuerySchema, + memberUpdateRequestSchema, + type MemberActionResponse, + type MemberCreateRequest, + type MemberInvitationAcceptRequest, + type MemberInvitationAcceptResponse, + type MemberInvitationActionResponse, + type MemberInvitationListResponse, + type MemberInviteRequest, type MemberListQuery, type MemberListResponse, + type MemberUpdateRequest, } from '@repo/contracts'; +const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + @Controller('members') export class MembersController { constructor(private readonly membersService: MembersService) {} + /** + * Lists pending member invitations visible to the current admin. + */ + @Get('invitations') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async findInvitations( + @Query(new ZodValidationPipe(memberListQuerySchema)) + query: MemberListQuery, + @CurrentUser() user: User, + ): Promise { + return this.membersService.findInvitations(query, user); + } + + /** + * Accepts a public member invitation and sets the session cookie. + */ + @Post('invitations/accept') + @Public() + @HttpCode(HttpStatus.OK) + async acceptInvitation( + @Body( + new ZodValidationPipe( + memberInvitationAcceptRequestSchema, + ), + ) + body: MemberInvitationAcceptRequest, + @Res({ passthrough: true }) response: Response, + ): Promise { + const { sessionId, user } = await this.membersService.acceptInvitation( + body.token, + ); + + response.cookie(SESSION_COOKIE_NAME, sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_MS, + path: '/', + }); + + return { user }; + } + + /** + * Sends a new member invitation for the current organization scope. + */ + @Post('invitations') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async invite( + @Body(new ZodValidationPipe(memberInviteRequestSchema)) + body: MemberInviteRequest, + @CurrentUser() user: User, + ): Promise { + return this.membersService.invite(body, user); + } + + /** + * Issues a fresh token and email for an existing pending invitation. + */ + @Post('invitations/:id/resend') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async resendInvitation( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: User, + ): Promise { + return this.membersService.resendInvitation(id, user); + } + + /** + * Revokes a pending member invitation in the current organization scope. + */ + @Delete('invitations/:id') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async revokeInvitation( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: User, + ): Promise { + return this.membersService.revokeInvitation(id, user); + } + @Get() @Roles('SUPER_ADMIN', 'ORG_ADMIN') async findAll( @@ -22,4 +131,55 @@ export class MembersController { ): Promise { return this.membersService.findAll(query, user); } + + /** + * Creates a Coordly member without creating an auth user. + */ + @Post() + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async create( + @Body(new ZodValidationPipe(memberCreateRequestSchema)) + body: MemberCreateRequest, + @CurrentUser() user: User, + ): Promise { + return this.membersService.create(body, user); + } + + /** + * Finds one Coordly member in the current organization scope. + */ + @Get(':id') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: User, + ): Promise { + return this.membersService.findOne(id, user); + } + + /** + * Updates a Coordly member in the current organization scope. + */ + @Patch(':id') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(memberUpdateRequestSchema)) + body: MemberUpdateRequest, + @CurrentUser() user: User, + ): Promise { + return this.membersService.update(id, body, user); + } + + /** + * Deletes a Coordly member in the current organization scope. + */ + @Delete(':id') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: User, + ): Promise { + return this.membersService.remove(id, user); + } } diff --git a/apps/api/src/members/members.module.ts b/apps/api/src/members/members.module.ts index 56518462..05efa8c8 100644 --- a/apps/api/src/members/members.module.ts +++ b/apps/api/src/members/members.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { MembersService } from './members.service'; import { MembersController } from './members.controller'; +import { AuthModule } from '../auth/auth.module'; +import { MailModule } from '../mail/mail.module'; @Module({ + imports: [AuthModule, MailModule], providers: [MembersService], controllers: [MembersController], }) diff --git a/apps/api/src/members/members.service.ts b/apps/api/src/members/members.service.ts index beda6109..84d75ab9 100644 --- a/apps/api/src/members/members.service.ts +++ b/apps/api/src/members/members.service.ts @@ -1,14 +1,243 @@ -import { Injectable, Logger } from '@nestjs/common'; -import type { User } from '@repo/db'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import type { Queue } from 'bullmq'; +import { createHash, randomBytes } from 'crypto'; +import type { MemberRole, User } from '@repo/db'; import { PrismaService } from '../database/prisma.service'; import { resolveOrganizationScope } from '../common/organization-scope'; -import type { MemberListQuery, MemberListResponse } from '@repo/contracts'; +import { SessionService } from '../auth/session.service'; +import { MAIL_JOBS, MAIL_QUEUE } from '../mail/mail.constants'; +import type { + Member, + MemberActionResponse, + MemberCreateRequest, + MemberInvitation, + MemberInvitationAcceptResponse, + MemberInvitationActionResponse, + MemberInviteRequest, + MemberListQuery, + MemberInvitationListResponse, + MemberListResponse, + MemberUpdateRequest, + UserResponse, +} from '@repo/contracts'; + +const INVITATION_EXPIRY_DAYS = 7; @Injectable() export class MembersService { private readonly logger = new Logger(MembersService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly sessionService: SessionService, + @InjectQueue(MAIL_QUEUE) private readonly mailQueue: Queue, + ) {} + + /** + * Hashes a raw invitation token for safe database storage. + */ + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + /** + * Maps a Prisma member record into the shared contract response shape. + */ + private mapMember(member: { + id: string; + username: string; + role: MemberRole; + organizationId: string; + userId?: string | null; + user?: { email: string } | null; + }): Member { + return { + id: member.id, + username: member.username, + role: member.role, + organizationId: member.organizationId, + userId: member.userId ?? null, + userEmail: member.user?.email ?? null, + }; + } + + /** + * Maps a Prisma invitation record into the shared contract response shape. + */ + private mapInvitation(invitation: { + id: string; + email: string; + username: string; + role: MemberRole; + organizationId: string; + invitedById: string; + expiresAt: Date; + acceptedAt: Date | null; + revokedAt: Date | null; + createdAt: Date; + }): MemberInvitation { + return { + id: invitation.id, + email: invitation.email, + username: invitation.username, + role: invitation.role, + organizationId: invitation.organizationId, + invitedById: invitation.invitedById, + expiresAt: invitation.expiresAt.toISOString(), + acceptedAt: invitation.acceptedAt?.toISOString() ?? null, + revokedAt: invitation.revokedAt?.toISOString() ?? null, + createdAt: invitation.createdAt.toISOString(), + }; + } + + /** + * Maps an auth user and optional Coordly member role into a user response. + */ + private mapUserResponse( + user: Pick< + User, + 'id' | 'email' | 'name' | 'role' | 'organizationId' | 'isConfirmed' + >, + memberRole: MemberRole | null, + ): UserResponse { + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + organizationId: user.organizationId, + isConfirmed: user.isConfirmed, + memberRole, + }; + } + + /** + * Resolves an organization scope and rejects requests without one. + */ + private resolveRequiredOrganizationScope( + user: User, + requestedOrganizationId?: string, + ): string { + const organizationId = resolveOrganizationScope( + user, + requestedOrganizationId, + ); + + if (!organizationId) { + throw new BadRequestException('organizationId is required'); + } + + return organizationId; + } + + /** + * Loads a member constrained to the caller's organization scope. + */ + private async getScopedMember(id: string, user: User) { + const organizationId = resolveOrganizationScope(user); + const member = await this.prisma.member.findFirst({ + where: { + id, + ...(organizationId ? { organizationId } : {}), + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + if (!member) { + throw new NotFoundException('Member not found'); + } + + return member; + } + + /** + * Ensures a username is unused within an organization. + */ + private async assertUsernameAvailable( + organizationId: string, + username: string, + excludingMemberId?: string, + ): Promise { + const existing = await this.prisma.member.findFirst({ + where: { + organizationId, + username, + ...(excludingMemberId ? { id: { not: excludingMemberId } } : {}), + }, + select: { id: true }, + }); + + if (existing) { + throw new ConflictException('A member with this username already exists'); + } + } + + /** + * Loads an organization or raises the shared not-found response. + */ + private async getOrganization(organizationId: string) { + const organization = await this.prisma.organization.findFirst({ + where: { id: organizationId }, + select: { id: true, name: true }, + }); + + if (!organization) { + throw new NotFoundException('Organization not found'); + } + + return organization; + } + + /** + * Rotates an invitation token, extends expiry, and queues the email. + */ + private async issueInvitationToken( + invitation: { + id: string; + email: string; + organization: { name: string }; + }, + inviterName: string | null, + ) { + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date( + Date.now() + INVITATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000, + ); + + const updated = await this.prisma.memberInvitation.update({ + where: { id: invitation.id }, + data: { + tokenHash: this.hashToken(token), + expiresAt, + }, + }); + + const appUrl = process.env.APP_URL ?? 'http://localhost:3000'; + const invitationLink = `${appUrl}/invite/accept?token=${token}`; + + await this.mailQueue.add(MAIL_JOBS.SEND_INVITATION, { + email: invitation.email, + inviterName, + organizationName: invitation.organization.name, + invitationLink, + }); + + return updated; + } async findAll( query: MemberListQuery, @@ -26,11 +255,12 @@ export class MembersService { skip, take: limit, orderBy: { createdAt: 'desc' }, - select: { - id: true, - username: true, - role: true, - organizationId: true, + include: { + user: { + select: { + email: true, + }, + }, }, }), this.prisma.member.count({ where }), @@ -38,6 +268,420 @@ export class MembersService { this.logger.log(`Listed ${members.length} members (total: ${total})`); - return { members, total }; + return { members: members.map((member) => this.mapMember(member)), total }; + } + + /** + * Finds a single Coordly member after scope validation. + */ + async findOne(id: string, user: User): Promise { + const member = await this.getScopedMember(id, user); + return { member: this.mapMember(member) }; + } + + /** + * Creates an unlinked Coordly member in the resolved organization scope. + */ + async create( + body: MemberCreateRequest, + user: User, + ): Promise { + const organizationId = this.resolveRequiredOrganizationScope( + user, + body.organizationId, + ); + await this.getOrganization(organizationId); + + await this.assertUsernameAvailable(organizationId, body.username); + + const member = await this.prisma.member.create({ + data: { + username: body.username, + role: body.role, + organizationId, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + this.logger.log(`Created member ${member.id} in org ${organizationId}`); + return { member: this.mapMember(member) }; + } + + /** + * Updates an existing Coordly member after scope and username checks. + */ + async update( + id: string, + body: MemberUpdateRequest, + user: User, + ): Promise { + const existing = await this.getScopedMember(id, user); + + if (body.username && body.username !== existing.username) { + await this.assertUsernameAvailable( + existing.organizationId, + body.username, + existing.id, + ); + } + + const member = await this.prisma.member.update({ + where: { id: existing.id }, + data: { + ...(body.username ? { username: body.username } : {}), + ...(body.role ? { role: body.role } : {}), + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + this.logger.log(`Updated member ${member.id}`); + return { member: this.mapMember(member) }; + } + + /** + * Deletes an existing Coordly member after scope validation. + */ + async remove(id: string, user: User): Promise { + const existing = await this.getScopedMember(id, user); + const member = await this.prisma.member.delete({ + where: { id: existing.id }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + this.logger.log(`Deleted member ${member.id}`); + return { member: this.mapMember(member) }; + } + + /** + * Lists active pending invitations visible to the current admin. + */ + async findInvitations( + query: MemberListQuery, + user: User, + ): Promise { + const { page = 1, limit = 20, organizationId: requestedOrgId } = query; + const skip = (page - 1) * limit; + const organizationId = resolveOrganizationScope(user, requestedOrgId); + const where = { + ...(organizationId ? { organizationId } : {}), + acceptedAt: null, + revokedAt: null, + }; + + const [invitations, total] = await Promise.all([ + this.prisma.memberInvitation.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.memberInvitation.count({ where }), + ]); + + return { + invitations: invitations.map((invitation) => + this.mapInvitation(invitation), + ), + total, + }; + } + + /** + * Creates and emails a pending member invitation. + */ + async invite( + body: MemberInviteRequest, + user: User, + ): Promise { + const organizationId = this.resolveRequiredOrganizationScope( + user, + body.organizationId, + ); + + const organization = await this.getOrganization(organizationId); + + await this.assertUsernameAvailable(organizationId, body.username); + + const existingUser = await this.prisma.user.findUnique({ + where: { email: body.email }, + select: { + id: true, + role: true, + organizationId: true, + }, + }); + + if (existingUser?.role === 'SUPER_ADMIN') { + throw new ConflictException('Super admins cannot be invited as members'); + } + + if ( + existingUser?.organizationId && + existingUser.organizationId !== organizationId + ) { + throw new ConflictException( + 'This user already belongs to another organization', + ); + } + + if (existingUser) { + const existingMembership = await this.prisma.member.findFirst({ + where: { userId: existingUser.id }, + select: { id: true }, + }); + + if (existingMembership) { + throw new ConflictException('This user is already linked to a member'); + } + } + + const existingInvitation = await this.prisma.memberInvitation.findFirst({ + where: { + organizationId, + acceptedAt: null, + revokedAt: null, + expiresAt: { gt: new Date() }, + OR: [{ email: body.email }, { username: body.username }], + }, + select: { id: true }, + }); + + if (existingInvitation) { + throw new ConflictException( + 'An active invitation already exists for this email or username', + ); + } + + const invitation = await this.prisma.memberInvitation.create({ + data: { + email: body.email, + username: body.username, + role: body.role, + organizationId, + invitedById: user.id, + tokenHash: this.hashToken(randomBytes(32).toString('hex')), + expiresAt: new Date(), + }, + include: { + organization: { + select: { name: true }, + }, + }, + }); + + const issuedInvitation = await this.issueInvitationToken( + { ...invitation, organization }, + user.name, + ); + + this.logger.log(`Queued member invitation ${invitation.id}`); + return { invitation: this.mapInvitation(issuedInvitation) }; + } + + /** + * Reissues a pending invitation with a fresh token and expiry. + */ + async resendInvitation( + id: string, + user: User, + ): Promise { + const organizationId = resolveOrganizationScope(user); + const invitation = await this.prisma.memberInvitation.findFirst({ + where: { + id, + ...(organizationId ? { organizationId } : {}), + acceptedAt: null, + revokedAt: null, + }, + include: { + organization: { + select: { name: true }, + }, + }, + }); + + if (!invitation) { + throw new NotFoundException('Invitation not found'); + } + + const updated = await this.issueInvitationToken(invitation, user.name); + + this.logger.log(`Resent member invitation ${invitation.id}`); + return { invitation: this.mapInvitation(updated) }; + } + + /** + * Marks a pending invitation as revoked. + */ + async revokeInvitation( + id: string, + user: User, + ): Promise { + const organizationId = resolveOrganizationScope(user); + const invitation = await this.prisma.memberInvitation.findFirst({ + where: { + id, + ...(organizationId ? { organizationId } : {}), + acceptedAt: null, + revokedAt: null, + }, + }); + + if (!invitation) { + throw new NotFoundException('Invitation not found'); + } + + const updated = await this.prisma.memberInvitation.update({ + where: { id: invitation.id }, + data: { revokedAt: new Date() }, + }); + + this.logger.log(`Revoked member invitation ${invitation.id}`); + return { invitation: this.mapInvitation(updated) }; + } + + /** + * Accepts an invitation by creating the user, member, and session atomically. + */ + async acceptInvitation( + token: string, + ): Promise<{ sessionId: string } & MemberInvitationAcceptResponse> { + const invitation = await this.prisma.memberInvitation.findUnique({ + where: { tokenHash: this.hashToken(token) }, + }); + + if (!invitation) { + throw new NotFoundException('Invalid or expired invitation'); + } + + if (invitation.acceptedAt) { + throw new BadRequestException( + 'This invitation has already been accepted', + ); + } + + if (invitation.revokedAt || invitation.expiresAt < new Date()) { + throw new NotFoundException('Invalid or expired invitation'); + } + + const result = await this.prisma.$transaction(async (tx) => { + const existingUser = await tx.user.findUnique({ + where: { email: invitation.email }, + }); + + if (existingUser?.role === 'SUPER_ADMIN') { + throw new ForbiddenException( + 'Super admins cannot accept member invitations', + ); + } + + if ( + existingUser?.organizationId && + existingUser.organizationId !== invitation.organizationId + ) { + throw new ConflictException( + 'This user already belongs to another organization', + ); + } + + if (existingUser) { + const existingMembership = await tx.member.findFirst({ + where: { userId: existingUser.id }, + select: { id: true }, + }); + + if (existingMembership) { + throw new ConflictException( + 'This user is already linked to a member', + ); + } + } + + const existingUsername = await tx.member.findFirst({ + where: { + organizationId: invitation.organizationId, + username: invitation.username, + }, + select: { id: true }, + }); + + if (existingUsername) { + throw new ConflictException( + 'A member with this username already exists', + ); + } + + const userRecord = existingUser + ? await tx.user.update({ + where: { id: existingUser.id }, + data: { + organizationId: invitation.organizationId, + isConfirmed: true, + }, + }) + : await tx.user.create({ + data: { + email: invitation.email, + name: invitation.username, + role: 'MEMBER', + organizationId: invitation.organizationId, + isConfirmed: true, + }, + }); + + const member = await tx.member.create({ + data: { + username: invitation.username, + role: invitation.role, + organizationId: invitation.organizationId, + userId: userRecord.id, + }, + }); + + await tx.memberInvitation.update({ + where: { id: invitation.id }, + data: { acceptedAt: new Date() }, + }); + + const sessionId = await this.sessionService.createSession( + userRecord.id, + tx, + ); + + return { sessionId, userRecord, memberRole: member.role }; + }); + + await this.sessionService.cacheSession(result.sessionId).catch((error) => { + this.logger.warn( + `Failed to cache accepted invitation session ${result.sessionId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + + this.logger.log(`Accepted member invitation ${invitation.id}`); + + return { + sessionId: result.sessionId, + user: this.mapUserResponse(result.userRecord, result.memberRole), + }; } } diff --git a/apps/web/app/(authenticated)/members/page.tsx b/apps/web/app/(authenticated)/members/page.tsx index fd88ba74..af884395 100644 --- a/apps/web/app/(authenticated)/members/page.tsx +++ b/apps/web/app/(authenticated)/members/page.tsx @@ -1,7 +1,35 @@ 'use client'; +import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { + Edit, + Loader2, + MailPlus, + Plus, + RefreshCcw, + ShieldX, + Trash2, + Users, + XCircle, +} from 'lucide-react'; import { useUser } from '@/hooks/use-auth'; -import { useMembers } from '@/hooks/use-members'; +import { useMembers, useMemberInvitations } from '@/hooks/use-members'; +import { useOrganizations } from '@/hooks/use-organizations'; +import { ApiError } from '@/lib/api'; +import { + memberCreateRequestSchema, + memberInviteRequestSchema, + memberUpdateRequestSchema, + type Member, + type MemberCreateRequest, + type MemberInvitation, + type MemberInviteRequest, + type MemberRole, + type MemberUpdateRequest, +} from '@repo/contracts'; import { Table, TableBody, @@ -10,19 +38,37 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; -import { Users, ShieldX } from 'lucide-react'; -const ROLE_COLORS: Record = { - ADMIN: 'bg-blue-100 text-blue-800', - PRESENTER: 'bg-purple-100 text-purple-800', +const ROLE_COLORS: Record = { + ADMIN: 'bg-primary-100 text-primary-base', + PRESENTER: 'bg-warning-light text-gray-900', }; -function RoleBadge({ role }: { role: string }) { +function RoleBadge({ role }: { role: MemberRole }) { return ( {role.charAt(0) + role.slice(1).toLowerCase()} @@ -35,8 +81,7 @@ function ForbiddenPage() {

Access Denied

- You don't have permission to access this page. Only Super Admins - and Organization Admins can view members. + Only super admins and organization admins can manage members.

); @@ -46,37 +91,146 @@ function LoadingSkeleton() { return (
-
- {[...Array(5)].map((_, i) => ( - - ))} -
+ +
); } function truncateId(id: string) { - return `${id.slice(0, 8)}…`; + return id.slice(0, 8); +} + +/** + * Formats an invitation expiration date for the members table. + */ +function formatDate(value: string) { + return new Intl.DateTimeFormat('en', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(value)); } function canAccessMembers(role: string | undefined) { return role === 'SUPER_ADMIN' || role === 'ORG_ADMIN'; } +/** + * Converts unknown mutation errors into user-facing messages. + */ +function errorMessage(error: unknown, fallback: string) { + return error instanceof ApiError ? error.message : fallback; +} + +/** + * Renders the organization selector used by super admin member forms. + */ +function OrganizationField({ + value, + onChange, + organizations, +}: { + value: string | undefined; + onChange: (value: string) => void; + organizations: + | Array<{ + id: string; + name: string; + }> + | undefined; +}) { + return ( +
+ + +
+ ); +} + export default function MembersPage() { const { user, isLoading: userLoading } = useUser(); const canAccess = canAccessMembers(user?.role); + const isSuperAdmin = user?.role === 'SUPER_ADMIN'; + const [createOpen, setCreateOpen] = useState(false); + const [inviteOpen, setInviteOpen] = useState(false); + const [editingMember, setEditingMember] = useState(null); + + const { organizations } = useOrganizations({ + enabled: canAccess && isSuperAdmin, + status: 'ACTIVE', + }); const { members, total, isLoading: membersLoading, - error, + error: membersError, + createMember, + updateMember, + deleteMember, } = useMembers({ enabled: canAccess, }); - const isSuperAdmin = user?.role === 'SUPER_ADMIN'; + const { + invitations, + total: invitationTotal, + isLoading: invitationsLoading, + error: invitationsError, + inviteMember, + resendInvitation, + revokeInvitation, + } = useMemberInvitations({ + enabled: canAccess, + }); + + const createForm = useForm({ + resolver: zodResolver(memberCreateRequestSchema), + defaultValues: { + username: '', + role: 'PRESENTER', + organizationId: undefined, + }, + }); + + const inviteForm = useForm({ + resolver: zodResolver(memberInviteRequestSchema), + defaultValues: { + email: '', + username: '', + role: 'PRESENTER', + organizationId: undefined, + }, + }); + + const editForm = useForm({ + resolver: zodResolver(memberUpdateRequestSchema), + defaultValues: { + username: '', + role: 'PRESENTER', + }, + }); + + useEffect(() => { + if (editingMember) { + editForm.reset({ + username: editingMember.username, + role: editingMember.role, + }); + } + }, [editForm, editingMember]); if (userLoading) { return ; @@ -86,25 +240,270 @@ export default function MembersPage() { return ; } + /** + * Submits a new unlinked Coordly member. + */ + const onCreate = async (body: MemberCreateRequest) => { + try { + await createMember(body); + toast.success('Member created'); + createForm.reset({ username: '', role: 'PRESENTER' }); + setCreateOpen(false); + } catch (error) { + toast.error(errorMessage(error, 'Failed to create member')); + } + }; + + /** + * Submits a new member invitation. + */ + const onInvite = async (body: MemberInviteRequest) => { + try { + await inviteMember(body); + toast.success('Invitation sent'); + inviteForm.reset({ email: '', username: '', role: 'PRESENTER' }); + setInviteOpen(false); + } catch (error) { + toast.error(errorMessage(error, 'Failed to send invitation')); + } + }; + + /** + * Submits updates for the selected member. + */ + const onEdit = async (body: MemberUpdateRequest) => { + if (!editingMember) return; + + try { + await updateMember(editingMember.id, body); + toast.success('Member updated'); + setEditingMember(null); + } catch (error) { + toast.error(errorMessage(error, 'Failed to update member')); + } + }; + + /** + * Confirms and deletes a member. + */ + const onDelete = async (member: Member) => { + if (!window.confirm(`Delete ${member.username}?`)) return; + + try { + await deleteMember(member.id); + toast.success('Member deleted'); + } catch (error) { + toast.error(errorMessage(error, 'Failed to delete member')); + } + }; + + /** + * Resends a pending invitation. + */ + const onResend = async (invitation: MemberInvitation) => { + try { + await resendInvitation(invitation.id); + toast.success('Invitation resent'); + } catch (error) { + toast.error(errorMessage(error, 'Failed to resend invitation')); + } + }; + + /** + * Confirms and revokes a pending invitation. + */ + const onRevoke = async (invitation: MemberInvitation) => { + if (!window.confirm(`Cancel invitation to ${invitation.email}?`)) return; + + try { + await revokeInvitation(invitation.id); + toast.success('Invitation canceled'); + } catch (error) { + toast.error(errorMessage(error, 'Failed to cancel invitation')); + } + }; + return (
-
-

Members

-

- {isSuperAdmin - ? 'Coordly domain members across all organizations' - : 'Members in your organization'} -

+
+
+

Members

+

+ {isSuperAdmin + ? 'Coordly members across active organizations' + : 'Members in your organization'} +

+
+
+ + + + + + + Create Member + + Add a Coordly member without creating a login account. + + +
+
+ + +
+ ( +
+ + +
+ )} + /> + {isSuperAdmin && ( + ( + + )} + /> + )} + + + + +
+
+ + + + + + + + Invite Member + + Send an email invitation and create the member on acceptance. + + +
+
+ + +
+
+ + +
+ ( +
+ + +
+ )} + /> + {isSuperAdmin && ( + ( + + )} + /> + )} + + + + +
+
+
- Members + Active Members {total !== undefined && ( - ({total} total) + ({total}) )} @@ -116,7 +515,7 @@ export default function MembersPage() { ))}
- ) : error ? ( + ) : membersError ? (
Failed to load members
@@ -130,8 +529,10 @@ export default function MembersPage() { ID Username + Email Role - {isSuperAdmin && Organization ID} + {isSuperAdmin && Organization} + Actions @@ -143,6 +544,9 @@ export default function MembersPage() { {member.username} + + {member.userEmail ?? 'No login'} + @@ -151,6 +555,26 @@ export default function MembersPage() { {truncateId(member.organizationId)} )} + +
+ + +
+
))}
@@ -158,6 +582,143 @@ export default function MembersPage() { )} + + + + + + Pending Invitations + {invitationTotal !== undefined && ( + + ({invitationTotal}) + + )} + + + + {invitationsLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : invitationsError ? ( +
+ Failed to load invitations +
+ ) : !invitations?.length ? ( +
+ No pending invitations +
+ ) : ( + + + + Email + Username + Role + Expires + {isSuperAdmin && Organization} + Actions + + + + {invitations.map((invitation) => ( + + + {invitation.email} + + {invitation.username} + + + + + {formatDate(invitation.expiresAt)} + + {isSuperAdmin && ( + + {truncateId(invitation.organizationId)} + + )} + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+ + { + if (!open) setEditingMember(null); + }} + > + + + Edit Member + + Update the member username or role. + + +
+
+ + +
+ ( +
+ + +
+ )} + /> + + + + +
+
); } diff --git a/apps/web/app/invite/accept/page.tsx b/apps/web/app/invite/accept/page.tsx new file mode 100644 index 00000000..188e4755 --- /dev/null +++ b/apps/web/app/invite/accept/page.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { Suspense, useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { CheckCircle2, Loader2, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; +import { useAuth } from '@/hooks/use-auth'; +import { ApiError } from '@/lib/api'; + +type AcceptStatus = 'loading' | 'success' | 'error'; + +/** + * Accepts a member invitation token from the URL and shows the result state. + */ +function AcceptInvitationContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { acceptMemberInvitation } = useAuth(); + const [status, setStatus] = useState('loading'); + const [errorMessage, setErrorMessage] = useState(''); + const hasAttemptedAccept = useRef(false); + + useEffect(() => { + if (hasAttemptedAccept.current) { + return; + } + + const token = searchParams.get('token'); + + if (!token) { + setStatus('error'); + setErrorMessage('Invalid or missing invitation token'); + return; + } + + hasAttemptedAccept.current = true; + + /** + * Sends the invitation token to the API and redirects after success. + */ + const accept = async () => { + try { + await acceptMemberInvitation({ token }); + setStatus('success'); + toast.success('Invitation accepted'); + setTimeout(() => { + router.replace('/dashboard'); + }, 1000); + } catch (error) { + setStatus('error'); + if (error instanceof ApiError) { + setErrorMessage(error.message); + toast.error(error.message); + } else { + setErrorMessage('Invitation could not be accepted.'); + toast.error('Invitation could not be accepted.'); + } + } + }; + + accept(); + }, [acceptMemberInvitation, router, searchParams]); + + return ( +
+
+ {status === 'loading' && ( + <> + +

+ Accepting invitation... +

+

+ Please wait while we set up your access. +

+ + )} + + {status === 'success' && ( + <> + +

+ Invitation accepted +

+

+ Redirecting to dashboard... +

+ + )} + + {status === 'error' && ( + <> + +

+ Invitation failed +

+

{errorMessage}

+ + Back to login + + + )} +
+
+ ); +} + +/** + * Renders the public invitation acceptance page with suspense for URL params. + */ +export default function AcceptInvitationPage() { + return ( + + + + } + > + + + ); +} diff --git a/apps/web/hooks/use-auth.ts b/apps/web/hooks/use-auth.ts index c97b0c6a..b2652e53 100644 --- a/apps/web/hooks/use-auth.ts +++ b/apps/web/hooks/use-auth.ts @@ -7,6 +7,8 @@ import { apiPost, ApiError } from '@/lib/api'; import type { MagicLinkRequest, MagicLinkVerifyRequest, + MemberInvitationAcceptRequest, + MemberInvitationAcceptResponse, UserResponse, } from '@repo/contracts'; @@ -23,7 +25,7 @@ interface UseUserReturn { } // Routes where we should NOT redirect on 401 -const AUTH_ROUTES = ['/login', '/auth']; +const AUTH_ROUTES = ['/login', '/auth', '/invite']; function isAuthRoute(pathname: string): boolean { return AUTH_ROUTES.some( @@ -80,6 +82,21 @@ export function useAuth() { [mutate], ); + /** + * Accepts a member invitation and refreshes the authenticated user. + */ + const acceptMemberInvitation = useCallback( + async (data: MemberInvitationAcceptRequest) => { + const result = await apiPost( + '/members/invitations/accept', + data, + ); + mutate(); + return result; + }, + [mutate], + ); + const logout = useCallback(async () => { await apiPost<{ success: boolean }>('/auth/logout'); mutate(); @@ -88,6 +105,7 @@ export function useAuth() { return { requestMagicLink, verifyMagicLink, + acceptMemberInvitation, logout, }; } diff --git a/apps/web/hooks/use-members.ts b/apps/web/hooks/use-members.ts index a4cdc2c8..e5fdb67b 100644 --- a/apps/web/hooks/use-members.ts +++ b/apps/web/hooks/use-members.ts @@ -1,7 +1,17 @@ 'use client'; -import useSWR, { type KeyedMutator } from 'swr'; -import type { MemberListResponse } from '@repo/contracts'; +import useSWR, { mutate } from 'swr'; +import { useCallback } from 'react'; +import { apiDelete, apiPatch, apiPost } from '@/lib/api'; +import type { + MemberActionResponse, + MemberCreateRequest, + MemberInvitationActionResponse, + MemberInvitationListResponse, + MemberInviteRequest, + MemberListResponse, + MemberUpdateRequest, +} from '@repo/contracts'; interface UseMembersOptions { enabled?: boolean; @@ -13,29 +23,177 @@ interface UseMembersReturn { total: number | undefined; isLoading: boolean; error: Error | undefined; - mutate: KeyedMutator; + createMember: (body: MemberCreateRequest) => Promise; + updateMember: ( + id: string, + body: MemberUpdateRequest, + ) => Promise; + deleteMember: (id: string) => Promise; + mutate: () => void; +} + +/** + * Builds a members API endpoint with an optional organization filter. + */ +function buildMembersEndpoint(base: string, organizationId?: string): string { + if (!organizationId) { + return base; + } + + const params = new URLSearchParams({ organizationId }); + return `${base}?${params.toString()}`; } export function useMembers(options: UseMembersOptions = {}): UseMembersReturn { const { enabled = true, organizationId } = options; + const endpoint = buildMembersEndpoint('/members', organizationId); - const params = new URLSearchParams(); - if (organizationId) params.set('organizationId', organizationId); - const query = params.toString(); - const endpoint = query ? `/members?${query}` : '/members'; + const { data, error, isLoading } = useSWR( + enabled ? endpoint : null, + ); - const { - data, - error, - isLoading, - mutate: swrMutate, - } = useSWR(enabled ? endpoint : null); + /** + * Revalidates all cached member and invitation lists. + */ + const invalidateMembers = useCallback(() => { + mutate((key) => typeof key === 'string' && key.startsWith('/members')); + }, []); + + /** + * Creates a member and refreshes member-related caches. + */ + const createMember = useCallback( + async (body: MemberCreateRequest) => { + const result = await apiPost('/members', body); + invalidateMembers(); + return result; + }, + [invalidateMembers], + ); + + /** + * Updates a member and refreshes member-related caches. + */ + const updateMember = useCallback( + async (id: string, body: MemberUpdateRequest) => { + const result = await apiPatch( + `/members/${id}`, + body, + ); + invalidateMembers(); + return result; + }, + [invalidateMembers], + ); + + /** + * Deletes a member and refreshes member-related caches. + */ + const deleteMember = useCallback( + async (id: string) => { + const result = await apiDelete(`/members/${id}`); + invalidateMembers(); + return result; + }, + [invalidateMembers], + ); return { members: data?.members, total: data?.total, isLoading, error, - mutate: swrMutate, + createMember, + updateMember, + deleteMember, + mutate: invalidateMembers, + }; +} + +interface UseMemberInvitationsReturn { + invitations: MemberInvitationListResponse['invitations'] | undefined; + total: number | undefined; + isLoading: boolean; + error: Error | undefined; + inviteMember: ( + body: MemberInviteRequest, + ) => Promise; + resendInvitation: (id: string) => Promise; + revokeInvitation: (id: string) => Promise; + mutate: () => void; +} + +/** + * Fetches pending invitations and exposes invitation mutation helpers. + */ +export function useMemberInvitations( + options: UseMembersOptions = {}, +): UseMemberInvitationsReturn { + const { enabled = true, organizationId } = options; + const endpoint = buildMembersEndpoint('/members/invitations', organizationId); + + const { data, error, isLoading } = useSWR( + enabled ? endpoint : null, + ); + + /** + * Revalidates all cached member and invitation lists. + */ + const invalidateInvitations = useCallback(() => { + mutate((key) => typeof key === 'string' && key.startsWith('/members')); + }, []); + + /** + * Sends an invitation and refreshes member-related caches. + */ + const inviteMember = useCallback( + async (body: MemberInviteRequest) => { + const result = await apiPost( + '/members/invitations', + body, + ); + invalidateInvitations(); + return result; + }, + [invalidateInvitations], + ); + + /** + * Resends an invitation and refreshes member-related caches. + */ + const resendInvitation = useCallback( + async (id: string) => { + const result = await apiPost( + `/members/invitations/${id}/resend`, + ); + invalidateInvitations(); + return result; + }, + [invalidateInvitations], + ); + + /** + * Revokes an invitation and refreshes member-related caches. + */ + const revokeInvitation = useCallback( + async (id: string) => { + const result = await apiDelete( + `/members/invitations/${id}`, + ); + invalidateInvitations(); + return result; + }, + [invalidateInvitations], + ); + + return { + invitations: data?.invitations, + total: data?.total, + isLoading, + error, + inviteMember, + resendInvitation, + revokeInvitation, + mutate: invalidateInvitations, }; } diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 0b2ca94b..e1f236b2 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -7,7 +7,13 @@ const SESSION_COOKIE_NAME = 'bootcamp_starter_session'; const authEntryRoutes = ['/login', '/auth/verify']; // Routes that do not require a session. -const publicRoutes = [...authEntryRoutes, '/browse', '/privacy', '/terms']; +const publicRoutes = [ + ...authEntryRoutes, + '/browse', + '/privacy', + '/terms', + '/invite/accept', +]; const DEFAULT_AUTHENTICATED_ROUTE = '/dashboard'; diff --git a/packages/contracts/src/members/index.ts b/packages/contracts/src/members/index.ts index ac721779..0cdb8648 100644 --- a/packages/contracts/src/members/index.ts +++ b/packages/contracts/src/members/index.ts @@ -2,3 +2,12 @@ export * from './member-role.schema'; export * from './member.response'; export * from './member-list.response'; export * from './member-list.query'; +export * from './member-create.request'; +export * from './member-update.request'; +export * from './member-action.response'; +export * from './member-invite.request'; +export * from './member-invitation.response'; +export * from './member-invitation-list.response'; +export * from './member-invitation-action.response'; +export * from './member-invitation-accept.request'; +export * from './member-invitation-accept.response'; diff --git a/packages/contracts/src/members/member-action.response.ts b/packages/contracts/src/members/member-action.response.ts new file mode 100644 index 00000000..8b8fc943 --- /dev/null +++ b/packages/contracts/src/members/member-action.response.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { memberSchema } from './member.response'; + +export const memberActionResponseSchema = z.object({ + member: memberSchema, +}); +export type MemberActionResponse = z.infer; diff --git a/packages/contracts/src/members/member-create.request.ts b/packages/contracts/src/members/member-create.request.ts new file mode 100644 index 00000000..21c1ed41 --- /dev/null +++ b/packages/contracts/src/members/member-create.request.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { memberRoleSchema } from './member-role.schema'; + +export const memberCreateRequestSchema = z.object({ + username: z.string().trim().min(1).max(120), + role: memberRoleSchema, + organizationId: z.uuid().optional(), +}); +export type MemberCreateRequest = z.infer; diff --git a/packages/contracts/src/members/member-invitation-accept.request.ts b/packages/contracts/src/members/member-invitation-accept.request.ts new file mode 100644 index 00000000..4fefef89 --- /dev/null +++ b/packages/contracts/src/members/member-invitation-accept.request.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const memberInvitationAcceptRequestSchema = z.object({ + token: z.string().min(1), +}); +export type MemberInvitationAcceptRequest = z.infer< + typeof memberInvitationAcceptRequestSchema +>; diff --git a/packages/contracts/src/members/member-invitation-accept.response.ts b/packages/contracts/src/members/member-invitation-accept.response.ts new file mode 100644 index 00000000..b05b4627 --- /dev/null +++ b/packages/contracts/src/members/member-invitation-accept.response.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { userResponseSchema } from '../users/user.response'; + +export const memberInvitationAcceptResponseSchema = z.object({ + user: userResponseSchema, +}); +export type MemberInvitationAcceptResponse = z.infer< + typeof memberInvitationAcceptResponseSchema +>; diff --git a/packages/contracts/src/members/member-invitation-action.response.ts b/packages/contracts/src/members/member-invitation-action.response.ts new file mode 100644 index 00000000..c6b732a5 --- /dev/null +++ b/packages/contracts/src/members/member-invitation-action.response.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { memberInvitationSchema } from './member-invitation.response'; + +export const memberInvitationActionResponseSchema = z.object({ + invitation: memberInvitationSchema, +}); +export type MemberInvitationActionResponse = z.infer< + typeof memberInvitationActionResponseSchema +>; diff --git a/packages/contracts/src/members/member-invitation-list.response.ts b/packages/contracts/src/members/member-invitation-list.response.ts new file mode 100644 index 00000000..295d7e22 --- /dev/null +++ b/packages/contracts/src/members/member-invitation-list.response.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { memberInvitationSchema } from './member-invitation.response'; + +export const memberInvitationListResponseSchema = z.object({ + invitations: z.array(memberInvitationSchema), + total: z.number(), +}); +export type MemberInvitationListResponse = z.infer< + typeof memberInvitationListResponseSchema +>; diff --git a/packages/contracts/src/members/member-invitation.response.ts b/packages/contracts/src/members/member-invitation.response.ts new file mode 100644 index 00000000..88158bff --- /dev/null +++ b/packages/contracts/src/members/member-invitation.response.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { memberRoleSchema } from './member-role.schema'; + +export const memberInvitationSchema = z.object({ + id: z.uuid(), + email: z.email(), + username: z.string(), + role: memberRoleSchema, + organizationId: z.uuid(), + invitedById: z.uuid(), + expiresAt: z.iso.datetime(), + acceptedAt: z.iso.datetime().nullable(), + revokedAt: z.iso.datetime().nullable(), + createdAt: z.iso.datetime(), +}); +export type MemberInvitation = z.infer; diff --git a/packages/contracts/src/members/member-invite.request.ts b/packages/contracts/src/members/member-invite.request.ts new file mode 100644 index 00000000..5bf8867b --- /dev/null +++ b/packages/contracts/src/members/member-invite.request.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { memberRoleSchema } from './member-role.schema'; + +export const memberInviteRequestSchema = z.object({ + email: z.email().transform((value) => value.toLowerCase()), + username: z.string().trim().min(1).max(120), + role: memberRoleSchema, + organizationId: z.uuid().optional(), +}); +export type MemberInviteRequest = z.infer; diff --git a/packages/contracts/src/members/member-update.request.ts b/packages/contracts/src/members/member-update.request.ts new file mode 100644 index 00000000..702b4ebc --- /dev/null +++ b/packages/contracts/src/members/member-update.request.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { memberRoleSchema } from './member-role.schema'; + +export const memberUpdateRequestSchema = z + .object({ + username: z.string().trim().min(1).max(120).optional(), + role: memberRoleSchema.optional(), + }) + .refine((value) => value.username !== undefined || value.role !== undefined, { + message: 'At least one field must be provided', + }); +export type MemberUpdateRequest = z.infer; diff --git a/packages/contracts/src/members/member.response.ts b/packages/contracts/src/members/member.response.ts index 63d42580..dde7a4b1 100644 --- a/packages/contracts/src/members/member.response.ts +++ b/packages/contracts/src/members/member.response.ts @@ -6,5 +6,7 @@ export const memberSchema = z.object({ username: z.string(), role: memberRoleSchema, organizationId: z.uuid(), + userId: z.uuid().nullable().optional(), + userEmail: z.email().nullable().optional(), }); export type Member = z.infer; diff --git a/packages/database/prisma/migrations/20260717183454_add_member_invitations/migration.sql b/packages/database/prisma/migrations/20260717183454_add_member_invitations/migration.sql new file mode 100644 index 00000000..90090cc9 --- /dev/null +++ b/packages/database/prisma/migrations/20260717183454_add_member_invitations/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "private"."MemberInvitation" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "username" TEXT NOT NULL, + "role" "MemberRole" NOT NULL DEFAULT 'PRESENTER', + "organizationId" TEXT NOT NULL, + "invitedById" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "acceptedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MemberInvitation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "MemberInvitation_tokenHash_key" ON "private"."MemberInvitation"("tokenHash"); + +-- CreateIndex +CREATE INDEX "MemberInvitation_organizationId_idx" ON "private"."MemberInvitation"("organizationId"); + +-- CreateIndex +CREATE INDEX "MemberInvitation_email_idx" ON "private"."MemberInvitation"("email"); + +-- CreateIndex +CREATE INDEX "MemberInvitation_expiresAt_idx" ON "private"."MemberInvitation"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "private"."MemberInvitation" ADD CONSTRAINT "MemberInvitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "private"."MemberInvitation" ADD CONSTRAINT "MemberInvitation_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql b/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql new file mode 100644 index 00000000..c5a55c87 --- /dev/null +++ b/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY "MemberInvitation_invitedById_idx" ON "private"."MemberInvitation"("invitedById"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 3033dccd..b056d4b7 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -79,12 +79,13 @@ model User { sessions Session[] magicLinks MagicLink[] profile UserProfile? - organization Organization? @relation("OrganizationMembers", fields: [organizationId], references: [id], onDelete: SetNull) - createdOrganization Organization? @relation("OrganizationCreator") - approvedOrganizations Organization[] @relation("OrganizationApprover") + organization Organization? @relation("OrganizationMembers", fields: [organizationId], references: [id], onDelete: SetNull) + createdOrganization Organization? @relation("OrganizationCreator") + approvedOrganizations Organization[] @relation("OrganizationApprover") eventAttendees EventAttendee[] orgMemberships Member[] announcements Announcement[] + sentMemberInvitations MemberInvitation[] @@index([organizationId]) @@schema("public") @@ -153,13 +154,14 @@ model Organization { updatedAt DateTime @updatedAt approvedAt DateTime? // When super admin approved - createdBy User @relation("OrganizationCreator", fields: [createdById], references: [id]) - approvedBy User? @relation("OrganizationApprover", fields: [approvedById], references: [id]) - users User[] @relation("OrganizationMembers") - members Member[] - events Event[] - eventAttendees EventAttendee[] - announcements Announcement[] + createdBy User @relation("OrganizationCreator", fields: [createdById], references: [id]) + approvedBy User? @relation("OrganizationApprover", fields: [approvedById], references: [id]) + users User[] @relation("OrganizationMembers") + members Member[] + events Event[] + eventAttendees EventAttendee[] + announcements Announcement[] + memberInvitations MemberInvitation[] @@index([approvedById]) @@index([status]) @@ -185,6 +187,30 @@ model Member { @@schema("public") } +model MemberInvitation { + id String @id @default(uuid()) + email String + username String + role MemberRole @default(PRESENTER) + organizationId String + invitedById String + tokenHash String @unique + expiresAt DateTime + acceptedAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + invitedBy User @relation(fields: [invitedById], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([invitedById]) + @@index([email]) + @@index([expiresAt]) + @@schema("private") +} + model Event { id String @id @default(uuid()) eventName String