"use server"; import { createClient, createAdminClient } from "@/lib/supabase/server"; import { auth, currentUser } from "@clerk/nextjs/server"; import { reportUsage } from "@/lib/supabase/stripe-actions"; import { nanoid } from "nanoid"; import type { Employee, EmployeeWithRelations, Department, CreateEmployeeInput, UpdateEmployeeInput, EmployeeStatus, EmploymentType, PayFrequency, } from "./employees-types"; // ── Helpers ────────────────────────────────────────────────── async function getOrganisationId(): Promise { const { userId } = await auth(); if (!userId) throw new Error("Unauthenticated"); const supabase = await createAdminClient(); const { data: member, error } = await supabase .from("organisation_members") .select("organisation_id") .eq("user_id", userId) .single(); if (error || !member) throw new Error("No organisation found"); return member.organisation_id; } /** * Syncs the current Clerk user to the profiles and employees table. */ async function syncUserToEmployee(organisationId: string, role: string = "employee"): Promise { const user = await currentUser(); if (!user) return; const supabaseAdmin = await createAdminClient(); const userId = user.id; const email = user.emailAddresses[0]?.emailAddress || ""; const firstName = user.firstName || ""; const lastName = user.lastName || ""; const fullName = `${firstName} ${lastName}`.trim() || email.split("@")[0]; // 1. Upsert profile await supabaseAdmin.from("profiles").upsert({ id: userId, email, full_name: fullName, }, { onConflict: "id" }); // 2. Check if employee record already exists for this user in this org const { data: existingEmp } = await supabaseAdmin .from("employees") .select("id") .eq("organisation_id", organisationId) .eq("user_id", userId) .maybeSingle(); if (!existingEmp) { // Check if there's an employee record with this email but no user_id (manually added before join) const { data: emailEmp } = await supabaseAdmin .from("employees") .select("id") .eq("organisation_id", organisationId) .eq("email", email) .is("user_id", null) .maybeSingle(); if (emailEmp) { // Link the existing record await supabaseAdmin .from("employees") .update({ user_id: userId, first_name: firstName || email.split("@")[0], last_name: lastName || "" }) .eq("id", emailEmp.id); } else { // Create new employee record await supabaseAdmin.from("employees").insert({ organisation_id: organisationId, user_id: userId, first_name: firstName || email.split("@")[0], last_name: lastName || "", email: email, status: "active", employment_type: "full_time", pay_frequency: "monthly", }); } } } // ── Fetch functions ────────────────────────────────────────── export async function fetchEmployees(): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); // Fetch employees and join with organisation_members to get roles for sorting const { data, error } = await supabase .from("employees") .select( ` *, department:departments(id, name), manager:employees!manager_id(first_name, last_name) `, ) .eq("organisation_id", orgId); if (error) throw error; // Fetch member roles to sort by role const { data: members } = await supabase .from("organisation_members") .select("user_id, role") .eq("organisation_id", orgId); const rolePriority: Record = { owner: 1, admin: 2, accountant: 3, hr_manager: 4, employee: 5, }; const memberRoles = new Map(); (members || []).forEach((m) => memberRoles.set(m.user_id, m.role)); const sortedData = (data || []).map((emp: any) => ({ ...emp, role: emp.user_id ? memberRoles.get(emp.user_id) || "employee" : "employee", })).sort((a, b) => { const priorityA = rolePriority[a.role] || 6; const priorityB = rolePriority[b.role] || 6; if (priorityA !== priorityB) return priorityA - priorityB; return (a.first_name || "").localeCompare(b.first_name || ""); }); return sortedData.map((emp: any) => ({ ...emp, department: emp.department ? ({ id: emp.department.id, name: emp.department.name, organisation_id: orgId, created_at: "", } as Department) : null, manager: emp.manager ? { first_name: emp.manager.first_name, last_name: emp.manager.last_name, } : null, })) as EmployeeWithRelations[]; } // ── Join links ─────────────────────────────────────────────── export type ExpiryType = "1_day" | "3_days" | "1_week" | "1_month" | "3_months" | "1_year" | "forever"; export async function createJoinLink( expiryType: ExpiryType, usageLimit: number | null, ): Promise<{ success: boolean; token?: string; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const orgId = await getOrganisationId(); const supabase = await createAdminClient(); let expiresAt: string | null = null; const now = new Date(); switch (expiryType) { case "1_day": expiresAt = new Date(now.getTime() + 86400000).toISOString(); break; case "3_days": expiresAt = new Date(now.getTime() + 86400000 * 3).toISOString(); break; case "1_week": expiresAt = new Date(now.getTime() + 86400000 * 7).toISOString(); break; case "1_month": expiresAt = new Date(now.getTime() + 86400000 * 30).toISOString(); break; case "3_months": expiresAt = new Date(now.getTime() + 86400000 * 90).toISOString(); break; case "1_year": expiresAt = new Date(now.getTime() + 86400000 * 365).toISOString(); break; case "forever": expiresAt = null; break; } const token = nanoid(12); const { error } = await supabase .from("pending_invitations") .insert({ organisation_id: orgId, token, invited_by: userId, expires_at: expiresAt, usage_limit: usageLimit, usage_count: 0, status: "pending", }); if (error) return { success: false, error: error.message }; return { success: true, token }; } export async function fetchInvitations(): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data, error } = await supabase .from("pending_invitations") .select("*") .eq("organisation_id", orgId) .order("created_at", { ascending: false }); if (error) throw error; return data || []; } export async function revokeInvitation(invitationId: string): Promise<{ success: boolean; error?: string }> { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { error } = await supabase .from("pending_invitations") .update({ status: "revoked" }) .eq("id", invitationId) .eq("organisation_id", orgId); if (error) return { success: false, error: error.message }; return { success: true }; } // ── Ownership transfer ──────────────────────────────────────── export async function transferOwnership(newOwnerUserId: string): Promise<{ success: boolean; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const orgId = await getOrganisationId(); const supabase = await createAdminClient(); // 1. Verify current user is owner const { data: currentOwner, error: ownerError } = await supabase .from("organisation_members") .select("role") .eq("organisation_id", orgId) .eq("user_id", userId) .single(); if (ownerError || currentOwner?.role !== "owner") { return { success: false, error: "Only the owner can transfer ownership" }; } // 2. Perform transfer (in transaction-like manner) // Use admin client for role management const supabaseAdmin = await createAdminClient(); // Update current owner to admin const { error: demoteError } = await supabaseAdmin .from("organisation_members") .update({ role: "admin" }) .eq("organisation_id", orgId) .eq("user_id", userId); if (demoteError) return { success: false, error: demoteError.message }; // Update new owner const { error: promoteError } = await supabaseAdmin .from("organisation_members") .update({ role: "owner" }) .eq("organisation_id", orgId) .eq("user_id", newOwnerUserId); if (promoteError) { // Rollback demotion if promotion fails await supabaseAdmin .from("organisation_members") .update({ role: "owner" }) .eq("organisation_id", orgId) .eq("user_id", userId); return { success: false, error: promoteError.message }; } return { success: true }; } export async function fetchEmployeeDetail( employeeId: string, ): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data, error } = await supabase .from("employees") .select( ` *, department:departments(id, name), manager:employees!manager_id(first_name, last_name) `, ) .eq("id", employeeId) .eq("organisation_id", orgId) .single(); if (error || !data) return null; return { ...data, department: data.department ? ({ id: data.department.id, name: data.department.name, organisation_id: orgId, created_at: "", } as Department) : null, manager: data.manager ? { first_name: data.manager.first_name, last_name: data.manager.last_name, } : null, } as EmployeeWithRelations; } export async function fetchDepartments(): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data, error } = await supabase .from("departments") .select("*") .eq("organisation_id", orgId) .order("name", { ascending: true }); if (error) throw error; return (data || []) as Department[]; } export async function fetchManagerDropdown(): Promise< { id: string; first_name: string; last_name: string }[] > { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data, error } = await supabase .from("employees") .select("id, first_name, last_name") .eq("organisation_id", orgId) .eq("status", "active") .order("first_name", { ascending: true }); if (error) throw error; return (data || []) as any[]; } // ── Server actions (writes) ────────────────────────────────── export async function createEmployee( input: CreateEmployeeInput, ): Promise<{ success: boolean; employeeId?: string; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data, error } = await supabase .from("employees") .insert({ organisation_id: orgId, first_name: input.first_name, last_name: input.last_name, email: input.email, phone: input.phone || null, date_of_birth: input.date_of_birth || null, address: input.address || null, job_title: input.job_title || null, department_id: input.department_id || null, employment_type: input.employment_type, start_date: input.start_date || null, salary: input.salary || null, hourly_rate: input.hourly_rate || null, pay_frequency: input.pay_frequency, manager_id: input.manager_id || null, }) .select("id") .single(); if (error || !data) { return { success: false, error: error?.message || "Failed to create employee", }; } // Report usage for PAYG billing (non-blocking) reportUsage("employees", 1).catch(() => {}); return { success: true, employeeId: data.id }; } export async function updateEmployee( employeeId: string, input: UpdateEmployeeInput, ): Promise<{ success: boolean; error?: string }> { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const updateData: Record = {}; if (input.first_name !== undefined) updateData.first_name = input.first_name; if (input.last_name !== undefined) updateData.last_name = input.last_name; if (input.email !== undefined) updateData.email = input.email; if (input.phone !== undefined) updateData.phone = input.phone; if (input.date_of_birth !== undefined) updateData.date_of_birth = input.date_of_birth; if (input.address !== undefined) updateData.address = input.address; if (input.job_title !== undefined) updateData.job_title = input.job_title; if (input.department_id !== undefined) updateData.department_id = input.department_id; if (input.employment_type !== undefined) updateData.employment_type = input.employment_type; if (input.start_date !== undefined) updateData.start_date = input.start_date; if (input.end_date !== undefined) updateData.end_date = input.end_date; if (input.salary !== undefined) updateData.salary = input.salary; if (input.hourly_rate !== undefined) updateData.hourly_rate = input.hourly_rate; if (input.pay_frequency !== undefined) updateData.pay_frequency = input.pay_frequency; if (input.status !== undefined) updateData.status = input.status; if (input.manager_id !== undefined) updateData.manager_id = input.manager_id; const { error } = await supabase .from("employees") .update(updateData) .eq("id", employeeId) .eq("organisation_id", orgId); if (error) return { success: false, error: error.message }; return { success: true }; } export async function updateEmployeeStatus( employeeId: string, status: EmployeeStatus, ): Promise<{ success: boolean; error?: string }> { return updateEmployee(employeeId, { status }); } // ── Onboarding actions ─────────────────────────────────────── export async function setupOrganisation( name: string, slug: string, country: string, ): Promise<{ success: boolean; organisationId?: string; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const supabaseAdmin = await createAdminClient(); // 1. Insert/Sync profile and employee record // We'll do this after org creation to have the orgId // 2. Insert organisation const { data: org, error: orgError } = await supabaseAdmin .from("organisations") .insert({ name, slug }) .select("id") .single(); if (orgError) { if (orgError.code === "23505" && orgError.details?.includes("slug")) { return { success: false, error: `The workspace name "${slug}" is already taken. Please choose a different slug.`, }; } return { success: false, error: orgError.message || "Failed to create organisation", }; } if (!org) { return { success: false, error: "Failed to create organisation" }; } // 3. Insert member as owner const { error: memberError } = await supabaseAdmin .from("organisation_members") .insert({ organisation_id: org.id, user_id: userId, role: "owner" }); if (memberError) { return { success: false, error: memberError.message }; } // 4. Sync user to employees as owner await syncUserToEmployee(org.id, "owner"); // 5. Seed default custom roles (Finance, HR, Operations) const { seedDefaultRoles } = await import("@/lib/supabase/rbac-actions"); await seedDefaultRoles(org.id); return { success: true, organisationId: org.id }; } export async function createInvitations( emails: string[], organisationId: string, ): Promise<{ success: boolean; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const supabase = await createAdminClient(); const invitations = emails.map((email) => ({ organisation_id: organisationId, email, invited_by: userId, })); if (invitations.length > 0) { const { error } = await supabase .from("pending_invitations") .insert(invitations); if (error) return { success: false, error: error.message }; } return { success: true }; } // ── Join by token ──────────────────────────────────────────── export async function fetchInvitationByToken( token: string, ): Promise<{ orgName: string; email: string } | null> { const supabase = await createAdminClient(); const { data, error } = await supabase .from("pending_invitations") .select( ` email, organisations(name), expires_at, status `, ) .eq("token", token) .single(); if (error || !data) return null; if (data.status === "expired") return null; if (new Date(data.expires_at) < new Date()) return null; return { orgName: (data.organisations as any)?.name || "Unknown", email: data.email, }; } export async function acceptInvitation( token: string, ): Promise<{ success: boolean; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const supabaseAdmin = await createAdminClient(); // Fetch the invitation const { data: invitation, error: invError } = await supabaseAdmin .from("pending_invitations") .select("id, organisation_id, email, status, expires_at, usage_limit, usage_count") .eq("token", token) .single(); if (invError || !invitation) return { success: false, error: "Invitation not found" }; if (invitation.status === "revoked") return { success: false, error: "Invitation has been revoked" }; if (invitation.expires_at && new Date(invitation.expires_at) < new Date()) return { success: false, error: "Invitation expired" }; if (invitation.usage_limit !== null && invitation.usage_count >= invitation.usage_limit) return { success: false, error: "Invitation usage limit reached" }; // Add user to organisation (using admin to bypass RLS) const { error: memberError } = await supabaseAdmin .from("organisation_members") .insert({ organisation_id: invitation.organisation_id, user_id: userId, role: "employee", }); if (memberError && memberError.code !== "23505") { // 23505 = unique violation (already a member), which is ok return { success: false, error: memberError.message }; } // Sync user to employees await syncUserToEmployee(invitation.organisation_id, "employee"); // Update usage count and status if it was a single-use email invite const newUsageCount = (invitation.usage_count || 0) + 1; const isNowAccepted = invitation.email && newUsageCount >= (invitation.usage_limit || 1); await supabaseAdmin .from("pending_invitations") .update({ usage_count: newUsageCount, status: isNowAccepted ? "accepted" : "pending" }) .eq("id", invitation.id); return { success: true }; } export async function processInvitationToken( token: string, ): Promise<{ success: boolean; redirectUrl?: string; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const supabaseAdmin = await createAdminClient(); const { data: invitation, error: invError } = await supabaseAdmin .from("pending_invitations") .select("id, organisation_id, email, status, expires_at, usage_limit, usage_count") .eq("token", token) .single(); if (invError || !invitation) return { success: false, error: "Invitation not found" }; if (invitation.status === "revoked") return { success: false, error: "Invitation has been revoked" }; if (invitation.expires_at && new Date(invitation.expires_at) < new Date()) return { success: false, error: "Expired" }; if (invitation.usage_limit !== null && invitation.usage_count >= invitation.usage_limit) return { success: false, error: "Usage limit reached" }; const { error: memberError } = await supabaseAdmin .from("organisation_members") .insert({ organisation_id: invitation.organisation_id, user_id: userId, role: "employee", }); if (memberError && memberError.code !== "23505") { return { success: false, error: memberError.message }; } // Sync user to employees await syncUserToEmployee(invitation.organisation_id, "employee"); // Update usage count const newUsageCount = (invitation.usage_count || 0) + 1; const isNowAccepted = invitation.email && newUsageCount >= (invitation.usage_limit || 1); await supabaseAdmin .from("pending_invitations") .update({ usage_count: newUsageCount, status: isNowAccepted ? "accepted" : "pending" }) .eq("id", invitation.id); return { success: true, redirectUrl: "/dashboard" }; } /** * Check if the current user's email has any pending invitations. * Returns the most recent one if found. */ export async function checkPendingInvitation(): Promise<{ orgName: string; orgId: string; invitationId: string; } | null> { const { userId, sessionClaims } = await auth(); if (!userId) return null; // Get the user's email from Clerk const userEmail = (sessionClaims as any)?.email; if (!userEmail) return null; const supabase = await createAdminClient(); const { data, error } = await supabase .from("pending_invitations") .select("id, organisation_id, organisations(name)") .eq("email", userEmail) .eq("status", "pending") .gte("expires_at", new Date().toISOString()) .order("created_at", { ascending: false }) .limit(1) .maybeSingle(); if (error || !data) return null; return { orgName: (data.organisations as any)?.name || "Unknown", orgId: data.organisation_id, invitationId: data.id, }; } /** * Accept a pending invitation by organisation ID (for the current user's email). */ export async function acceptInvitationByOrg( orgId: string, ): Promise<{ success: boolean; error?: string }> { const { userId, sessionClaims } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const userEmail = (sessionClaims as any)?.email; if (!userEmail) return { success: false, error: "Email not found" }; const supabaseAdmin = await createAdminClient(); // Mark matching invitation as accepted await supabaseAdmin .from("pending_invitations") .update({ status: "accepted" }) .eq("organisation_id", orgId) .eq("email", userEmail) .eq("status", "pending"); // Add user to organisation (using admin to bypass RLS) const { error: memberError } = await supabaseAdmin .from("organisation_members") .insert({ organisation_id: orgId, user_id: userId, role: "employee", }); if (memberError && memberError.code !== "23505") { return { success: false, error: memberError.message }; } // Sync user to employees await syncUserToEmployee(orgId, "employee"); return { success: true }; } /** * Join an organisation by its join code. */ export async function joinOrganisationByCode( code: string, ): Promise<{ success: boolean; orgName?: string; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const supabaseAdmin = await createAdminClient(); const { data: org, error: orgError } = await supabaseAdmin .from("organisations") .select("id, name") .eq("join_code", code.toUpperCase().trim()) .single(); if (orgError || !org) return { success: false, error: "Invalid join code" }; // Add user to organisation (using admin to bypass RLS) const { error: memberError } = await supabaseAdmin .from("organisation_members") .insert({ organisation_id: org.id, user_id: userId, role: "employee" }); if (memberError && memberError.code !== "23505") { return { success: false, error: memberError.message }; } // Sync user to employees await syncUserToEmployee(org.id, "employee"); return { success: true, orgName: org.name }; }