Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { z } from 'zod';
// Adjust safe-action import for colocalized structure
import { authActionClient } from '@/actions/safe-action';
import type { ActionResponse } from '@/actions/types';
import { sendUnassignedItemsNotificationEmail, type UnassignedItem } from '@comp/email';
import {
isUserUnsubscribed,
sendUnassignedItemsNotificationEmail,
type UnassignedItem,
} from '@comp/email';

const removeMemberSchema = z.object({
memberId: z.string(),
Expand Down Expand Up @@ -252,15 +256,24 @@ export const removeMember = authActionClient
const removedMemberName = targetMember.user.name || targetMember.user.email || 'Member';

if (owner) {
// Send email to the org owner
sendUnassignedItemsNotificationEmail({
email: owner.user.email,
userName: owner.user.name || owner.user.email || 'Owner',
organizationName: organization.name,
organizationId: ctx.session.activeOrganizationId,
removedMemberName,
unassignedItems,
});
// Check if owner is unsubscribed from unassigned items notifications
const unsubscribed = await isUserUnsubscribed(
db,
owner.user.email,
'unassignedItemsNotifications',
);

if (!unsubscribed) {
// Send email to the org owner
sendUnassignedItemsNotificationEmail({
email: owner.user.email,
userName: owner.user.name || owner.user.email || 'Owner',
organizationName: organization.name,
organizationId: ctx.session.activeOrganizationId,
removedMemberName,
unassignedItems,
});
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,17 @@ export function QuestionnaireDetailClient({
question: r.question,
answer: r.answer,
sources: r.sources,
failedToGenerate: (r as any).failedToGenerate ?? false,
status: (r as any).status ?? 'untouched',
_originalIndex: (r as any).originalIndex ?? index,
failedToGenerate: r.failedToGenerate ?? false,
status: r.status ?? 'untouched',
_originalIndex: r.originalIndex ?? index,
}))}
filteredResults={filteredResults?.map((r, index) => ({
question: r.question,
answer: r.answer,
sources: r.sources,
failedToGenerate: (r as any).failedToGenerate ?? false,
status: (r as any).status ?? 'untouched',
_originalIndex: (r as any).originalIndex ?? index,
failedToGenerate: r.failedToGenerate ?? false,
status: r.status ?? 'untouched',
_originalIndex: r.originalIndex ?? index,
}))}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
'use server';

import { authActionClient } from '@/actions/safe-action';
import { vendorQuestionnaireOrchestratorTask } from '@/jobs/tasks/vendors/vendor-questionnaire-orchestrator';
import { tasks } from '@trigger.dev/sdk';
import { answerQuestion } from '@/jobs/tasks/vendors/answer-question';
import { syncOrganizationEmbeddings } from '@/lib/vector';
import { logger } from '@/utils/logger';
import { headers } from 'next/headers';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const inputSchema = z.object({
questionsAndAnswers: z.array(
z.object({
question: z.string(),
answer: z.string().nullable(),
_originalIndex: z.number().optional(), // Preserves original index from QuestionnaireResult
}),
),
});
Expand All @@ -34,26 +38,99 @@ export const vendorQuestionnaireOrchestrator = authActionClient
const organizationId = session.activeOrganizationId;

try {
// Trigger the root orchestrator task - it will handle batching internally
const handle = await tasks.trigger<typeof vendorQuestionnaireOrchestratorTask>(
'vendor-questionnaire-orchestrator',
{
vendorId: `org_${organizationId}`,
logger.info('Starting auto-answer questionnaire', {
organizationId,
questionCount: questionsAndAnswers.length,
});

// Sync organization embeddings before generating answers
// Uses incremental sync: only updates what changed (much faster than full sync)
try {
await syncOrganizationEmbeddings(organizationId);
logger.info('Organization embeddings synced successfully', {
organizationId,
questionsAndAnswers,
},
});
} catch (error) {
logger.warn('Failed to sync organization embeddings', {
organizationId,
error: error instanceof Error ? error.message : 'Unknown error',
});
// Continue with existing embeddings if sync fails
}

// Filter questions that need answers (skip already answered)
// Preserve original index if provided (for single question answers)
const questionsToAnswer = questionsAndAnswers
.map((qa, index) => ({
...qa,
index: qa._originalIndex !== undefined ? qa._originalIndex : index,
}))
.filter((qa) => !qa.answer || qa.answer.trim().length === 0);

logger.info('Questions to answer', {
total: questionsAndAnswers.length,
toAnswer: questionsToAnswer.length,
});

// Process all questions in parallel by calling answerQuestion directly
// Note: metadata updates are disabled since we're not in a Trigger.dev task context
const results = await Promise.all(
questionsToAnswer.map((qa) =>
answerQuestion(
{
question: qa.question,
organizationId,
questionIndex: qa.index,
totalQuestions: questionsAndAnswers.length,
},
{ useMetadata: false },
),
),
);

// Process results
const allAnswers: Array<{
questionIndex: number;
question: string;
answer: string | null;
sources?: Array<{
sourceType: string;
sourceName?: string;
score: number;
}>;
}> = results.map((result) => ({
questionIndex: result.questionIndex,
question: result.question,
answer: result.answer,
sources: result.sources,
}));

logger.info('Auto-answer questionnaire completed', {
organizationId,
totalQuestions: questionsAndAnswers.length,
answered: allAnswers.filter((a) => a.answer).length,
});

// Revalidate the page to show updated answers
const headersList = await headers();
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
path = path.replace(/\/[a-z]{2}\//, '/');
revalidatePath(path);

return {
success: true,
data: {
taskId: handle.id, // Return orchestrator task ID for polling
answers: allAnswers,
},
};
} catch (error) {
logger.error('Failed to answer questions', {
organizationId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error instanceof Error
? error
: new Error('Failed to trigger vendor questionnaire orchestrator');
: new Error('Failed to answer questions');
}
});

Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ export function QuestionnaireResultsCards({
<div className="lg:hidden space-y-4">
{filteredResults.map((qa, index) => {
// Use originalIndex if available (from detail page), otherwise find by question text
const originalIndex = (qa as any)._originalIndex !== undefined
? (qa as any)._originalIndex
const originalIndex = qa._originalIndex !== undefined
? qa._originalIndex
: results.findIndex((r) => r.question === qa.question);
// Fallback to index if not found (shouldn't happen, but safety check)
const safeIndex = originalIndex >= 0 ? originalIndex : index;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export function QuestionnaireResultsTable({
<TableBody>
{filteredResults.map((qa, index) => {
// Use originalIndex if available (from detail page), otherwise find by question text
const originalIndex = (qa as any)._originalIndex !== undefined
? (qa as any)._originalIndex
const originalIndex = qa._originalIndex !== undefined
? qa._originalIndex
: results.findIndex((r) => r.question === qa.question);
// Fallback to index if not found (shouldn't happen, but safety check)
const safeIndex = originalIndex >= 0 ? originalIndex : index;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,8 @@ export interface QuestionAnswer {
}>;
failedToGenerate?: boolean; // Track if auto-generation was attempted but failed
status?: 'untouched' | 'generated' | 'manual'; // Track answer source: untouched, AI-generated, or manually edited
// Optional field used when converting QuestionnaireResult to QuestionAnswer for orchestrator
// Preserves the original index from QuestionnaireResult.originalIndex
_originalIndex?: number;
}

Loading
Loading