"use server" import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" import { organizationInvites, organizationMembers, organizations, users, type NewOrganizationInvite, } from "@/db/schema" import { getCurrentUser } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { isDemoUser } from "@/lib/demo" import { eq, and, desc } from "drizzle-orm" import { revalidatePath } from "next/cache" import { cookies } from "next/headers" // unambiguous charset — no 0/O/1/I/l const CODE_CHARS = "23456789abcdefghjkmnpqrstuvwxyz" function generateInviteCode(orgSlug: string): string { const prefix = orgSlug.replace(/[^a-z0-9]/g, "").slice(0, 3) const suffix = Array.from( { length: 6 }, () => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)] ).join("") return `${prefix}-${suffix}` } // --- createInvite --- export async function createInvite( role: string, maxUses?: number, expiresAt?: string ): Promise<{ success: boolean; error?: string; data?: { code: string; url: string } }> { try { const currentUser = await getCurrentUser() if (!currentUser) return { success: false, error: "Unauthorized" } if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" } requirePermission(currentUser, "organization", "create") if (!currentUser.organizationId) { return { success: false, error: "No active organization" } } const { env } = await getCloudflareContext() if (!env?.DB) return { success: false, error: "Database not available" } const db = getDb(env.DB) const org = await db .select({ slug: organizations.slug }) .from(organizations) .where(eq(organizations.id, currentUser.organizationId)) .get() if (!org) return { success: false, error: "Organization not found" } const code = generateInviteCode(org.slug) const now = new Date().toISOString() const invite: NewOrganizationInvite = { id: crypto.randomUUID(), organizationId: currentUser.organizationId, code, role, maxUses: maxUses ?? null, useCount: 0, expiresAt: expiresAt ?? null, createdBy: currentUser.id, isActive: true, createdAt: now, } await db.insert(organizationInvites).values(invite).run() revalidatePath("/dashboard/settings") return { success: true, data: { code, url: `/join/${code}`, }, } } catch (error) { console.error("Error creating invite:", error) return { success: false, error: error instanceof Error ? error.message : "Unknown error", } } } // --- getOrgInvites --- export async function getOrgInvites(): Promise<{ success: boolean error?: string data?: ReadonlyArray<{ readonly id: string readonly code: string readonly role: string readonly maxUses: number | null readonly useCount: number readonly expiresAt: string | null readonly isActive: boolean readonly createdAt: string readonly createdByName: string | null }> }> { try { const currentUser = await getCurrentUser() if (!currentUser) return { success: false, error: "Unauthorized" } requirePermission(currentUser, "organization", "read") if (!currentUser.organizationId) { return { success: false, error: "No active organization" } } const { env } = await getCloudflareContext() if (!env?.DB) return { success: false, error: "Database not available" } const db = getDb(env.DB) const invites = await db .select({ id: organizationInvites.id, code: organizationInvites.code, role: organizationInvites.role, maxUses: organizationInvites.maxUses, useCount: organizationInvites.useCount, expiresAt: organizationInvites.expiresAt, isActive: organizationInvites.isActive, createdAt: organizationInvites.createdAt, createdByName: users.displayName, }) .from(organizationInvites) .leftJoin(users, eq(organizationInvites.createdBy, users.id)) .where(eq(organizationInvites.organizationId, currentUser.organizationId)) .orderBy(desc(organizationInvites.createdAt)) return { success: true, data: invites } } catch (error) { console.error("Error fetching org invites:", error) return { success: false, error: error instanceof Error ? error.message : "Unknown error", } } } // --- revokeInvite --- export async function revokeInvite( inviteId: string ): Promise<{ success: boolean; error?: string }> { try { const currentUser = await getCurrentUser() if (!currentUser) return { success: false, error: "Unauthorized" } if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" } requirePermission(currentUser, "organization", "update") if (!currentUser.organizationId) { return { success: false, error: "No active organization" } } const { env } = await getCloudflareContext() if (!env?.DB) return { success: false, error: "Database not available" } const db = getDb(env.DB) // verify invite belongs to this org before revoking const invite = await db .select({ id: organizationInvites.id }) .from(organizationInvites) .where( and( eq(organizationInvites.id, inviteId), eq(organizationInvites.organizationId, currentUser.organizationId) ) ) .get() if (!invite) return { success: false, error: "Invite not found" } await db .update(organizationInvites) .set({ isActive: false }) .where(eq(organizationInvites.id, inviteId)) .run() revalidatePath("/dashboard/settings") return { success: true } } catch (error) { console.error("Error revoking invite:", error) return { success: false, error: error instanceof Error ? error.message : "Unknown error", } } } // --- getInviteByCode (public — no auth) --- export async function getInviteByCode(code: string): Promise<{ success: boolean error?: string data?: { readonly organizationName: string readonly role: string } }> { const INVALID = "This invite link is invalid or has expired" try { const { env } = await getCloudflareContext() if (!env?.DB) return { success: false, error: INVALID } const db = getDb(env.DB) const row = await db .select({ id: organizationInvites.id, role: organizationInvites.role, maxUses: organizationInvites.maxUses, useCount: organizationInvites.useCount, expiresAt: organizationInvites.expiresAt, isActive: organizationInvites.isActive, organizationName: organizations.name, }) .from(organizationInvites) .innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id)) .where(eq(organizationInvites.code, code)) .get() if (!row || !row.isActive) return { success: false, error: INVALID } if (row.expiresAt && new Date(row.expiresAt) < new Date()) { return { success: false, error: INVALID } } if (row.maxUses !== null && row.useCount >= row.maxUses) { return { success: false, error: INVALID } } return { success: true, data: { organizationName: row.organizationName, role: row.role } } } catch (error) { console.error("Error looking up invite:", error) return { success: false, error: INVALID } } } // --- acceptInvite --- export async function acceptInvite(code: string): Promise<{ success: boolean error?: string data?: { organizationId: string; organizationName: string } }> { const INVALID = "This invite link is invalid or has expired" try { const currentUser = await getCurrentUser() if (!currentUser) return { success: false, error: "Unauthorized" } if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" } const { env } = await getCloudflareContext() if (!env?.DB) return { success: false, error: "Database not available" } const db = getDb(env.DB) const invite = await db .select({ id: organizationInvites.id, organizationId: organizationInvites.organizationId, role: organizationInvites.role, maxUses: organizationInvites.maxUses, useCount: organizationInvites.useCount, expiresAt: organizationInvites.expiresAt, isActive: organizationInvites.isActive, organizationName: organizations.name, }) .from(organizationInvites) .innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id)) .where(eq(organizationInvites.code, code)) .get() if (!invite || !invite.isActive) return { success: false, error: INVALID } if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) { return { success: false, error: INVALID } } if (invite.maxUses !== null && invite.useCount >= invite.maxUses) { return { success: false, error: INVALID } } // check user is not already a member const existing = await db .select({ id: organizationMembers.id }) .from(organizationMembers) .where( and( eq(organizationMembers.organizationId, invite.organizationId), eq(organizationMembers.userId, currentUser.id) ) ) .get() if (existing) { return { success: false, error: "You are already a member of this organization" } } const now = new Date().toISOString() await db .insert(organizationMembers) .values({ id: crypto.randomUUID(), organizationId: invite.organizationId, userId: currentUser.id, role: invite.role, joinedAt: now, }) .run() const newUseCount = invite.useCount + 1 const exhausted = invite.maxUses !== null && newUseCount >= invite.maxUses await db .update(organizationInvites) .set({ useCount: newUseCount, ...(exhausted ? { isActive: false } : {}), }) .where(eq(organizationInvites.id, invite.id)) .run() const cookieStore = await cookies() cookieStore.set("compass-active-org", invite.organizationId, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 365, }) revalidatePath("/dashboard") return { success: true, data: { organizationId: invite.organizationId, organizationName: invite.organizationName, }, } } catch (error) { console.error("Error accepting invite:", error) return { success: false, error: error instanceof Error ? error.message : "Unknown error", } } }