From 1d5cfa942956ef6f35d2fa5f87c5f63f6c69ae97 Mon Sep 17 00:00:00 2001 From: prasiddhi-105 Date: Wed, 8 Jul 2026 06:33:55 +0530 Subject: [PATCH] feat(maintainer/contributors): implement structured invite system and sidebar panel --- src/app/actions/maintainer/invites.ts | 180 ++++++++++++++++++++++++++ src/app/invite/[token]/page.tsx | 72 +++++++++++ src/lib/db/schema.ts | 31 +++++ src/lib/email.ts | 20 +++ 4 files changed, 303 insertions(+) create mode 100644 src/app/actions/maintainer/invites.ts create mode 100644 src/app/invite/[token]/page.tsx diff --git a/src/app/actions/maintainer/invites.ts b/src/app/actions/maintainer/invites.ts new file mode 100644 index 00000000..0cf1e243 --- /dev/null +++ b/src/app/actions/maintainer/invites.ts @@ -0,0 +1,180 @@ +'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' }; + } +} 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 115b8c6e..af8cc36d 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -602,3 +602,34 @@ export const userChallengeProgress = pgTable( pk: primaryKey({ columns: [t.userId, t.date] }), }), ); +// ---------- 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), + }), +); diff --git a/src/lib/email.ts b/src/lib/email.ts index 9f37c005..3e562d3b 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -132,3 +132,23 @@ export async function sendWeeklyDigestEmail({ text: `Your Weekly Progress Digest\n\nHello ${githubHandle}, here's what you achieved this week on MergeShip!\n\nProgress:\n- XP Gained: ${xpGained} XP\n- Current Level: Level ${currentLevel}\n- Progress to Next Level: ${xpToNextLevel} XP needed\n\nActivity:\n- Issues Completed: ${issuesCompleted}\n- PRs Merged: ${prsMerged}\n- Reviews Performed: ${reviewsPerformed}\n\n${recommendations.length > 0 ? `Recommended for you:\n${recommendations.map((r) => `- ${r.title} (+${r.xpReward} XP): ${r.url}`).join('\n')}\n\n` : ''}View your dashboard: ${process.env.NEXT_PUBLIC_APP_URL || 'https://mergeship.com'}\n\nYou can unsubscribe from these emails by updating your Profile Settings.\n`, }); } +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.`, + }); +}