Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/app/actions/maintainer/invites.ts
Original file line number Diff line number Diff line change
@@ -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<T> = { 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<boolean> {
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<Result<void>> {
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<Result<void>> {
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<Result<InviteRow[]>> {
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<Result<void>> {
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';
Expand Down Expand Up @@ -157,3 +336,3 @@

return ok(undefined);
}
72 changes: 72 additions & 0 deletions src/app/invite/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -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');
}
34 changes: 34 additions & 0 deletions src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -685,3 +718,4 @@ export const organizationInvites = pgTable('organization_invites', {
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
});

21 changes: 21 additions & 0 deletions src/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,23 +133,43 @@
});
}

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 <noreply@mergeship.com>',
to,
subject: 'You have been invited to join MergeShip!',
html: `
<h2>You have been invited as a contributor!</h2>
<p>Click the link below to accept the invitation and link your account:</p>
<p><a href="${inviteUrl}">${inviteUrl}</a></p>
<br />
<p style="font-size: 12px; color: #666;">This invitation link will expire in 7 days.</p>
`,
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({

Check failure on line 156 in src/lib/email.ts

View workflow job for this annotation

GitHub Actions / check

':' expected.
to,
inviteLink,
inviterHandle,
organizationName,
}: {

Check failure on line 161 in src/lib/email.ts

View workflow job for this annotation

GitHub Actions / check

',' expected.
to: string;

Check failure on line 162 in src/lib/email.ts

View workflow job for this annotation

GitHub Actions / check

',' expected.
inviteLink: string;

Check failure on line 163 in src/lib/email.ts

View workflow job for this annotation

GitHub Actions / check

',' expected.
inviterHandle: string;

Check failure on line 164 in src/lib/email.ts

View workflow job for this annotation

GitHub Actions / check

',' expected.
organizationName: string;

Check failure on line 165 in src/lib/email.ts

View workflow job for this annotation

GitHub Actions / check

',' expected.
}) {

Check failure on line 166 in src/lib/email.ts

View workflow job for this annotation

GitHub Actions / check

',' expected.
if (!resend) {

Check failure on line 167 in src/lib/email.ts

View workflow job for this annotation

GitHub Actions / check

Identifier expected.
console.warn('RESEND_API_KEY missing, skipping email send');
return { skipped: true };
}

return resend.emails.send({

Check failure on line 172 in src/lib/email.ts

View workflow job for this annotation

GitHub Actions / check

',' expected.
from: process.env.EMAIL_FROM || 'onboarding@resend.dev',
to,
subject: `[MergeShip] ${inviterHandle} invited you to join ${organizationName}`,
Expand All @@ -159,5 +179,6 @@
<p>Click the link below to accept the invitation:</p>
<p><a href="${inviteLink}">${inviteLink}</a></p>
`,

});
}
Loading