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/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..079525fe02 --- /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'; +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..ad14d0f2fe 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'; 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', { 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/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/[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..99c1722078 --- /dev/null +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -0,0 +1,106 @@ +'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 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.' }; + } + + 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, + organizationId: { not: parsedInput.organizationId }, + deactivated: false, + organization: { + onboardingCompleted: true, + hasAccess: true, + }, + }, + select: { organizationId: true }, + 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). + 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). + // 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.' }; + } + + return { + success: true, + fallbackOrgId: fallbackOrg?.organizationId ?? null, + }; + }); 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)/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..4a9fda707f 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/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 6478ec53bd..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 @@ -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(), @@ -132,18 +134,28 @@ 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'], + }, + ], }, }, }, }); const orgId = newOrg.id; + createdOrgId = orgId; // Get the member that was created with the organization (the owner) const ownerMember = await db.member.findFirst({ @@ -174,22 +186,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 @@ -200,6 +218,17 @@ export const createOrganizationMinimal = authActionClientWithoutOrg } catch (error) { console.error('Error during minimal organization creation:', error); + // 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 } }); + } 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/components/OnboardingFormActions.tsx b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx index 27b6190230..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)/setup/hooks/useOnboardingForm.ts b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts index 0af292fe6c..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('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: () => { - toast.error('Failed to create organization'); + onError: ({ error }) => { + console.error('Organization creation error:', error.serverError); + toast.error('Failed to create organization. Please try again.'); setIsFinalizing(false); setIsOnboarding(false); }, 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} + />
); 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 }; + }, +}); + 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();