diff --git a/src/app/actions/maintainer/invites.ts b/src/app/actions/maintainer/invites.ts index db87bfa4..73e99d06 100644 --- a/src/app/actions/maintainer/invites.ts +++ b/src/app/actions/maintainer/invites.ts @@ -1,5 +1,184 @@ 'use server'; + +import crypto from 'crypto'; +import { eq, and, gt } from 'drizzle-orm'; + +import { getDb } from '@/lib/db/client'; +import { invites, githubInstallations, githubInstallationUsers } from '@/lib/db/schema'; +import { requireMaintainer } from '@/lib/action-auth'; +import { sendInviteEmail } from '@/lib/email'; + +export type Result = { success: true; data: T } | { success: false; error: string }; + +export type InviteRow = typeof invites.$inferSelect; + +/** + * Verifies if the authenticated maintainer has access to the target installation + */ +async function verifyInstallationAccess(userId: string, installationId: number): Promise { + const db = getDb(); + const access = await db + .select() + .from(githubInstallationUsers) + .where( + and( + eq(githubInstallationUsers.installationId, installationId), + eq(githubInstallationUsers.userId, userId), + ), + ) + .limit(1); + + return access.length > 0; +} + +/** + * 1. Send Invite Action + */ +export async function sendInvite(installationId: number, email: string): Promise> { + try { + const authResult = await requireMaintainer(); + if (!authResult.ok) { + return { success: false, error: 'Unauthorized: Not a valid maintainer session' }; + } + + const user = authResult.data.user; + const db = getDb(); + + const hasAccess = await verifyInstallationAccess(user.id, installationId); + if (!hasAccess) return { success: false, error: 'Unauthorized installation access' }; + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) return { success: false, error: 'Invalid email format' }; + + const existing = await db + .select() + .from(invites) + .where( + and( + eq(invites.installationId, installationId), + eq(invites.email, email), + eq(invites.status, 'pending'), + gt(invites.expiresAt, new Date()), + ), + ) + .limit(1); + + if (existing.length > 0) { + return { success: false, error: 'A pending invite already exists for this email' }; + } + + const token = crypto.randomBytes(32).toString('hex'); + const sentAt = new Date(); + const expiresAt = new Date(sentAt.getTime() + 7 * 24 * 60 * 60 * 1000); + + await db.insert(invites).values({ + installationId, + invitedByUserId: user.id, + email, + token, + status: 'pending', + sentAt, + expiresAt, + }); + + const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/invite/${token}`; + + await sendInviteEmail({ to: email, inviteUrl }); + + return { success: true, data: undefined }; + } catch (error: any) { + return { success: false, error: error.message || 'Failed to send invite' }; + } +} + +/** + * 2. Resend Invite Action + */ +export async function resendInvite(inviteId: number): Promise> { + try { + const authResult = await requireMaintainer(); + if (!authResult.ok) return { success: false, error: 'Unauthorized' }; + const user = authResult.data.user; + const db = getDb(); + + const [invite] = await db.select().from(invites).where(eq(invites.id, inviteId)).limit(1); + if (!invite) return { success: false, error: 'Invite not found' }; + + const hasAccess = await verifyInstallationAccess(user.id, invite.installationId); + if (!hasAccess) return { success: false, error: 'Unauthorized installation access' }; + + const sentAt = new Date(); + const expiresAt = new Date(sentAt.getTime() + 7 * 24 * 60 * 60 * 1000); + + await db + .update(invites) + .set({ sentAt, expiresAt, status: 'pending' }) + .where(eq(invites.id, inviteId)); + + const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/invite/${invite.token}`; + + await sendInviteEmail({ to: invite.email, inviteUrl }); + + return { success: true, data: undefined }; + } catch (error: any) { + return { success: false, error: error.message || 'Failed to resend invite' }; + } +} + +/** + * 3. List Pending Invites Action + */ +export async function listPendingInvites(installationId: number): Promise> { + try { + const authResult = await requireMaintainer(); + if (!authResult.ok) return { success: false, error: 'Unauthorized' }; + const user = authResult.data.user; + const db = getDb(); + + const hasAccess = await verifyInstallationAccess(user.id, installationId); + if (!hasAccess) return { success: false, error: 'Unauthorized installation access' }; + + const rows = await db + .select() + .from(invites) + .where( + and( + eq(invites.installationId, installationId), + eq(invites.status, 'pending'), + gt(invites.expiresAt, new Date()), + ), + ); + + return { success: true, data: rows }; + } catch (error: any) { + return { success: false, error: error.message || 'Failed to retrieve pending invites' }; + } +} + +/** + * 4. Revoke Invite Action + */ +export async function revokeInvite(inviteId: number): Promise> { + try { + const authResult = await requireMaintainer(); + if (!authResult.ok) return { success: false, error: 'Unauthorized' }; + const user = authResult.data.user; + const db = getDb(); + + const [invite] = await db.select().from(invites).where(eq(invites.id, inviteId)).limit(1); + if (!invite) return { success: false, error: 'Invite not found' }; + + const hasAccess = await verifyInstallationAccess(user.id, invite.installationId); + if (!hasAccess) return { success: false, error: 'Unauthorized installation access' }; + + await db.delete(invites).where(eq(invites.id, inviteId)); + + return { success: true, data: undefined }; + } catch (error: any) { + return { success: false, error: error.message || 'Failed to revoke invite' }; + } + import { ok, err, type Result } from '@/lib/result'; import { requireMaintainer } from '@/lib/action-auth'; import { RATE_LIMIT_TIERS } from '@/lib/rate-limit'; diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx new file mode 100644 index 00000000..3073f9fb --- /dev/null +++ b/src/app/invite/[token]/page.tsx @@ -0,0 +1,72 @@ +import { notFound, redirect } from 'next/navigation'; +import { and, eq, gt } from 'drizzle-orm'; +import { getDb } from '@/lib/db/client'; +import { invites, githubInstallationUsers } from '@/lib/db/schema'; +import { requireUser } from '@/lib/action-auth'; + +interface InvitePageProps { + params: { + token: string; + }; +} + +export default async function InviteAcceptPage({ params }: InvitePageProps) { + const { token } = params; + const db = getDb(); + + // 1. Look up the invitation token + const [invite] = await db + .select() + .from(invites) + .where( + and( + eq(invites.token, token), + eq(invites.status, 'pending'), + gt(invites.expiresAt, new Date()), + ), + ) + .limit(1); + + // If token is invalid, modified, or expired, instantly bail out + if (!invite) { + notFound(); + } + + // 2. Check the user authentication block + const authResult = await requireUser(); + + // If user session doesn't exist, route them to login while preserving the return destination + if (!authResult.ok) { + redirect(`/login?next=/invite/${token}`); + } + + const user = authResult.data.user; + + // 3. Atomically update the database tracking rows inside a transaction + await db.transaction(async (tx) => { + // A. Link profiles.id to the target github_installation_users map table + // Adjust permission_level or source strings to match your enum constraints if required + await tx + .insert(githubInstallationUsers) + .values({ + installationId: invite.installationId, + userId: user.id, + permissionLevel: 'repo_maintain', // contributor join tier + source: 'manual_invite', + verifiedAt: new Date(), + }) + .onConflictDoNothing(); // Prevents duplicate primary key assignment crashes + + // B. Mark the invitation token row as accepted + await tx + .update(invites) + .set({ + status: 'accepted', + acceptedAt: new Date(), + }) + .where(eq(invites.id, invite.id)); + }); + + // 4. Send them straight into their new command center layout + redirect('/maintainer/contributors'); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index d970bfa5..e0e82d2c 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -644,6 +644,39 @@ export const userChallengeProgress = pgTable( }), ); +// ---------- invites (structured invite system) ---------- + +export const invites = pgTable( + 'invites', + { + id: bigserial('id', { mode: 'number' }).primaryKey(), + installationId: bigint('installation_id', { mode: 'number' }) + .notNull() + .references(() => githubInstallations.id, { onDelete: 'cascade' }), + invitedByUserId: uuid('invited_by_user_id') + .notNull() + .references(() => profiles.id), + email: text('email').notNull(), + token: text('token').notNull().unique(), + status: text('status', { enum: ['pending', 'accepted', 'expired'] }) + .notNull() + .default('pending'), + sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(), + acceptedAt: timestamp('accepted_at', { withTimezone: true }), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + }, + (t) => ({ + // Ensures a unique pending invite per email per organization/installation + uniqInstallEmail: uniqueIndex('invites_installation_email_unique').on( + t.installationId, + t.email, + ), + installationIdx: index('invites_installation_idx').on(t.installationId), + tokenIdx: index('invites_token_idx').on(t.token), + }), +); + + // ---------- maintainer audit logs ---------- export const maintainerAuditLogs = pgTable( @@ -685,3 +718,4 @@ export const organizationInvites = pgTable('organization_invites', { expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), acceptedAt: timestamp('accepted_at', { withTimezone: true }), }); + diff --git a/src/lib/email.ts b/src/lib/email.ts index ff09e064..7ba5f1a8 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -133,6 +133,26 @@ export async function sendWeeklyDigestEmail({ }); } +export async function sendInviteEmail({ to, inviteUrl }: { to: string; inviteUrl: string }) { + if (!resend) { + console.warn('Resend email client not configured. Invite URL:', inviteUrl); + return; + } + + await resend.emails.send({ + from: 'MergeShip ', + to, + subject: 'You have been invited to join MergeShip!', + html: ` +

You have been invited as a contributor!

+

Click the link below to accept the invitation and link your account:

+

${inviteUrl}

+
+

This invitation link will expire in 7 days.

+ `, + text: `You have been invited as a contributor!\n\nJoin the project by opening this link: ${inviteUrl}\n\nThis invitation link will expire in 7 days.`, + + export async function sendOrganizationInviteEmail({ to, inviteLink, @@ -159,5 +179,6 @@ export async function sendOrganizationInviteEmail({

Click the link below to accept the invitation:

${inviteLink}

`, + }); }