From 726760d7f613259329fefc7d9b6632990ad10fed Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 13:18:57 -0400 Subject: [PATCH 01/12] fix(onboarding): fix org creation timeout and improve error handling The initializeOrganization transaction runs 20+ DB operations (controls, policies, tasks, versions, requirement maps) and was hitting Prisma's default 5s timeout for users selecting multiple frameworks. - Set global transaction timeout to 30s across all 5 Prisma client instances - Clean up partially created org on failure to prevent orphans on retry - Surface actual error messages instead of generic "Failed to create organization" Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/prisma/client.ts | 7 ++++++- apps/app/prisma/client.ts | 7 ++++++- .../setup/actions/create-organization-minimal.ts | 12 ++++++++++++ .../src/app/(app)/setup/hooks/useOnboardingForm.ts | 6 +++--- apps/framework-editor/prisma/client.ts | 7 ++++++- apps/portal/prisma/client.ts | 7 ++++++- packages/db/src/client.ts | 7 ++++++- 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts index 573debfd25..21e833f75a 100644 --- a/apps/api/prisma/client.ts +++ b/apps/api/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index d48e37720f..169de23539 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 5ea1e258bb..9879ffcfb5 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -27,6 +27,8 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }, }) .action(async ({ parsedInput, ctx }) => { + let createdOrgId: string | undefined; + try { const session = await auth.api.getSession({ headers: await headers(), @@ -83,6 +85,7 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }); const orgId = newOrg.id; + createdOrgId = orgId; // Get the member that was created with the organization (the owner) const ownerMember = await db.member.findFirst({ @@ -139,6 +142,15 @@ export const createOrganizationMinimal = authActionClientWithoutOrg } catch (error) { console.error('Error during minimal organization creation:', error); + // Clean up partially created org to prevent orphans on retry + if (createdOrgId) { + try { + await db.organization.delete({ where: { id: createdOrgId } }); + } catch (cleanupError) { + console.error('Failed to clean up org after creation error:', cleanupError); + } + } + if (error instanceof Error) { return { success: false, diff --git a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts index a91d04fa05..7cf07a47eb 100644 --- a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts +++ b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts @@ -130,13 +130,13 @@ export function useOnboardingForm({ // Clear answers after successful creation setSavedAnswers({}); } else { - toast.error('Failed to create organization'); + toast.error(data?.error || 'Failed to create organization'); setIsFinalizing(false); setIsOnboarding(false); } }, - onError: () => { - toast.error('Failed to create organization'); + onError: ({ error }) => { + toast.error(error.serverError || 'Failed to create organization'); setIsFinalizing(false); setIsOnboarding(false); }, diff --git a/apps/framework-editor/prisma/client.ts b/apps/framework-editor/prisma/client.ts index 573debfd25..21e833f75a 100644 --- a/apps/framework-editor/prisma/client.ts +++ b/apps/framework-editor/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/apps/portal/prisma/client.ts b/apps/portal/prisma/client.ts index d48e37720f..169de23539 100644 --- a/apps/portal/prisma/client.ts +++ b/apps/portal/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 573debfd25..21e833f75a 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); From a9cb9c5615b8a1a24894164dc166767f07fadec1 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 13:34:37 -0400 Subject: [PATCH 02/12] fix(onboarding): don't delete org after session activation succeeds Address Bugbot review: if setActiveOrganization succeeded but a later step (revalidatePath) threw, the cleanup would delete a fully initialized org while the session still referenced it. Now cleanup is disabled after activation, and revalidatePath errors are caught separately since they are non-critical. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../actions/create-organization-minimal.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 9879ffcfb5..35e9b9ef99 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -116,22 +116,28 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }); } - // Set new org as active + // Set new org as active — after this point, the session references + // the org so we must NOT delete it on cleanup. await auth.api.setActiveOrganization({ headers: await headers(), body: { organizationId: orgId, }, }); - - // Revalidate paths - const headersList = await headers(); - let path = headersList.get('x-pathname') || headersList.get('referer') || ''; - path = path.replace(/\/[a-z]{2}\//, '/'); - - revalidatePath(path); - revalidatePath('/'); - revalidatePath('/setup'); + createdOrgId = undefined; // Org is fully initialized, disable cleanup + + // Revalidate paths (non-critical, don't let failures kill the flow) + try { + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + + revalidatePath(path); + revalidatePath('/'); + revalidatePath('/setup'); + } catch (revalidateError) { + console.error('Non-critical: failed to revalidate paths:', revalidateError); + } // NO JOB TRIGGERS - that happens after payment in complete-onboarding @@ -142,7 +148,9 @@ export const createOrganizationMinimal = authActionClientWithoutOrg } catch (error) { console.error('Error during minimal organization creation:', error); - // Clean up partially created org to prevent orphans on retry + // Clean up partially created org to prevent orphans on retry. + // Only runs if the org was created but setActiveOrganization hasn't + // succeeded yet (createdOrgId is cleared after activation). if (createdOrgId) { try { await db.organization.delete({ where: { id: createdOrgId } }); From 8e53a10fb5375397da1d7bee58b5ab49fd65984b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 13:46:29 -0400 Subject: [PATCH 03/12] fix(onboarding): disable Complete button while server action is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isOnboarding is true during the server action but wasn't used in the disabled prop — only isSubmitting (react-hook-form) was, which resets after the synchronous onSubmit handler. This allowed double-clicks to create duplicate orgs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/app/(app)/setup/components/OnboardingFormActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx index 27b6190230..04d51c7aaf 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx @@ -82,7 +82,7 @@ export function OnboardingFormActions({ type="submit" form="onboarding-form" // Important: links to the form in OrganizationSetupForm.tsx className="flex items-center gap-2" - disabled={isSubmitting || !isCurrentStepValid} + disabled={isSubmitting || isOnboarding || !isCurrentStepValid} data-testid="setup-finish-button" > Date: Fri, 10 Apr 2026 14:02:16 -0400 Subject: [PATCH 04/12] feat(onboarding): add cancel button to abandon onboarding and return to previous org When users create an additional org via create-additional flow, they get trapped in the onboarding funnel with no way to go back. This adds a Cancel button (visible only when user has other completed orgs) to: - Pre-payment setup form: navigates back to root (no org to delete yet) - Upgrade page: deletes incomplete org, switches to previous org - Post-payment onboarding: deletes incomplete org, switches to previous org Includes confirmation step before deletion to prevent accidental cancels. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/app/(app)/onboarding/[orgId]/page.tsx | 11 +++ .../onboarding/actions/cancel-onboarding.ts | 85 +++++++++++++++++++ .../components/CancelOnboardingButton.tsx | 73 ++++++++++++++++ .../components/PostPaymentOnboarding.tsx | 7 ++ .../src/app/(app)/setup/[setupId]/page.tsx | 11 +++ .../components/OnboardingFormActions.tsx | 13 +++ .../components/OrganizationSetupForm.tsx | 3 + .../[orgId]/components/booking-step.tsx | 11 +++ .../src/app/(app)/upgrade/[orgId]/page.tsx | 17 +++- 9 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts create mode 100644 apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx diff --git a/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx b/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx index bf9588ca2c..49a103261b 100644 --- a/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx @@ -109,12 +109,23 @@ export default async function OnboardingPage({ params }: OnboardingPageProps) { }); } + // Check if user has other completed orgs (for cancel button) + const otherOrgCount = await db.member.count({ + where: { + userId: session.user.id, + organizationId: { not: orgId }, + deactivated: false, + organization: { onboardingCompleted: true, hasAccess: true }, + }, + }); + // We'll use a modified version that starts at step 3 return ( 0} /> ); } diff --git a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts new file mode 100644 index 0000000000..8b98f95a7e --- /dev/null +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -0,0 +1,85 @@ +'use server'; + +import { authActionClientWithoutOrg } from '@/actions/safe-action'; +import { auth } from '@/utils/auth'; +import { db } from '@db/server'; +import { headers } from 'next/headers'; +import { z } from 'zod'; + +const cancelSchema = z.object({ + organizationId: z.string().min(1), +}); + +export const cancelOnboarding = authActionClientWithoutOrg + .inputSchema(cancelSchema) + .metadata({ + name: 'cancel-onboarding', + track: { + event: 'cancel-onboarding', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return { success: false, error: 'Not authorized.' }; + } + + // Verify the user owns this org + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: parsedInput.organizationId, + role: { contains: 'owner' }, + }, + }); + + if (!member) { + return { success: false, error: 'Only the owner can cancel onboarding.' }; + } + + // Find a fallback org to switch to (completed, with access) + const fallbackOrg = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: { not: parsedInput.organizationId }, + deactivated: false, + organization: { + onboardingCompleted: true, + hasAccess: true, + }, + }, + select: { organizationId: true }, + orderBy: { createdAt: 'desc' }, + }); + + // Delete the incomplete org (cascade handles related records) + try { + await db.organization.delete({ + where: { id: parsedInput.organizationId }, + }); + } catch (error) { + console.error('Failed to delete organization:', error); + return { success: false, error: 'Failed to cancel onboarding.' }; + } + + // Switch to fallback org if available + if (fallbackOrg) { + try { + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { organizationId: fallbackOrg.organizationId }, + }); + } catch (error) { + console.error('Failed to switch to fallback org:', error); + } + } + + return { + success: true, + fallbackOrgId: fallbackOrg?.organizationId ?? null, + }; + }); diff --git a/apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx b/apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx new file mode 100644 index 0000000000..12c6ed492a --- /dev/null +++ b/apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Button } from '@trycompai/ui/button'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { cancelOnboarding } from '../actions/cancel-onboarding'; + +interface CancelOnboardingButtonProps { + organizationId: string; + hasOtherOrgs: boolean; +} + +export function CancelOnboardingButton({ + organizationId, + hasOtherOrgs, +}: CancelOnboardingButtonProps) { + const [confirming, setConfirming] = useState(false); + + const cancelAction = useAction(cancelOnboarding, { + onSuccess: ({ data }) => { + if (data?.success) { + const target = data.fallbackOrgId ? `/${data.fallbackOrgId}` : '/setup'; + window.location.assign(target); + } else { + toast.error(data?.error || 'Failed to cancel'); + setConfirming(false); + } + }, + onError: ({ error }) => { + toast.error(error.serverError || 'Failed to cancel'); + setConfirming(false); + }, + }); + + if (!hasOtherOrgs) return null; + + if (!confirming) { + return ( + + ); + } + + return ( +
+ Delete this org? + + +
+ ); +} diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index 8f3968fc0b..fb36ddfef9 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -11,17 +11,20 @@ import { AlertCircle, Loader2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import Balancer from 'react-wrap-balancer'; import { usePostPaymentOnboarding } from '../hooks/usePostPaymentOnboarding'; +import { CancelOnboardingButton } from './CancelOnboardingButton'; interface PostPaymentOnboardingProps { organization: Organization; initialData?: Record; userEmail?: string; + hasOtherOrgs?: boolean; } export function PostPaymentOnboarding({ organization, initialData = {}, userEmail, + hasOtherOrgs = false, }: PostPaymentOnboardingProps) { const { stepIndex, @@ -239,6 +242,10 @@ export function PostPaymentOnboarding({ )}
+ {stepIndex > 0 && ( {/* Form Section - Left Side */} @@ -51,6 +61,7 @@ export default async function SetupWithIdPage({ params, searchParams }: SetupPag setupId={setupId} initialData={setupSession.formData} currentStep={setupSession.currentStep} + hasOtherOrgs={existingOrgCount > 0} />
diff --git a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx index 04d51c7aaf..947850d579 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx @@ -11,6 +11,7 @@ interface OnboardingFormActionsProps { isOnboarding: boolean; // For the loader in the Finish button isCurrentStepValid: boolean; onPrefillAll?: () => void; + hasOtherOrgs?: boolean; } export function OnboardingFormActions({ @@ -21,6 +22,7 @@ export function OnboardingFormActions({ isOnboarding, isCurrentStepValid, onPrefillAll, + hasOtherOrgs = false, }: OnboardingFormActionsProps) { // Check if we're on localhost - use useState/useEffect to avoid hydration mismatch const [isLocalhost, setIsLocalhost] = useState(false); @@ -38,6 +40,17 @@ export function OnboardingFormActions({ return (
+ {hasOtherOrgs && ( + + )} {isLocalhost && onPrefillAll && stepIndex === 0 && (
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx index f719a69acc..b243f55441 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx @@ -1,5 +1,6 @@ 'use client'; +import { CancelOnboardingButton } from '@/app/(app)/onboarding/components/CancelOnboardingButton'; import { Button } from '@trycompai/ui/button'; import { Card } from '@trycompai/ui/card'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; @@ -11,10 +12,12 @@ export function BookingStep({ company, orgId, hasAccess, + hasOtherOrgs = false, }: { company: string; orgId: string; hasAccess: boolean; + hasOtherOrgs?: boolean; }) { const [isCopied, setIsCopied] = useState(false); @@ -80,6 +83,14 @@ export function BookingStep({ + + {/* Cancel option */} +
+ +
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx index ff50a68b59..d33ae89074 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx @@ -113,11 +113,26 @@ export default async function UpgradePage({ params }: PageProps) { redirect(`/${orgId}`); } + // Check if user has other completed orgs (for cancel button) + const otherOrgCount = await db.member.count({ + where: { + userId: authSession.user.id, + organizationId: { not: orgId }, + deactivated: false, + organization: { onboardingCompleted: true, hasAccess: true }, + }, + }); + return ( <>
- + 0} + />
); From b1dec0e8a8f55e50a001f47f6832e398ce19f3b3 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 14:08:10 -0400 Subject: [PATCH 05/12] =?UTF-8?q?fix(onboarding):=20harden=20cancel=20acti?= =?UTF-8?q?on=20=E2=80=94=20guard=20completed=20orgs,=20switch=20before=20?= =?UTF-8?q?delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject cancel on orgs with onboardingCompleted=true - Switch activeOrganization BEFORE deleting so session never references a deleted org (prevents dangling session on slow client redirect) - Fail cancel if org switch fails rather than leaving orphaned state Co-Authored-By: Claude Opus 4.6 (1M context) --- .../onboarding/actions/cancel-onboarding.ts | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts index 8b98f95a7e..e2e70402c2 100644 --- a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -28,20 +28,25 @@ export const cancelOnboarding = authActionClientWithoutOrg return { success: false, error: 'Not authorized.' }; } - // Verify the user owns this org + // Verify the user owns this org and it's still incomplete const member = await db.member.findFirst({ where: { userId: session.user.id, organizationId: parsedInput.organizationId, role: { contains: 'owner' }, }, + include: { organization: { select: { onboardingCompleted: true } } }, }); if (!member) { return { success: false, error: 'Only the owner can cancel onboarding.' }; } - // Find a fallback org to switch to (completed, with access) + if (member.organization.onboardingCompleted) { + return { success: false, error: 'Cannot cancel a completed organization.' }; + } + + // Find a fallback org to switch to BEFORE deleting const fallbackOrg = await db.member.findFirst({ where: { userId: session.user.id, @@ -56,17 +61,8 @@ export const cancelOnboarding = authActionClientWithoutOrg orderBy: { createdAt: 'desc' }, }); - // Delete the incomplete org (cascade handles related records) - try { - await db.organization.delete({ - where: { id: parsedInput.organizationId }, - }); - } catch (error) { - console.error('Failed to delete organization:', error); - return { success: false, error: 'Failed to cancel onboarding.' }; - } - - // Switch to fallback org if available + // Switch active org BEFORE deletion so the session never + // references a deleted org (even if the client redirect is slow). if (fallbackOrg) { try { await auth.api.setActiveOrganization({ @@ -75,9 +71,20 @@ export const cancelOnboarding = authActionClientWithoutOrg }); } catch (error) { console.error('Failed to switch to fallback org:', error); + return { success: false, error: 'Failed to switch organization.' }; } } + // Delete the incomplete org (cascade handles related records) + try { + await db.organization.delete({ + where: { id: parsedInput.organizationId }, + }); + } catch (error) { + console.error('Failed to delete organization:', error); + return { success: false, error: 'Failed to cancel onboarding.' }; + } + return { success: true, fallbackOrgId: fallbackOrg?.organizationId ?? null, From 14a35df42f47b9d3f7bc7841182f14072e5ae95c Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 14:33:38 -0400 Subject: [PATCH 06/12] fix(onboarding): sanitize error messages shown to users Don't expose raw Prisma/DB error messages (like constraint violations or connection details) in user-facing toasts. Log the raw error to console for debugging, show a generic user-friendly message in the toast. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts index d7dde73cad..9ac806e8b7 100644 --- a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts +++ b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts @@ -130,13 +130,15 @@ export function useOnboardingForm({ // Clear answers after successful creation setSavedAnswers({}); } else { - toast.error(data?.error || 'Failed to create organization'); + console.error('Organization creation failed:', data?.error); + toast.error('Failed to create organization. Please try again.'); setIsFinalizing(false); setIsOnboarding(false); } }, onError: ({ error }) => { - toast.error(error.serverError || 'Failed to create organization'); + console.error('Organization creation error:', error.serverError); + toast.error('Failed to create organization. Please try again.'); setIsFinalizing(false); setIsOnboarding(false); }, From 03452e38467865ed293e38b9ddf4263bc878379e Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 15:16:25 -0400 Subject: [PATCH 07/12] fix(onboarding): require fallback org before allowing cancel Refuse to delete org if no fallback org exists server-side. Prevents race condition where other orgs are removed between page render (which checks hasOtherOrgs) and action execution, which would leave the session pointing at a deleted org. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../onboarding/actions/cancel-onboarding.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts index e2e70402c2..0044c7b06d 100644 --- a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -61,18 +61,23 @@ export const cancelOnboarding = authActionClientWithoutOrg orderBy: { createdAt: 'desc' }, }); + // Must have a fallback org — refuse to delete if there's nowhere to go. + // The UI guards this too, but a race condition could remove fallback orgs + // between page render and action execution. + if (!fallbackOrg) { + return { success: false, error: 'No other organization to switch to.' }; + } + // Switch active org BEFORE deletion so the session never // references a deleted org (even if the client redirect is slow). - if (fallbackOrg) { - try { - await auth.api.setActiveOrganization({ - headers: await headers(), - body: { organizationId: fallbackOrg.organizationId }, - }); - } catch (error) { - console.error('Failed to switch to fallback org:', error); - return { success: false, error: 'Failed to switch organization.' }; - } + try { + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { organizationId: fallbackOrg.organizationId }, + }); + } catch (error) { + console.error('Failed to switch to fallback org:', error); + return { success: false, error: 'Failed to switch organization.' }; } // Delete the incomplete org (cascade handles related records) From 9b884f09f46221b61fa73f4fcc2f921613422302 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 15:30:17 -0400 Subject: [PATCH 08/12] fix(onboarding): rollback active org switch if delete fails If setActiveOrganization succeeds but organization.delete fails, roll back the active org to the original one so the session stays consistent with what the user sees on screen. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(app)/onboarding/actions/cancel-onboarding.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts index 0044c7b06d..99c1722078 100644 --- a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -80,13 +80,22 @@ export const cancelOnboarding = authActionClientWithoutOrg return { success: false, error: 'Failed to switch organization.' }; } - // Delete the incomplete org (cascade handles related records) + // Delete the incomplete org (cascade handles related records). + // If this fails, roll back the active org switch to keep state consistent. try { await db.organization.delete({ where: { id: parsedInput.organizationId }, }); } catch (error) { console.error('Failed to delete organization:', error); + try { + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { organizationId: parsedInput.organizationId }, + }); + } catch (rollbackError) { + console.error('Failed to rollback active org switch:', rollbackError); + } return { success: false, error: 'Failed to cancel onboarding.' }; } From 80db5d98a21251ab3931e0f484a95cdf451b863d Mon Sep 17 00:00:00 2001 From: claudio Date: Fri, 10 Apr 2026 15:46:52 -0400 Subject: [PATCH 09/12] feat: add List-Unsubscribe headers and throttle email sends (#2507) * feat: add List-Unsubscribe headers and throttle email sends - Add List-Unsubscribe and List-Unsubscribe-Post headers to all outbound emails for Gmail/RFC 8058 one-click unsubscribe compliance - Reduce email queue concurrency from 30 to 10 - Add 1s delay between sends to avoid email spikes that trigger reputation systems Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use setTimeout instead of wait.for for email throttling wait.for suspends execution and frees the concurrency slot, defeating the throttling purpose. setTimeout holds the slot occupied for 1s, actually spacing out sends. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use real recipient email for unsubscribe URL, not test override When RESEND_TO_TEST is set, toAddress becomes the test email. The unsubscribe URL should always reference the real recipient (params.to) so the token validates correctly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove List-Unsubscribe-Post, add mailto fallback The one-click POST handler doesn't exist yet (unsubscribe page is GET only). Removed List-Unsubscribe-Post to avoid claiming RFC 8058 support we don't have. Added mailto fallback for broader client compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add RFC 8058 one-click unsubscribe POST endpoint - New POST /v1/email/unsubscribe endpoint that accepts email+token via query params, verifies HMAC token, and unsubscribes the user - No auth required (token IS the auth, Gmail needs to POST directly) - Re-add List-Unsubscribe-Post header now that the handler exists - List-Unsubscribe URL points to API endpoint for one-click POST Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove dead import, use timing-safe token comparison - Remove unused getUnsubscribeUrl import from send-email.ts - Use crypto.timingSafeEqual for HMAC token verification in unsubscribe endpoint Co-Authored-By: Claude Opus 4.6 (1M context) * fix: guard against type confusion on query/body params CodeQL flagged that query params could be arrays. Explicitly coerce to string before using. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: include findingNotifications in unsubscribe preferences Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- apps/api/src/email/email.module.ts | 3 +- apps/api/src/email/unsubscribe.controller.ts | 71 ++++++++++++++++++++ apps/api/src/trigger/email/send-email.ts | 16 ++++- 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/email/unsubscribe.controller.ts diff --git a/apps/api/src/email/email.module.ts b/apps/api/src/email/email.module.ts index 5851bd274a..872cb1a0f9 100644 --- a/apps/api/src/email/email.module.ts +++ b/apps/api/src/email/email.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { EmailController } from './email.controller'; +import { UnsubscribeController } from './unsubscribe.controller'; @Module({ imports: [AuthModule], - controllers: [EmailController], + controllers: [EmailController, UnsubscribeController], }) export class EmailModule {} diff --git a/apps/api/src/email/unsubscribe.controller.ts b/apps/api/src/email/unsubscribe.controller.ts new file mode 100644 index 0000000000..ec6d9fc450 --- /dev/null +++ b/apps/api/src/email/unsubscribe.controller.ts @@ -0,0 +1,71 @@ +import { Controller, Post, Body, Query, HttpCode, BadRequestException } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { db } from '@db'; +import { generateUnsubscribeToken } from '@trycompai/email/lib/unsubscribe'; +import { timingSafeEqual } from 'node:crypto'; + +@ApiTags('Email - Unsubscribe') +@Controller({ path: 'email/unsubscribe', version: '1' }) +export class UnsubscribeController { + /** + * RFC 8058 one-click unsubscribe endpoint. + * Gmail POSTs to this URL with List-Unsubscribe=One-Click in the body. + * Email and token come via query params in the URL. + */ + @Post() + @HttpCode(200) + @ApiOperation({ summary: 'One-click unsubscribe (RFC 8058)' }) + async unsubscribe( + @Query('email') queryEmail?: string, + @Query('token') queryToken?: string, + @Body() body?: { email?: string; token?: string }, + ) { + // Coerce to string - query params can be arrays if repeated + const rawEmail = queryEmail || body?.email; + const rawToken = queryToken || body?.token; + const email = typeof rawEmail === 'string' ? rawEmail : undefined; + const token = typeof rawToken === 'string' ? rawToken : undefined; + + if (!email || !token) { + throw new BadRequestException('Email and token are required'); + } + + // Verify HMAC token (timing-safe comparison) + const expectedToken = generateUnsubscribeToken(email); + const tokensMatch = + expectedToken.length === token.length && + timingSafeEqual(Buffer.from(expectedToken), Buffer.from(token)); + if (!tokensMatch) { + throw new BadRequestException('Invalid token'); + } + + // Unsubscribe the user from all email notifications + const user = await db.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (!user) { + // Don't reveal user existence - just return success + return { success: true }; + } + + await db.user.update({ + where: { id: user.id }, + data: { + emailNotificationsUnsubscribed: true, + emailPreferences: { + policyNotifications: false, + taskReminders: false, + weeklyTaskDigest: false, + unassignedItemsNotifications: false, + taskMentions: false, + taskAssignments: false, + findingNotifications: false, + }, + }, + }); + + return { success: true }; + } +} diff --git a/apps/api/src/trigger/email/send-email.ts b/apps/api/src/trigger/email/send-email.ts index 36a99bdce2..85e85babeb 100644 --- a/apps/api/src/trigger/email/send-email.ts +++ b/apps/api/src/trigger/email/send-email.ts @@ -1,10 +1,11 @@ import { logger, queue, schemaTask } from '@trigger.dev/sdk'; import { z } from 'zod'; import { resend } from '../../email/resend'; +import { generateUnsubscribeToken } from '@trycompai/email/lib/unsubscribe'; const emailQueue = queue({ name: 'send-email', - concurrencyLimit: 30, + concurrencyLimit: 10, }); export const sendEmailTask = schemaTask({ @@ -51,12 +52,22 @@ export const sendEmailTask = schemaTask({ } try { + // Build List-Unsubscribe headers for Gmail/RFC 8058 one-click compliance + const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; + const token = generateUnsubscribeToken(params.to); + const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(params.to)}&token=${encodeURIComponent(token)}`; + const headers: Record = { + 'List-Unsubscribe': `<${oneClickUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }; + const { data, error } = await resend.emails.send({ from: fromAddress, to: toAddress, cc: params.cc, subject: params.subject, html: params.html, + headers, scheduledAt: params.scheduledAt, attachments: params.attachments?.map((att) => ({ filename: att.filename, @@ -76,6 +87,9 @@ export const sendEmailTask = schemaTask({ logger.info('Email sent', { to: params.to, id: data?.id }); + // Throttle: hold the concurrency slot for 1s to space out sends + await new Promise((r) => setTimeout(r, 1000)); + return { id: data?.id }; } catch (error) { logger.error('Email sending failed', { From b165a18c4ce4c23df6497ca28c4c3bd7213db05f Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 15:53:30 -0400 Subject: [PATCH 10/12] fix: use barrel import for email package (Trigger build fix) Deep path import @trycompai/email/lib/unsubscribe resolves to source .ts files which Trigger's esbuild can't find in dist/. Use barrel import from @trycompai/email instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/email/unsubscribe.controller.ts | 2 +- apps/api/src/trigger/email/send-email.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/email/unsubscribe.controller.ts b/apps/api/src/email/unsubscribe.controller.ts index ec6d9fc450..079525fe02 100644 --- a/apps/api/src/email/unsubscribe.controller.ts +++ b/apps/api/src/email/unsubscribe.controller.ts @@ -1,7 +1,7 @@ import { Controller, Post, Body, Query, HttpCode, BadRequestException } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { db } from '@db'; -import { generateUnsubscribeToken } from '@trycompai/email/lib/unsubscribe'; +import { generateUnsubscribeToken } from '@trycompai/email'; import { timingSafeEqual } from 'node:crypto'; @ApiTags('Email - Unsubscribe') diff --git a/apps/api/src/trigger/email/send-email.ts b/apps/api/src/trigger/email/send-email.ts index 85e85babeb..ad14d0f2fe 100644 --- a/apps/api/src/trigger/email/send-email.ts +++ b/apps/api/src/trigger/email/send-email.ts @@ -1,7 +1,7 @@ import { logger, queue, schemaTask } from '@trigger.dev/sdk'; import { z } from 'zod'; import { resend } from '../../email/resend'; -import { generateUnsubscribeToken } from '@trycompai/email/lib/unsubscribe'; +import { generateUnsubscribeToken } from '@trycompai/email'; const emailQueue = queue({ name: 'send-email', From 887dfa949c60671d3c54a070fb1401d216b1dc5f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 15:57:00 -0400 Subject: [PATCH 11/12] fix(onboarding): hide cancel button while onboarding submission is in-flight Prevents race between org deletion and completeOnboarding by hiding the cancel button when isOnboarding or isFinalizing is true. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(app)/onboarding/components/PostPaymentOnboarding.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index fb36ddfef9..4a9fda707f 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -244,7 +244,7 @@ export function PostPaymentOnboarding({
{stepIndex > 0 && ( From 082501f4f3f4f2a6de77514a2fb7b98a09d48ba8 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 10 Apr 2026 16:36:08 -0400 Subject: [PATCH 12/12] =?UTF-8?q?fix(onboarding):=20add=20initialize-organ?= =?UTF-8?q?ization=20trigger=20task=20and=20recover=E2=80=A6=20(#2512)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(onboarding): add initialize-organization trigger task and recovery guard If createOrganizationMinimal partially fails (org created but initializeOrganization doesn't run), the org ends up with no framework instances, controls, policies, or tasks. completeOnboarding now detects this and runs initializeOrganization as recovery before triggering the onboard job. A standalone Trigger.dev task allows manual re-runs from the dashboard for orgs already in this broken state. Also saves raw framework IDs to context for reliable recovery lookups and upserts the onboarding record in case that was also missing. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(onboarding): extract resolveFrameworkIds to shared helper Deduplicates the resolveFrameworkIds logic that was copied in both complete-onboarding.ts and initialize-organization.ts. Now lives in actions/organization/lib/resolve-framework-ids.ts alongside initialize-organization.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../organization/lib/resolve-framework-ids.ts | 52 +++++++++++++++ .../onboarding/actions/complete-onboarding.ts | 40 +++++++++++ .../actions/create-organization-minimal.ts | 19 ++++-- .../onboarding/initialize-organization.ts | 66 +++++++++++++++++++ 4 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 apps/app/src/actions/organization/lib/resolve-framework-ids.ts create mode 100644 apps/app/src/trigger/tasks/onboarding/initialize-organization.ts diff --git a/apps/app/src/actions/organization/lib/resolve-framework-ids.ts b/apps/app/src/actions/organization/lib/resolve-framework-ids.ts new file mode 100644 index 0000000000..a1cf83cf0c --- /dev/null +++ b/apps/app/src/actions/organization/lib/resolve-framework-ids.ts @@ -0,0 +1,52 @@ +import { db } from '@db/server'; + +/** + * Resolves framework IDs for an organization by: + * 1. Checking for a raw frameworkIds context entry (JSON array, saved by newer code) + * 2. Falling back to reverse-looking framework names from the onboarding context + */ +export async function resolveFrameworkIds(organizationId: string): Promise { + // Try the raw IDs context entry first (saved by newer createOrganizationMinimal) + const rawIdsContext = await db.context.findFirst({ + where: { + organizationId, + question: 'frameworkIds', + tags: { has: 'onboarding' }, + }, + }); + + if (rawIdsContext?.answer) { + try { + const ids = JSON.parse(rawIdsContext.answer); + if (Array.isArray(ids) && ids.length > 0) { + return ids; + } + } catch { + // Fall through to name-based lookup + } + } + + // Fall back to reverse-looking from framework names + const frameworkContext = await db.context.findFirst({ + where: { + organizationId, + question: 'Which compliance frameworks do you need?', + tags: { has: 'onboarding' }, + }, + }); + + if (!frameworkContext?.answer) { + return []; + } + + const frameworkNames = frameworkContext.answer.split(',').map((name) => name.trim()); + + const frameworks = await db.frameworkEditorFramework.findMany({ + where: { + name: { in: frameworkNames, mode: 'insensitive' }, + }, + select: { id: true }, + }); + + return frameworks.map((f) => f.id); +} diff --git a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts index 7c58e1d8f9..97e3a1db0b 100644 --- a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts @@ -1,5 +1,7 @@ 'use server'; +import { initializeOrganization } from '@/actions/organization/lib/initialize-organization'; +import { resolveFrameworkIds } from '@/actions/organization/lib/resolve-framework-ids'; import { authActionClientWithoutOrg } from '@/actions/safe-action'; import { steps } from '@/app/(app)/setup/lib/constants'; import { createFleetLabelForOrg } from '@/trigger/tasks/device/create-fleet-label-for-org'; @@ -155,6 +157,43 @@ export const completeOnboarding = authActionClientWithoutOrg data: { onboardingCompleted: true }, }); + // Ensure framework structure exists before triggering the onboard job. + // If createOrganizationMinimal partially failed (org created but + // initializeOrganization didn't run), recover by initializing now. + const existingFrameworks = await db.frameworkInstance.findFirst({ + where: { organizationId: parsedInput.organizationId }, + }); + + if (!existingFrameworks) { + console.warn( + `[complete-onboarding] No framework instances found for org ${parsedInput.organizationId}, running initializeOrganization as recovery`, + ); + + const frameworkIds = await resolveFrameworkIds(parsedInput.organizationId); + + if (frameworkIds.length > 0) { + await initializeOrganization({ + frameworkIds, + organizationId: parsedInput.organizationId, + }); + } else { + console.error( + `[complete-onboarding] Could not resolve framework IDs for org ${parsedInput.organizationId}`, + ); + } + } + + // Ensure onboarding record exists (may be missing if createOrganizationMinimal + // failed before creating it). + await db.onboarding.upsert({ + where: { organizationId: parsedInput.organizationId }, + create: { + organizationId: parsedInput.organizationId, + triggerJobCompleted: false, + }, + update: {}, + }); + // Now trigger the jobs that were skipped during minimal creation const handle = await tasks.trigger('onboard-organization', { organizationId: parsedInput.organizationId, @@ -208,3 +247,4 @@ export const completeOnboarding = authActionClientWithoutOrg }; } }); + diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 3393e6df7c..c84c6af096 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -134,12 +134,21 @@ export const createOrganizationMinimal = authActionClientWithoutOrg role: 'owner', }, }, - // Only save the context for frameworkIds (we need this for later) + // Save framework context: display names for AI prompts + raw IDs for recovery context: { - create: { - question: 'Which compliance frameworks do you need?', - answer: frameworkNames || parsedInput.frameworkIds.join(', '), - tags: ['onboarding'], + createMany: { + data: [ + { + question: 'Which compliance frameworks do you need?', + answer: frameworkNames || parsedInput.frameworkIds.join(', '), + tags: ['onboarding'], + }, + { + question: 'frameworkIds', + answer: JSON.stringify(parsedInput.frameworkIds), + tags: ['onboarding'], + }, + ], }, }, }, diff --git a/apps/app/src/trigger/tasks/onboarding/initialize-organization.ts b/apps/app/src/trigger/tasks/onboarding/initialize-organization.ts new file mode 100644 index 0000000000..e1e4ba14e5 --- /dev/null +++ b/apps/app/src/trigger/tasks/onboarding/initialize-organization.ts @@ -0,0 +1,66 @@ +import { initializeOrganization } from '@/actions/organization/lib/initialize-organization'; +import { resolveFrameworkIds } from '@/actions/organization/lib/resolve-framework-ids'; +import { db } from '@db/server'; +import { logger, queue, tags, task } from '@trigger.dev/sdk'; + +const initOrgQueue = queue({ + name: 'initialize-organization', + concurrencyLimit: 10, +}); + +/** + * Standalone Trigger.dev task for initializing an organization's framework + * structure (framework instances, controls, policies, tasks, requirement maps). + * + * Use cases: + * - Manual re-run from the Trigger.dev dashboard for orgs stuck in a partial state + * - Automatic recovery when `completeOnboarding` detects missing framework instances + * + * Accepts optional `frameworkIds`. When omitted, resolves them by reverse-looking + * framework names stored in the organization's onboarding context. + */ +export const initializeOrganizationTask = task({ + id: 'initialize-organization', + queue: initOrgQueue, + retry: { + maxAttempts: 3, + }, + run: async (payload: { organizationId: string; frameworkIds?: string[] }) => { + const { organizationId } = payload; + await tags.add([`org:${organizationId}`]); + + logger.info(`Initializing organization ${organizationId}`); + + // Check if already initialized + const existingFrameworks = await db.frameworkInstance.findFirst({ + where: { organizationId }, + }); + + if (existingFrameworks) { + logger.info( + `Organization ${organizationId} already has framework instances, skipping initialization`, + ); + return { skipped: true, reason: 'already_initialized' }; + } + + // Resolve framework IDs + const frameworkIds = payload.frameworkIds ?? (await resolveFrameworkIds(organizationId)); + + if (frameworkIds.length === 0) { + logger.error(`No framework IDs found for organization ${organizationId}`); + throw new Error( + `Cannot initialize organization ${organizationId}: no framework IDs found in context`, + ); + } + + logger.info( + `Initializing organization ${organizationId} with frameworks: ${frameworkIds.join(', ')}`, + ); + + await initializeOrganization({ frameworkIds, organizationId }); + + logger.info(`Successfully initialized organization ${organizationId}`); + return { skipped: false, frameworkIds }; + }, +}); +