From c4320674027f9293f9fd937529b6c6da86c8f72e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:14:20 -0500 Subject: [PATCH 1/2] [dev] [tofikwest] tofik/update-organization-owner (#1876) * feat(organization): add transfer ownership functionality and do visible the section del organization only for owner * feat(organization): implement transfer ownership endpoint and update related schemas * fix(organization): throw BadRequestException for missing user ID in transfer ownership --------- Co-authored-by: Tofik Hasanov --- .../dto/transfer-ownership.dto.ts | 19 ++ .../organization/organization.controller.ts | 45 +++- .../src/organization/organization.service.ts | 151 ++++++++++++- .../schemas/organization-api-bodies.ts | 16 ++ .../schemas/organization-operations.ts | 5 + .../schemas/transfer-ownership.responses.ts | 146 ++++++++++++ .../src/app/(app)/[orgId]/settings/page.tsx | 89 ++++++-- .../organization/delete-organization.tsx | 13 +- .../forms/organization/transfer-ownership.tsx | 207 ++++++++++++++++++ packages/docs/openapi.json | 127 +++++++++++ 10 files changed, 798 insertions(+), 20 deletions(-) create mode 100644 apps/api/src/organization/dto/transfer-ownership.dto.ts create mode 100644 apps/api/src/organization/schemas/transfer-ownership.responses.ts create mode 100644 apps/app/src/components/forms/organization/transfer-ownership.tsx diff --git a/apps/api/src/organization/dto/transfer-ownership.dto.ts b/apps/api/src/organization/dto/transfer-ownership.dto.ts new file mode 100644 index 000000000..cb0525414 --- /dev/null +++ b/apps/api/src/organization/dto/transfer-ownership.dto.ts @@ -0,0 +1,19 @@ +export interface TransferOwnershipDto { + newOwnerId: string; +} + +export interface TransferOwnershipResponseDto { + success: boolean; + message: string; + currentOwner?: { + memberId: string; + previousRoles: string[]; + newRoles: string[]; + }; + newOwner?: { + memberId: string; + previousRoles: string[]; + newRoles: string[]; + }; +} + diff --git a/apps/api/src/organization/organization.controller.ts b/apps/api/src/organization/organization.controller.ts index f2484a13f..6b04c352f 100644 --- a/apps/api/src/organization/organization.controller.ts +++ b/apps/api/src/organization/organization.controller.ts @@ -1,9 +1,11 @@ import { + BadRequestException, Body, Controller, Delete, Get, Patch, + Post, UseGuards, } from '@nestjs/common'; import { @@ -22,11 +24,16 @@ import { import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import type { AuthContext as AuthContextType } from '../auth/types'; import type { UpdateOrganizationDto } from './dto/update-organization.dto'; +import type { TransferOwnershipDto } from './dto/transfer-ownership.dto'; import { OrganizationService } from './organization.service'; import { GET_ORGANIZATION_RESPONSES } from './schemas/get-organization.responses'; import { UPDATE_ORGANIZATION_RESPONSES } from './schemas/update-organization.responses'; import { DELETE_ORGANIZATION_RESPONSES } from './schemas/delete-organization.responses'; -import { UPDATE_ORGANIZATION_BODY } from './schemas/organization-api-bodies'; +import { TRANSFER_OWNERSHIP_RESPONSES } from './schemas/transfer-ownership.responses'; +import { + UPDATE_ORGANIZATION_BODY, + TRANSFER_OWNERSHIP_BODY, +} from './schemas/organization-api-bodies'; import { ORGANIZATION_OPERATIONS } from './schemas/organization-operations'; @ApiTags('Organization') @@ -96,6 +103,42 @@ export class OrganizationController { }; } + @Post('transfer-ownership') + @ApiOperation(ORGANIZATION_OPERATIONS.transferOwnership) + @ApiBody(TRANSFER_OWNERSHIP_BODY) + @ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[200]) + @ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[400]) + @ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[401]) + @ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[403]) + @ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[404]) + async transferOwnership( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Body() transferData: TransferOwnershipDto, + ) { + if (!authContext.userId) { + throw new BadRequestException( + 'User ID is required for this operation. This endpoint requires session authentication.', + ); + } + + const result = await this.organizationService.transferOwnership( + organizationId, + authContext.userId, + transferData.newOwnerId, + ); + + return { + ...result, + authType: authContext.authType, + // Include user context for session auth (helpful for debugging) + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }; + } + @Delete() @ApiOperation(ORGANIZATION_OPERATIONS.deleteOrganization) @ApiResponse(DELETE_ORGANIZATION_RESPONSES[200]) diff --git a/apps/api/src/organization/organization.service.ts b/apps/api/src/organization/organization.service.ts index 6e4db316c..1365cd2b1 100644 --- a/apps/api/src/organization/organization.service.ts +++ b/apps/api/src/organization/organization.service.ts @@ -1,6 +1,13 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; -import { db } from '@trycompai/db'; +import { + Injectable, + NotFoundException, + Logger, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; +import { db, Role } from '@trycompai/db'; import type { UpdateOrganizationDto } from './dto/update-organization.dto'; +import type { TransferOwnershipResponseDto } from './dto/transfer-ownership.dto'; @Injectable() export class OrganizationService { @@ -126,4 +133,144 @@ export class OrganizationService { throw error; } } + + async transferOwnership( + organizationId: string, + currentUserId: string, + newOwnerId: string, + ): Promise { + try { + // Validate input + if (!newOwnerId || newOwnerId.trim() === '') { + throw new BadRequestException('New owner must be selected'); + } + + // Get current user's member record + const currentUserMember = await db.member.findFirst({ + where: { organizationId, userId: currentUserId }, + }); + + if (!currentUserMember) { + throw new ForbiddenException( + 'Current user is not a member of this organization', + ); + } + + // Check if current user is the owner + const currentUserRoles = + currentUserMember.role?.split(',').map((r) => r.trim()) ?? []; + if (!currentUserRoles.includes(Role.owner)) { + throw new ForbiddenException( + 'Only the organization owner can transfer ownership', + ); + } + + // Get new owner's member record + const newOwnerMember = await db.member.findFirst({ + where: { + id: newOwnerId, + organizationId, + deactivated: false, + }, + }); + + if (!newOwnerMember) { + throw new NotFoundException('New owner not found or is deactivated'); + } + + // Prevent transferring to self + if (newOwnerMember.userId === currentUserId) { + throw new BadRequestException( + 'You cannot transfer ownership to yourself', + ); + } + + // Parse new owner's current roles + const newOwnerRoles = + newOwnerMember.role?.split(',').map((r) => r.trim()) ?? []; + + // Check if new owner already has owner role (shouldn't happen, but safety check) + if (newOwnerRoles.includes(Role.owner)) { + throw new BadRequestException('Selected member is already an owner'); + } + + // Prepare updated roles for current owner: + // Remove 'owner', add 'admin' if not present, keep all other roles + const updatedCurrentOwnerRoles = currentUserRoles + .filter((role) => role !== Role.owner) // Remove owner + .concat(currentUserRoles.includes(Role.admin) ? [] : [Role.admin]); // Add admin if not present + + // Prepare updated roles for new owner: + // Add 'owner', keep all existing roles + const updatedNewOwnerRoles = [ + ...new Set([...newOwnerRoles, Role.owner]), + ]; // Use Set to avoid duplicates + + this.logger.log('[Transfer Ownership] Role updates:', { + organizationId, + currentOwner: { + memberId: currentUserMember.id, + userId: currentUserId, + before: currentUserRoles, + after: updatedCurrentOwnerRoles, + }, + newOwner: { + memberId: newOwnerMember.id, + userId: newOwnerMember.userId, + before: newOwnerRoles, + after: updatedNewOwnerRoles, + }, + }); + + // Update both members in a transaction + await db.$transaction([ + // Remove owner role from current user and add admin role (keep other roles) + db.member.update({ + where: { id: currentUserMember.id }, + data: { + role: updatedCurrentOwnerRoles.sort().join(','), + }, + }), + // Add owner role to new owner (keep all existing roles) + db.member.update({ + where: { id: newOwnerMember.id }, + data: { + role: updatedNewOwnerRoles.sort().join(','), + }, + }), + ]); + + this.logger.log( + `Ownership transferred successfully for organization ${organizationId}`, + ); + + return { + success: true, + message: 'Ownership transferred successfully', + currentOwner: { + memberId: currentUserMember.id, + previousRoles: currentUserRoles, + newRoles: updatedCurrentOwnerRoles, + }, + newOwner: { + memberId: newOwnerMember.id, + previousRoles: newOwnerRoles, + newRoles: updatedNewOwnerRoles, + }, + }; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof BadRequestException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error( + `Failed to transfer ownership for organization ${organizationId}:`, + error, + ); + throw error; + } + } } diff --git a/apps/api/src/organization/schemas/organization-api-bodies.ts b/apps/api/src/organization/schemas/organization-api-bodies.ts index 87d7d133c..997cdbe22 100644 --- a/apps/api/src/organization/schemas/organization-api-bodies.ts +++ b/apps/api/src/organization/schemas/organization-api-bodies.ts @@ -54,3 +54,19 @@ export const UPDATE_ORGANIZATION_BODY: ApiBodyOptions = { additionalProperties: false, }, }; + +export const TRANSFER_OWNERSHIP_BODY: ApiBodyOptions = { + description: 'Transfer organization ownership to another member', + schema: { + type: 'object', + required: ['newOwnerId'], + properties: { + newOwnerId: { + type: 'string', + description: 'Member ID of the new owner', + example: 'mem_xyz789', + }, + }, + additionalProperties: false, + }, +}; diff --git a/apps/api/src/organization/schemas/organization-operations.ts b/apps/api/src/organization/schemas/organization-operations.ts index bc8cc7be8..dc7a15c31 100644 --- a/apps/api/src/organization/schemas/organization-operations.ts +++ b/apps/api/src/organization/schemas/organization-operations.ts @@ -16,4 +16,9 @@ export const ORGANIZATION_OPERATIONS: Record = { description: 'Permanently deletes the authenticated organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', }, + transferOwnership: { + summary: 'Transfer organization ownership', + description: + 'Transfers organization ownership to another member. The current owner will become an admin and keep all other roles. The new owner will receive the owner role while keeping their existing roles. Only the current organization owner can perform this action. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, }; diff --git a/apps/api/src/organization/schemas/transfer-ownership.responses.ts b/apps/api/src/organization/schemas/transfer-ownership.responses.ts new file mode 100644 index 000000000..42251a798 --- /dev/null +++ b/apps/api/src/organization/schemas/transfer-ownership.responses.ts @@ -0,0 +1,146 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const TRANSFER_OWNERSHIP_RESPONSES: Record< + 200 | 400 | 401 | 403 | 404, + ApiResponseOptions +> = { + 200: { + description: 'Ownership transferred successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + example: true, + }, + message: { + type: 'string', + example: 'Ownership transferred successfully', + }, + currentOwner: { + type: 'object', + properties: { + memberId: { + type: 'string', + example: 'mem_abc123', + }, + previousRoles: { + type: 'array', + items: { type: 'string' }, + example: ['owner', 'employee'], + }, + newRoles: { + type: 'array', + items: { type: 'string' }, + example: ['admin', 'employee'], + }, + }, + }, + newOwner: { + type: 'object', + properties: { + memberId: { + type: 'string', + example: 'mem_xyz789', + }, + previousRoles: { + type: 'array', + items: { type: 'string' }, + example: ['admin'], + }, + newRoles: { + type: 'array', + items: { type: 'string' }, + example: ['admin', 'owner'], + }, + }, + }, + }, + }, + }, + }, + }, + 400: { + description: 'Bad request - Invalid input', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + example: false, + }, + message: { + type: 'string', + example: 'New owner must be selected', + }, + }, + }, + }, + }, + }, + 401: { + description: 'Unauthorized - Invalid or missing authentication', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + statusCode: { + type: 'number', + example: 401, + }, + message: { + type: 'string', + example: 'Unauthorized', + }, + }, + }, + }, + }, + }, + 403: { + description: 'Forbidden - Only organization owner can transfer ownership', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + example: false, + }, + message: { + type: 'string', + example: 'Only the organization owner can transfer ownership', + }, + }, + }, + }, + }, + }, + 404: { + description: 'Not found - Organization or member not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + example: false, + }, + message: { + type: 'string', + example: 'New owner not found or is deactivated', + }, + }, + }, + }, + }, + }, +}; + diff --git a/apps/app/src/app/(app)/[orgId]/settings/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/page.tsx index 05d76f58b..f8ba5c8ff 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/page.tsx @@ -1,5 +1,6 @@ import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; import { DeleteOrganization } from '@/components/forms/organization/delete-organization'; +import { TransferOwnership } from '@/components/forms/organization/transfer-ownership'; import { UpdateOrganizationAdvancedMode } from '@/components/forms/organization/update-organization-advanced-mode'; import { UpdateOrganizationLogo } from '@/components/forms/organization/update-organization-logo'; import { UpdateOrganizationName } from '@/components/forms/organization/update-organization-name'; @@ -7,14 +8,21 @@ import { UpdateOrganizationWebsite } from '@/components/forms/organization/updat import { auth } from '@/utils/auth'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { db } from '@db'; +import { db, Role } from '@db'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; -import { cache } from 'react'; -export default async function OrganizationSettings() { - const organization = await organizationDetails(); +export default async function OrganizationSettings({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + console.log('[OrganizationSettings Debug] orgId:', orgId); + + const organization = await organizationDetails(orgId); const logoUrl = await getLogoUrl(organization?.logo); + const { isOwner, eligibleMembers } = await getOwnershipData(orgId); return (
@@ -24,7 +32,8 @@ export default async function OrganizationSettings() { - + +
); } @@ -49,17 +58,9 @@ export async function generateMetadata(): Promise { }; } -const organizationDetails = cache(async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.session.activeOrganizationId) { - return null; - } - +async function organizationDetails(orgId: string) { const organization = await db.organization.findUnique({ - where: { id: session?.session.activeOrganizationId }, + where: { id: orgId }, select: { name: true, id: true, @@ -70,4 +71,60 @@ const organizationDetails = cache(async () => { }); return organization; -}); +} + +async function getOwnershipData(orgId: string) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user.id) { + return { isOwner: false, eligibleMembers: [] }; + } + + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: orgId, + userId: session.user.id, + deactivated: false, + }, + }); + + const currentUserRoles = currentUserMember?.role?.split(',').map((r) => r.trim()) ?? []; + const isOwner = currentUserRoles.includes(Role.owner); + + // Only fetch eligible members if current user is owner + let eligibleMembers: Array<{ + id: string; + user: { name: string | null; email: string }; + }> = []; + + if (isOwner) { + // Get only eligible members (active, not current user) + const members = await db.member.findMany({ + where: { + organizationId: orgId, + userId: { not: session.user.id }, // Exclude current user + deactivated: false, + }, + select: { + id: true, + user: { + select: { + name: true, + email: true, + }, + }, + }, + orderBy: { + user: { + email: 'asc', + }, + }, + }); + + eligibleMembers = members; + } + + return { isOwner, eligibleMembers }; +} diff --git a/apps/app/src/components/forms/organization/delete-organization.tsx b/apps/app/src/components/forms/organization/delete-organization.tsx index f61c27423..f10bc74bc 100644 --- a/apps/app/src/components/forms/organization/delete-organization.tsx +++ b/apps/app/src/components/forms/organization/delete-organization.tsx @@ -29,7 +29,13 @@ import { redirect } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; -export function DeleteOrganization({ organizationId }: { organizationId: string }) { +export function DeleteOrganization({ + organizationId, + isOwner, +}: { + organizationId: string; + isOwner: boolean; +}) { const [value, setValue] = useState(''); const deleteOrganization = useAction(deleteOrganizationAction, { onSuccess: () => { @@ -41,6 +47,11 @@ export function DeleteOrganization({ organizationId }: { organizationId: string }, }); + // Only show delete organization section to the owner + if (!isOwner) { + return null; + } + return ( diff --git a/apps/app/src/components/forms/organization/transfer-ownership.tsx b/apps/app/src/components/forms/organization/transfer-ownership.tsx new file mode 100644 index 000000000..92cd2a990 --- /dev/null +++ b/apps/app/src/components/forms/organization/transfer-ownership.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useApi } from '@/hooks/use-api'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@comp/ui/alert-dialog'; +import { Button } from '@comp/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@comp/ui/card'; +import { Input } from '@comp/ui/input'; +import { Label } from '@comp/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@comp/ui/select'; +import { Loader2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface Member { + id: string; + user: { + name: string | null; + email: string; + }; +} + +interface TransferOwnershipProps { + members: Member[]; + isOwner: boolean; +} + +export function TransferOwnership({ members, isOwner }: TransferOwnershipProps) { + const [selectedMemberId, setSelectedMemberId] = useState(''); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [confirmationText, setConfirmationText] = useState(''); + const [isTransferring, setIsTransferring] = useState(false); + const router = useRouter(); + const api = useApi(); + + const handleTransfer = () => { + if (!selectedMemberId) { + toast.error('Please select a new owner'); + return; + } + setShowConfirmDialog(true); + }; + + const confirmTransfer = async () => { + if (!selectedMemberId) return; + + setIsTransferring(true); + + try { + const response = await api.post<{ + success: boolean; + message: string; + }>('/v1/organization/transfer-ownership', { + newOwnerId: selectedMemberId, + }); + + if (response.error || !response.data?.success) { + // Check for error in response.error (non-200 responses) or response.data.message (200 with success: false) + const errorMessage = response.error || response.data?.message || 'Failed to transfer ownership'; + toast.error(errorMessage); + return; + } + + toast.success('Ownership transferred successfully. You are now an admin.'); + setSelectedMemberId(''); + setShowConfirmDialog(false); + setConfirmationText(''); + router.refresh(); + } catch (error) { + console.error('Error transferring ownership:', error); + toast.error('Failed to transfer ownership'); + } finally { + setIsTransferring(false); + } + }; + + // Don't show this section if user is not the owner + if (!isOwner) { + return null; + } + + // Show message if there are no other members to transfer to + if (members.length === 0) { + return ( + + + Transfer ownership + +
+ Transfer the ownership of this organization to another member. You will become an + admin after the transfer. +
+
+
+ +

+ You need to add other members to your organization before you can transfer ownership. + Invite team members from the People section. +

+
+
+ ); + } + + return ( + <> + + + Transfer ownership + +
+ Transfer the ownership of this organization to another member. You will become an + admin after the transfer. +
+
+
+ + + + +
+ This action cannot be undone without the new owner transferring back. +
+ +
+
+ + + + + Are you absolutely sure? + + This will transfer ownership of the organization to the selected member. You will + become an admin and will no longer have owner privileges. This action cannot be + undone without the new owner transferring ownership back to you. + + + +
+ + setConfirmationText(e.target.value)} + placeholder="transfer" + /> +
+ + + setConfirmationText('')}>Cancel + + {isTransferring ? : null} + Transfer ownership + + +
+
+ + ); +} + diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 3a93c93b4..225a038f0 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -434,6 +434,133 @@ ] } }, + "/v1/organization/transfer-ownership": { + "post": { + "description": "Transfers organization ownership to another member. The current owner will become an admin and keep all other roles. The new owner will receive the owner role while keeping their existing roles. Only the current organization owner can perform this action. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).", + "operationId": "OrganizationController_transferOwnership_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "description": "Transfer organization ownership to another member", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "newOwnerId" + ], + "properties": { + "newOwnerId": { + "type": "string", + "description": "Member ID of the new owner", + "example": "mem_xyz789" + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "default": { + "description": "Ownership transferred successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Ownership transferred successfully" + }, + "currentOwner": { + "type": "object", + "properties": { + "memberId": { + "type": "string", + "example": "mem_abc123" + }, + "previousRoles": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "owner", + "employee" + ] + }, + "newRoles": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "admin", + "employee" + ] + } + } + }, + "newOwner": { + "type": "object", + "properties": { + "memberId": { + "type": "string", + "example": "mem_xyz789" + }, + "previousRoles": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "admin" + ] + }, + "newRoles": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "admin", + "owner" + ] + } + } + } + } + } + } + } + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Transfer organization ownership", + "tags": [ + "Organization" + ] + } + }, "/v1/people": { "get": { "description": "Returns all members for the authenticated organization with their user information. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).", From d50d0d3e27658296b2a91560b472ef47dad63049 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 8 Dec 2025 14:42:13 -0500 Subject: [PATCH 2/2] refactor(auditor): update Firecrawl API to v2 and improve error handling (#1878) --- .../tasks/auditor/generate-auditor-content.ts | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/apps/app/src/jobs/tasks/auditor/generate-auditor-content.ts b/apps/app/src/jobs/tasks/auditor/generate-auditor-content.ts index 2f1e5f0d4..f0e87dabc 100644 --- a/apps/app/src/jobs/tasks/auditor/generate-auditor-content.ts +++ b/apps/app/src/jobs/tasks/auditor/generate-auditor-content.ts @@ -1,5 +1,5 @@ import { getOrganizationContext } from '@/jobs/tasks/onboarding/onboard-organization-helpers'; -import { openai } from '@ai-sdk/openai'; +import { groq } from '@ai-sdk/groq'; import { db } from '@db'; import { logger, metadata, schemaTask } from '@trigger.dev/sdk'; import { generateText } from 'ai'; @@ -134,7 +134,8 @@ async function scrapeWebsite(website: string): Promise { throw new Error('Firecrawl API key is not configured'); } - const initialResponse = await fetch('https://api.firecrawl.dev/v1/extract', { + // Start extraction job using v2 API + const initialResponse = await fetch('https://api.firecrawl.dev/v2/extract', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -143,27 +144,31 @@ async function scrapeWebsite(website: string): Promise { body: JSON.stringify({ urls: [website], prompt: - 'Extract all text content from this website, including company information, services, mission, vision, and any other relevant business information.', - scrapeOptions: { - onlyMainContent: true, - removeBase64Images: true, - }, + 'Extract all text content from this website, including company information, services, mission, vision, and any other relevant business information. Return the content as plain text or markdown.', }), }); const initialData = await initialResponse.json(); - if (!initialData.success || !initialData.id) { + if (!initialData.success) { + logger.error('Failed to start Firecrawl extraction', { initialData }); throw new Error('Failed to start Firecrawl extraction'); } + if (!initialData.id) { + logger.error('Firecrawl did not return job ID', { initialData }); + throw new Error('Firecrawl did not return job ID'); + } + const jobId = initialData.id; const startTime = Date.now(); + logger.info('Firecrawl extraction started, polling for completion', { jobId }); + // Poll for completion while (Date.now() - startTime < MAX_POLL_DURATION_MS) { await sleep(POLL_INTERVAL_MS); - const statusResponse = await fetch(`https://api.firecrawl.dev/v1/extract/${jobId}`, { + const statusResponse = await fetch(`https://api.firecrawl.dev/v2/extract/${jobId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -173,26 +178,38 @@ async function scrapeWebsite(website: string): Promise { const statusData = await statusResponse.json(); - if (statusData.status === 'completed' && statusData.data) { + logger.info('Firecrawl status check', { + status: statusData.status, + jobId, + hasData: !!statusData.data, + }); + + if (statusData.status === 'completed') { + if (!statusData.data) { + logger.error('Firecrawl completed but no data returned', { statusData, jobId }); + throw new Error('Firecrawl extraction completed but returned no data'); + } + + // v2 API returns data as an object, convert to string for processing const extractedData = statusData.data; if (typeof extractedData === 'string') { return extractedData; } - if (typeof extractedData === 'object' && extractedData.content) { - return typeof extractedData.content === 'string' - ? extractedData.content - : JSON.stringify(extractedData.content); - } - return JSON.stringify(extractedData); + // Convert structured data to readable text format + return JSON.stringify(extractedData, null, 2); } if (statusData.status === 'failed') { + logger.error('Firecrawl extraction failed', { statusData, jobId }); throw new Error('Firecrawl extraction failed'); } if (statusData.status === 'cancelled') { + logger.error('Firecrawl extraction was cancelled', { statusData, jobId }); throw new Error('Firecrawl extraction was cancelled'); } + + // Status is still 'processing', continue polling } throw new Error('Firecrawl extraction timed out'); @@ -205,7 +222,7 @@ async function generateSectionContent( contextHubText: string, ): Promise { const { text } = await generateText({ - model: openai('gpt-4.1'), + model: groq('openai/gpt-oss-120b'), system: `You are an expert at extracting and organizing company information for audit purposes. CRITICAL RULES: