diff --git a/.env.example b/.env.example
index c4da7a6f2..886df5796 100644
--- a/.env.example
+++ b/.env.example
@@ -18,7 +18,7 @@ APP_AWS_SECRET_ACCESS_KEY="" # AWS Secret Access Key
APP_AWS_REGION="" # AWS Region
APP_AWS_BUCKET_NAME="" # AWS Bucket Name
APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET="" # AWS, Required for Security Questionnaire feature
-
+APP_AWS_KNOWLEDGE_BASE_BUCKET="" # AWS Required for the Knowledge Base feature in Security Questionnaire
TRIGGER_SECRET_KEY="" # For background jobs. Self-host or use cloud-version @ https://trigger.dev
# TRIGGER_API_URL="" # Only set if you are self-hosting
TRIGGER_API_KEY="" # API key from Trigger.dev
diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md
index c225133e9..c33f1a28e 100644
--- a/SELF_HOSTING.md
+++ b/SELF_HOSTING.md
@@ -45,6 +45,7 @@ App (`apps/app`):
- **APP_AWS_REGION**, **APP_AWS_ACCESS_KEY_ID**, **APP_AWS_SECRET_ACCESS_KEY**, **APP_AWS_BUCKET_NAME**: AWS S3 credentials for file storage (attachments, general uploads).
- **APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET**: AWS S3 bucket name specifically for questionnaire file uploads. Required for the Security Questionnaire feature. If not set, users will see an error when trying to parse questionnaires.
+- **APP_AWS_KNOWLEDGE_BASE_BUCKET**: AWS S3 bucket name specifically for knowledge base documents. Required for the Knowledge Base feature in Security Questionnaire. If not set, users will see an error when trying to upload knowledge base documents.
- **OPENAI_API_KEY**: Enables AI features that call OpenAI models.
- **UPSTASH_REDIS_REST_URL**, **UPSTASH_REDIS_REST_TOKEN**: Optional Redis (Upstash) used for rate limiting/queues/caching.
- **NEXT_PUBLIC_POSTHOG_KEY**, **NEXT_PUBLIC_POSTHOG_HOST**: Client analytics via PostHog; leave unset to disable.
@@ -151,6 +152,7 @@ NEXT_PUBLIC_BETTER_AUTH_URL_PORTAL=http://localhost:3002
# APP_AWS_SECRET_ACCESS_KEY=
# APP_AWS_BUCKET_NAME=
# APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET=
+# APP_AWS_KNOWLEDGE_BASE_BUCKET=
# OPENAI_API_KEY=
# UPSTASH_REDIS_REST_URL=
# UPSTASH_REDIS_REST_TOKEN=
diff --git a/apps/api/src/trust-portal/trust-access.controller.ts b/apps/api/src/trust-portal/trust-access.controller.ts
index 323456f55..895e3f076 100644
--- a/apps/api/src/trust-portal/trust-access.controller.ts
+++ b/apps/api/src/trust-portal/trust-access.controller.ts
@@ -14,6 +14,7 @@ import {
import {
ApiHeader,
ApiOperation,
+ ApiParam,
ApiResponse,
ApiSecurity,
ApiTags,
@@ -44,11 +45,16 @@ export class TrustAccessController {
description:
'External users submit request for data access from trust site',
})
+ @ApiParam({
+ name: 'friendlyUrl',
+ description: 'Trust Portal friendly URL or Organization ID',
+ })
@ApiResponse({
status: HttpStatus.CREATED,
description: 'Access request created and sent for review',
})
async createAccessRequest(
+ // Note: friendlyUrl can be either the custom friendly URL or the organization ID
@Param('friendlyUrl') friendlyUrl: string,
@Body() dto: CreateAccessRequestDto,
@Req() req: Request,
@@ -365,11 +371,16 @@ export class TrustAccessController {
description:
'Generate access link for users with existing grants to redownload data',
})
+ @ApiParam({
+ name: 'friendlyUrl',
+ description: 'Trust Portal friendly URL or Organization ID',
+ })
@ApiResponse({
status: HttpStatus.OK,
description: 'Access link sent to email',
})
async reclaimAccess(
+ // Note: friendlyUrl can be either the custom friendly URL or the organization ID
@Param('friendlyUrl') friendlyUrl: string,
@Body() dto: ReclaimAccessDto,
) {
diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts
index f95b883d9..930bede9b 100644
--- a/apps/api/src/trust-portal/trust-access.service.ts
+++ b/apps/api/src/trust-portal/trust-access.service.ts
@@ -28,6 +28,28 @@ export class TrustAccessService {
return randomBytes(length).toString('base64url').slice(0, length);
}
+ private async findPublishedTrustByRouteId(id: string) {
+ // First, try treating `id` as the existing friendlyUrl.
+ let trust = await db.trust.findUnique({
+ where: { friendlyUrl: id },
+ include: { organization: true },
+ });
+
+ // If none found, fall back to treating `id` as organizationId.
+ if (!trust) {
+ trust = await db.trust.findFirst({
+ where: { organizationId: id },
+ include: { organization: true },
+ });
+ }
+
+ if (!trust || trust.status !== 'published') {
+ throw new NotFoundException('Trust site not found or not published');
+ }
+
+ return trust;
+ }
+
constructor(
private readonly ndaPdfService: NdaPdfService,
private readonly emailService: TrustEmailService,
@@ -60,19 +82,12 @@ export class TrustAccessService {
}
async createAccessRequest(
- friendlyUrl: string,
+ id: string,
dto: CreateAccessRequestDto,
ipAddress: string | undefined,
userAgent: string | undefined,
) {
- const trust = await db.trust.findUnique({
- where: { friendlyUrl },
- include: { organization: true },
- });
-
- if (!trust || trust.status !== 'published') {
- throw new NotFoundException('Trust site not found or not published');
- }
+ const trust = await this.findPublishedTrustByRouteId(id);
// Check if the email already has an active grant
const existingGrant = await db.trustAccessGrant.findFirst({
@@ -470,6 +485,7 @@ export class TrustAccessService {
organization: true,
},
},
+ grant: true,
},
});
@@ -477,26 +493,59 @@ export class TrustAccessService {
throw new NotFoundException('NDA agreement not found');
}
+ const trust = await db.trust.findUnique({
+ where: { organizationId: nda.organizationId },
+ select: { friendlyUrl: true },
+ });
+
+ const portalUrl = trust?.friendlyUrl
+ ? `${this.TRUST_APP_URL}/${trust.friendlyUrl}`
+ : null;
+
+ const baseResponse = {
+ id: nda.id,
+ organizationName: nda.accessRequest.organization.name,
+ requesterName: nda.accessRequest.name,
+ requesterEmail: nda.accessRequest.email,
+ expiresAt: nda.signTokenExpiresAt,
+ portalUrl,
+ };
+
if (nda.signTokenExpiresAt < new Date()) {
- throw new BadRequestException('NDA signing link has expired');
+ return {
+ ...baseResponse,
+ status: 'expired',
+ message: 'NDA signing link has expired',
+ };
}
if (nda.status === 'void') {
- throw new BadRequestException(
- 'This NDA has been revoked and is no longer valid',
- );
+ return {
+ ...baseResponse,
+ status: 'void',
+ message: 'This NDA has been revoked and is no longer valid',
+ };
}
- if (nda.status !== 'pending') {
- throw new BadRequestException('NDA has already been signed');
+ if (nda.status === 'signed') {
+ let accessUrl = portalUrl;
+ if (nda.grant?.accessToken && nda.grant.status === 'active') {
+ if (trust?.friendlyUrl) {
+ accessUrl = `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${nda.grant.accessToken}`;
+ }
+ }
+
+ return {
+ ...baseResponse,
+ status: 'signed',
+ message: 'NDA has already been signed',
+ portalUrl: accessUrl,
+ };
}
return {
- id: nda.id,
- organizationName: nda.accessRequest.organization.name,
- requesterName: nda.accessRequest.name,
- requesterEmail: nda.accessRequest.email,
- expiresAt: nda.signTokenExpiresAt,
+ ...baseResponse,
+ status: 'pending',
};
}
@@ -791,15 +840,8 @@ export class TrustAccessService {
};
}
- async reclaimAccess(friendlyUrl: string, email: string) {
- const trust = await db.trust.findUnique({
- where: { friendlyUrl },
- include: { organization: true },
- });
-
- if (!trust || trust.status !== 'published') {
- throw new NotFoundException('Trust site not found or not published');
- }
+ async reclaimAccess(id: string, email: string) {
+ const trust = await this.findPublishedTrustByRouteId(id);
const grant = await db.trustAccessGrant.findFirst({
where: {
@@ -849,7 +891,8 @@ export class TrustAccessService {
});
}
- const accessLink = `${this.TRUST_APP_URL}/${friendlyUrl}/access/${accessToken}`;
+ const urlId = trust.friendlyUrl || trust.organizationId;
+ const accessLink = `${this.TRUST_APP_URL}/${urlId}/access/${accessToken}`;
await this.emailService.sendAccessReclaimEmail({
toEmail: email,
diff --git a/apps/app/package.json b/apps/app/package.json
index 99ab867b8..16482e763 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -75,6 +75,7 @@
"geist": "^1.3.1",
"jspdf": "^3.0.2",
"lucide-react": "^0.544.0",
+ "mammoth": "^1.11.0",
"motion": "^12.9.2",
"next": "^15.4.6",
"next-safe-action": "^8.0.3",
diff --git a/apps/app/public/badges/iso9001.svg b/apps/app/public/badges/iso9001.svg
new file mode 100644
index 000000000..64c9b6e7d
--- /dev/null
+++ b/apps/app/public/badges/iso9001.svg
@@ -0,0 +1,21 @@
+
diff --git a/apps/app/src/actions/safe-action.ts b/apps/app/src/actions/safe-action.ts
index 3814b4f65..656010ebb 100644
--- a/apps/app/src/actions/safe-action.ts
+++ b/apps/app/src/actions/safe-action.ts
@@ -68,7 +68,7 @@ export const authActionClient = actionClientWithMeta
},
});
- const { fileData: _, ...inputForLog } = clientInput as any;
+ const { fileData: _, ...inputForLog } = (clientInput || {}) as any;
logger.info('Input ->', JSON.stringify(inputForLog, null, 2));
logger.info('Result ->', JSON.stringify(result.data, null, 2));
@@ -79,7 +79,7 @@ export const authActionClient = actionClientWithMeta
return result;
})
- .use(async ({ next, metadata }) => {
+ .use(async ({ next, metadata, ctx }) => {
const headersList = await headers();
let remaining: number | undefined;
@@ -97,6 +97,7 @@ export const authActionClient = actionClientWithMeta
return next({
ctx: {
+ ...ctx,
ip: headersList.get('x-forwarded-for'),
userAgent: headersList.get('user-agent'),
ratelimit: {
@@ -106,58 +107,51 @@ export const authActionClient = actionClientWithMeta
});
})
.use(async ({ next, metadata, ctx }) => {
- const session = await auth.api.getSession({
- headers: await headers(),
- });
-
- if (!session) {
+ // Use user and session from previous middleware instead of re-fetching
+ // This ensures consistency and avoids potential security issues from stale data
+ if (!ctx.user || !ctx.session) {
throw new Error('Unauthorized');
}
if (metadata.track) {
- track(session.user.id, metadata.track.event, {
+ track(ctx.user.id, metadata.track.event, {
channel: metadata.track.channel,
- email: session.user.email,
- name: session.user.name,
- organizationId: session.session.activeOrganizationId,
+ email: ctx.user.email,
+ name: ctx.user.name,
+ organizationId: ctx.session.activeOrganizationId,
});
}
- return next({
- ctx: {
- user: session.user,
- },
- });
+ return next({ ctx });
})
- .use(async ({ next, metadata, clientInput }) => {
+ .use(async ({ next, metadata, clientInput, ctx }) => {
const headersList = await headers();
- const session = await auth.api.getSession({
- headers: headersList,
- });
-
- const member = await auth.api.getActiveMember({
- headers: headersList,
- });
-
- if (!session) {
+
+ // Use user and session from previous middleware for consistency
+ // Only fetch activeMember as it may require fresh data
+ if (!ctx.user || !ctx.session) {
throw new Error('Unauthorized');
}
- if (!session.session.activeOrganizationId) {
+ if (!ctx.session.activeOrganizationId) {
throw new Error('Organization not found');
}
+ const member = await auth.api.getActiveMember({
+ headers: headersList,
+ });
+
if (!member) {
throw new Error('Member not found');
}
- const { fileData: _, ...inputForAuditLog } = clientInput as any;
+ const { fileData: _, ...inputForAuditLog } = (clientInput || {}) as any;
const data = {
- userId: session.user.id,
- email: session.user.email,
- name: session.user.name,
- organizationId: session.session.activeOrganizationId,
+ userId: ctx.user.id,
+ email: ctx.user.email,
+ name: ctx.user.name,
+ organizationId: ctx.session.activeOrganizationId,
action: metadata.name,
input: inputForAuditLog,
ipAddress: headersList.get('x-forwarded-for') || null,
@@ -203,9 +197,9 @@ export const authActionClient = actionClientWithMeta
data: {
data: JSON.stringify(data),
memberId: member.id,
- userId: session.user.id,
+ userId: ctx.user.id,
description: metadata.track?.description || null,
- organizationId: session.session.activeOrganizationId,
+ organizationId: ctx.session.activeOrganizationId,
entityId,
entityType,
},
@@ -220,7 +214,7 @@ export const authActionClient = actionClientWithMeta
revalidatePath(path);
- return next();
+ return next({ ctx });
});
// New action client that includes organization access check
@@ -246,6 +240,7 @@ export const authWithOrgAccessClient = authActionClient.use(async ({ next, clien
return next({
ctx: {
+ ...ctx,
member,
organizationId,
},
@@ -272,7 +267,7 @@ export const authActionClientWithoutOrg = actionClientWithMeta
},
});
- const { fileData: _, ...inputForLog } = clientInput as any;
+ const { fileData: _, ...inputForLog } = (clientInput || {}) as any;
logger.info('Input ->', JSON.stringify(inputForLog, null, 2));
logger.info('Result ->', JSON.stringify(result.data, null, 2));
@@ -283,7 +278,7 @@ export const authActionClientWithoutOrg = actionClientWithMeta
return result;
})
- .use(async ({ next, metadata }) => {
+ .use(async ({ next, metadata, ctx }) => {
const headersList = await headers();
let remaining: number | undefined;
@@ -301,6 +296,7 @@ export const authActionClientWithoutOrg = actionClientWithMeta
return next({
ctx: {
+ ...ctx,
ip: headersList.get('x-forwarded-for'),
userAgent: headersList.get('user-agent'),
ratelimit: {
@@ -330,6 +326,7 @@ export const authActionClientWithoutOrg = actionClientWithMeta
return next({
ctx: {
+ ...ctx,
user: session.user,
},
});
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx
index db848d64e..e7782c4c2 100644
--- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx
@@ -48,6 +48,10 @@ export function mapFrameworkToBadge(framework: FrameworkInstanceWithControls) {
return '/badges/nen7510.svg';
}
+ if (framework.framework.name === 'ISO 9001') {
+ return '/badges/iso9001.svg';
+ }
+
return null;
}
diff --git a/apps/app/src/app/(app)/[orgId]/knowledge-base/page.tsx b/apps/app/src/app/(app)/[orgId]/knowledge-base/page.tsx
new file mode 100644
index 000000000..50e87f488
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/knowledge-base/page.tsx
@@ -0,0 +1,63 @@
+import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb';
+import { auth } from '@/utils/auth';
+import { headers } from 'next/headers';
+import { notFound } from 'next/navigation';
+import { AdditionalDocumentsSection } from '../security-questionnaire/knowledge-base/additional-documents/components';
+import { ContextSection } from '../security-questionnaire/knowledge-base/context/components';
+import { ManualAnswersSection } from '../security-questionnaire/knowledge-base/manual-answers/components';
+import { PublishedPoliciesSection } from '../security-questionnaire/knowledge-base/published-policies/components';
+import { KnowledgeBaseHeader } from '../security-questionnaire/knowledge-base/components/KnowledgeBaseHeader';
+import {
+ getContextEntries,
+ getKnowledgeBaseDocuments,
+ getManualAnswers,
+ getPublishedPolicies,
+} from '../security-questionnaire/knowledge-base/data/queries';
+
+export default async function KnowledgeBasePage() {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+
+ if (!session?.user?.id || !session?.session?.activeOrganizationId) {
+ return notFound();
+ }
+
+ const organizationId = session.session.activeOrganizationId;
+
+ // Fetch all data in parallel
+ const [policies, contextEntries, manualAnswers, documents] = await Promise.all([
+ getPublishedPolicies(organizationId),
+ getContextEntries(organizationId),
+ getManualAnswers(organizationId),
+ getKnowledgeBaseDocuments(organizationId),
+ ]);
+
+ return (
+
+
+
+
+ {/* Published Policies and Context Sections - Side by Side */}
+
+
+ {/* Manual Answers Section */}
+
+
+ {/* Additional Documents Section */}
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx
new file mode 100644
index 000000000..763ed0251
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { FileQuestion, FileText, ChevronRight } from 'lucide-react';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+
+interface QuestionnaireBreadcrumbProps {
+ filename: string;
+ organizationId: string;
+}
+
+export function QuestionnaireBreadcrumb({ filename, organizationId }: QuestionnaireBreadcrumbProps) {
+ const params = useParams();
+ const orgId = params.orgId as string;
+
+ return (
+
+ );
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx
new file mode 100644
index 000000000..471f28808
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { QuestionnaireResults } from '../../components/QuestionnaireResults';
+import { useQuestionnaireDetail } from '../../hooks/useQuestionnaireDetail';
+
+interface QuestionnaireDetailClientProps {
+ questionnaireId: string;
+ organizationId: string;
+ initialQuestions: Array<{
+ id: string;
+ question: string;
+ answer: string | null;
+ status: 'untouched' | 'generated' | 'manual';
+ questionIndex: number;
+ sources: any;
+ }>;
+ filename: string;
+}
+
+export function QuestionnaireDetailClient({
+ questionnaireId,
+ organizationId,
+ initialQuestions,
+ filename,
+}: QuestionnaireDetailClientProps) {
+ const {
+ results,
+ searchQuery,
+ setSearchQuery,
+ editingIndex,
+ editingAnswer,
+ setEditingAnswer,
+ expandedSources,
+ questionStatuses,
+ answeringQuestionIndex,
+ hasClickedAutoAnswer,
+ isLoading,
+ isAutoAnswering,
+ isExporting,
+ isSaving,
+ savingIndex,
+ filteredResults,
+ answeredCount,
+ totalCount,
+ progressPercentage,
+ handleAutoAnswer,
+ handleAnswerSingleQuestion,
+ handleEditAnswer,
+ handleSaveAnswer,
+ handleCancelEdit,
+ handleExport,
+ handleToggleSource,
+ } = useQuestionnaireDetail({
+ questionnaireId,
+ organizationId,
+ initialQuestions,
+ });
+
+ return (
+
+
+
{filename}
+
+ Review and manage answers for this questionnaire
+
+
+
({
+ question: r.question,
+ answer: r.answer,
+ sources: r.sources,
+ failedToGenerate: (r as any).failedToGenerate ?? false, // Preserve failedToGenerate from result
+ status: (r as any).status ?? 'untouched', // Preserve status field for UI behavior
+ _originalIndex: (r as any).originalIndex ?? index, // Preserve originalIndex for reference, fallback to map index
+ }))}
+ filteredResults={filteredResults?.map((r, index) => ({
+ question: r.question,
+ answer: r.answer,
+ sources: r.sources,
+ failedToGenerate: (r as any).failedToGenerate ?? false, // Preserve failedToGenerate from result
+ status: (r as any).status ?? 'untouched', // Preserve status field for UI behavior
+ _originalIndex: (r as any).originalIndex ?? index, // Preserve originalIndex for reference, fallback to map index
+ }))}
+ searchQuery={searchQuery}
+ onSearchChange={setSearchQuery}
+ editingIndex={editingIndex}
+ editingAnswer={editingAnswer}
+ onEditingAnswerChange={setEditingAnswer}
+ expandedSources={expandedSources}
+ questionStatuses={questionStatuses}
+ answeringQuestionIndex={answeringQuestionIndex}
+ hasClickedAutoAnswer={hasClickedAutoAnswer}
+ isLoading={isLoading}
+ isAutoAnswering={isAutoAnswering}
+ isExporting={isExporting}
+ isSaving={isSaving}
+ savingIndex={savingIndex}
+ showExitDialog={false}
+ onShowExitDialogChange={() => {}}
+ onExit={() => {}}
+ onAutoAnswer={handleAutoAnswer}
+ onAnswerSingleQuestion={handleAnswerSingleQuestion}
+ onEditAnswer={handleEditAnswer}
+ onSaveAnswer={handleSaveAnswer}
+ onCancelEdit={handleCancelEdit}
+ onExport={handleExport}
+ onToggleSource={handleToggleSource}
+ totalCount={totalCount}
+ answeredCount={answeredCount}
+ progressPercentage={progressPercentage}
+ />
+
+ );
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/data/queries.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/data/queries.ts
new file mode 100644
index 000000000..dd90016ee
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/data/queries.ts
@@ -0,0 +1,46 @@
+'use server';
+
+import { auth } from '@/utils/auth';
+import { db } from '@db';
+import { headers } from 'next/headers';
+import { notFound } from 'next/navigation';
+import 'server-only';
+
+export const getQuestionnaireById = async (questionnaireId: string, organizationId: string) => {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+
+ if (!session?.session?.activeOrganizationId || session.session.activeOrganizationId !== organizationId) {
+ return null;
+ }
+
+ const questionnaire = await db.questionnaire.findUnique({
+ where: {
+ id: questionnaireId,
+ organizationId,
+ },
+ include: {
+ questions: {
+ orderBy: {
+ questionIndex: 'asc',
+ },
+ select: {
+ id: true,
+ question: true,
+ answer: true,
+ status: true,
+ questionIndex: true,
+ sources: true,
+ },
+ },
+ },
+ });
+
+ if (!questionnaire) {
+ return null;
+ }
+
+ return questionnaire;
+};
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx
new file mode 100644
index 000000000..77808dd13
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx
@@ -0,0 +1,53 @@
+import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb';
+import { auth } from '@/utils/auth';
+import { headers } from 'next/headers';
+import { notFound } from 'next/navigation';
+import { QuestionnaireResults } from '../components/QuestionnaireResults';
+import { useQuestionnaireDetail } from '../hooks/useQuestionnaireDetail';
+import { getQuestionnaireById } from './data/queries';
+import { QuestionnaireDetailClient } from './components/QuestionnaireDetailClient';
+
+export default async function QuestionnaireDetailPage({
+ params,
+}: {
+ params: Promise<{ questionnaireId: string; orgId: string }>;
+}) {
+ const { questionnaireId, orgId } = await params;
+
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+
+ if (!session?.user?.id || !session?.session?.activeOrganizationId) {
+ return notFound();
+ }
+
+ const organizationId = session.session.activeOrganizationId;
+
+ if (organizationId !== orgId) {
+ return notFound();
+ }
+
+ const questionnaire = await getQuestionnaireById(questionnaireId, organizationId);
+
+ if (!questionnaire) {
+ return notFound();
+ }
+
+ return (
+
+
+
+ );
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/delete-questionnaire-answer.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/delete-questionnaire-answer.ts
new file mode 100644
index 000000000..a2878b4aa
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/delete-questionnaire-answer.ts
@@ -0,0 +1,118 @@
+'use server';
+
+import { authActionClient } from '@/actions/safe-action';
+import { db } from '@db';
+import { Prisma } from '@prisma/client';
+import { headers } from 'next/headers';
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+
+const deleteAnswerSchema = z.object({
+ questionnaireId: z.string(),
+ questionAnswerId: z.string(),
+});
+
+export const deleteQuestionnaireAnswer = authActionClient
+ .inputSchema(deleteAnswerSchema)
+ .metadata({
+ name: 'delete-questionnaire-answer',
+ track: {
+ event: 'delete-questionnaire-answer',
+ description: 'Delete Questionnaire Answer',
+ channel: 'server',
+ },
+ })
+ .action(async ({ parsedInput, ctx }) => {
+ const { questionnaireId, questionAnswerId } = parsedInput;
+ const { activeOrganizationId } = ctx.session;
+
+ if (!activeOrganizationId) {
+ return {
+ success: false,
+ error: 'Not authorized',
+ };
+ }
+
+ try {
+ // Verify questionnaire exists and belongs to organization
+ const questionnaire = await db.questionnaire.findUnique({
+ where: {
+ id: questionnaireId,
+ organizationId: activeOrganizationId,
+ },
+ });
+
+ if (!questionnaire) {
+ return {
+ success: false,
+ error: 'Questionnaire not found',
+ };
+ }
+
+ // Verify question answer exists and belongs to questionnaire
+ const questionAnswer = await db.questionnaireQuestionAnswer.findUnique({
+ where: {
+ id: questionAnswerId,
+ questionnaireId,
+ },
+ });
+
+ if (!questionAnswer) {
+ return {
+ success: false,
+ error: 'Question answer not found',
+ };
+ }
+
+ // Delete the answer (set to null and status to untouched)
+ await db.questionnaireQuestionAnswer.update({
+ where: {
+ id: questionAnswerId,
+ },
+ data: {
+ answer: null,
+ status: 'untouched',
+ sources: Prisma.JsonNull,
+ generatedAt: null,
+ updatedBy: null,
+ updatedAt: new Date(),
+ },
+ });
+
+ // Update answered questions count
+ const answeredCount = await db.questionnaireQuestionAnswer.count({
+ where: {
+ questionnaireId,
+ answer: {
+ not: null,
+ },
+ },
+ });
+
+ await db.questionnaire.update({
+ where: {
+ id: questionnaireId,
+ },
+ data: {
+ answeredQuestions: answeredCount,
+ },
+ });
+
+ const headersList = await headers();
+ let path = headersList.get('x-pathname') || headersList.get('referer') || '';
+ path = path.replace(/\/[a-z]{2}\//, '/');
+
+ revalidatePath(path);
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ console.error('Error deleting answer:', error);
+ return {
+ success: false,
+ error: 'Failed to delete answer',
+ };
+ }
+ });
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts
new file mode 100644
index 000000000..0774151a9
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts
@@ -0,0 +1,207 @@
+'use server';
+
+import { authActionClient } from '@/actions/safe-action';
+import { db } from '@db';
+import { headers } from 'next/headers';
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+import { syncManualAnswerToVector } from '@/lib/vector/sync/sync-manual-answer';
+import { logger } from '@/utils/logger';
+
+const saveAnswerSchema = z.object({
+ questionnaireId: z.string(),
+ questionIndex: z.number(),
+ answer: z.string().nullable(),
+ sources: z
+ .array(
+ z.object({
+ sourceType: z.string(),
+ sourceName: z.string().optional(),
+ sourceId: z.string().optional(),
+ policyName: z.string().optional(),
+ documentName: z.string().optional(),
+ score: z.number(),
+ }),
+ )
+ .optional(),
+ status: z.enum(['generated', 'manual']),
+});
+
+export const saveAnswerAction = authActionClient
+ .inputSchema(saveAnswerSchema)
+ .metadata({
+ name: 'save-questionnaire-answer',
+ track: {
+ event: 'save-questionnaire-answer',
+ description: 'Save Questionnaire Answer',
+ channel: 'server',
+ },
+ })
+ .action(async ({ parsedInput, ctx }) => {
+ const { questionnaireId, questionIndex, answer, sources, status } = parsedInput;
+ const { activeOrganizationId } = ctx.session;
+ const userId = ctx.user.id;
+
+ if (!activeOrganizationId) {
+ return {
+ success: false,
+ error: 'Not authorized',
+ };
+ }
+
+ try {
+ // Verify questionnaire exists and belongs to organization
+ const questionnaire = await db.questionnaire.findUnique({
+ where: {
+ id: questionnaireId,
+ organizationId: activeOrganizationId,
+ },
+ include: {
+ questions: {
+ where: {
+ questionIndex,
+ },
+ },
+ },
+ });
+
+ if (!questionnaire) {
+ return {
+ success: false,
+ error: 'Questionnaire not found',
+ };
+ }
+
+ const existingQuestion = questionnaire.questions[0];
+
+ if (existingQuestion) {
+ // Update existing question answer
+ await db.questionnaireQuestionAnswer.update({
+ where: {
+ id: existingQuestion.id,
+ },
+ data: {
+ answer: answer || null,
+ status: status === 'generated' ? 'generated' : 'manual',
+ sources: sources ? (sources as any) : null,
+ generatedAt: status === 'generated' ? new Date() : null,
+ updatedBy: status === 'manual' ? userId || null : null,
+ updatedAt: new Date(),
+ },
+ });
+
+ // If status is manual and answer exists, also save to SecurityQuestionnaireManualAnswer
+ if (status === 'manual' && answer && answer.trim().length > 0 && existingQuestion.question) {
+ try {
+ const manualAnswer = await db.securityQuestionnaireManualAnswer.upsert({
+ where: {
+ organizationId_question: {
+ organizationId: activeOrganizationId,
+ question: existingQuestion.question.trim(),
+ },
+ },
+ create: {
+ question: existingQuestion.question.trim(),
+ answer: answer.trim(),
+ tags: [],
+ organizationId: activeOrganizationId,
+ sourceQuestionnaireId: questionnaireId,
+ createdBy: userId || null,
+ updatedBy: userId || null,
+ },
+ update: {
+ answer: answer.trim(),
+ sourceQuestionnaireId: questionnaireId,
+ updatedBy: userId || null,
+ updatedAt: new Date(),
+ },
+ });
+
+ // Sync to vector DB SYNCHRONOUSLY
+ logger.info('🔄 Syncing manual answer to vector DB from save-answer', {
+ manualAnswerId: manualAnswer.id,
+ organizationId: activeOrganizationId,
+ questionIndex,
+ });
+
+ const syncResult = await syncManualAnswerToVector(
+ manualAnswer.id,
+ activeOrganizationId,
+ );
+
+ if (!syncResult.success) {
+ logger.error('❌ Failed to sync manual answer to vector DB', {
+ manualAnswerId: manualAnswer.id,
+ organizationId: activeOrganizationId,
+ error: syncResult.error,
+ });
+ // Don't fail the main operation - manual answer is saved in DB
+ } else {
+ logger.info('✅ Successfully synced manual answer to vector DB', {
+ manualAnswerId: manualAnswer.id,
+ embeddingId: syncResult.embeddingId,
+ organizationId: activeOrganizationId,
+ });
+ }
+ } catch (error) {
+ // Log error but don't fail the main operation
+ logger.error('Error saving to manual answers:', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ questionIndex,
+ organizationId: activeOrganizationId,
+ });
+ }
+ }
+ } else {
+ // Create new question answer (shouldn't happen, but handle it)
+ await db.questionnaireQuestionAnswer.create({
+ data: {
+ questionnaireId,
+ question: '', // Will be updated from parse results
+ questionIndex,
+ answer: answer || null,
+ status: status === 'generated' ? 'generated' : 'manual',
+ sources: sources ? (sources as any) : null,
+ generatedAt: status === 'generated' ? new Date() : null,
+ updatedBy: status === 'manual' ? userId || null : null,
+ },
+ });
+ }
+
+ // Update answered questions count
+ const answeredCount = await db.questionnaireQuestionAnswer.count({
+ where: {
+ questionnaireId,
+ answer: {
+ not: null,
+ },
+ },
+ });
+
+ await db.questionnaire.update({
+ where: {
+ id: questionnaireId,
+ },
+ data: {
+ answeredQuestions: answeredCount,
+ },
+ });
+
+ const headersList = await headers();
+ let path = headersList.get('x-pathname') || headersList.get('referer') || '';
+ path = path.replace(/\/[a-z]{2}\//, '/');
+
+ revalidatePath(path);
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ console.error('Error saving answer:', error);
+ return {
+ success: false,
+ error: 'Failed to save answer',
+ };
+ }
+ });
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answers-batch.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answers-batch.ts
new file mode 100644
index 000000000..f36c5b5ed
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answers-batch.ts
@@ -0,0 +1,155 @@
+'use server';
+
+import { authActionClient } from '@/actions/safe-action';
+import { db } from '@db';
+import { headers } from 'next/headers';
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+
+const saveAnswersBatchSchema = z.object({
+ questionnaireId: z.string(),
+ answers: z.array(
+ z.object({
+ questionIndex: z.number(),
+ answer: z.string().nullable(),
+ sources: z
+ .array(
+ z.object({
+ sourceType: z.string(),
+ sourceName: z.string().optional(),
+ sourceId: z.string().optional(),
+ policyName: z.string().optional(),
+ documentName: z.string().optional(),
+ score: z.number(),
+ }),
+ )
+ .optional(),
+ status: z.enum(['generated', 'manual']),
+ }),
+ ),
+});
+
+export const saveAnswersBatchAction = authActionClient
+ .inputSchema(saveAnswersBatchSchema)
+ .metadata({
+ name: 'save-questionnaire-answers-batch',
+ track: {
+ event: 'save-questionnaire-answers-batch',
+ description: 'Save Questionnaire Answers Batch',
+ channel: 'server',
+ },
+ })
+ .action(async ({ parsedInput, ctx }) => {
+ const { questionnaireId, answers } = parsedInput;
+ const { activeOrganizationId } = ctx.session;
+ const userId = ctx.user.id;
+
+ if (!activeOrganizationId) {
+ return {
+ success: false,
+ error: 'Not authorized',
+ };
+ }
+
+ try {
+ // Verify questionnaire exists and belongs to organization
+ const questionnaire = await db.questionnaire.findUnique({
+ where: {
+ id: questionnaireId,
+ organizationId: activeOrganizationId,
+ },
+ });
+
+ if (!questionnaire) {
+ return {
+ success: false,
+ error: 'Questionnaire not found',
+ };
+ }
+
+ // Get all existing questions for this questionnaire
+ const existingQuestions = await db.questionnaireQuestionAnswer.findMany({
+ where: {
+ questionnaireId,
+ },
+ });
+
+ const existingQuestionsMap = new Map(
+ existingQuestions.map((q) => [q.questionIndex, q]),
+ );
+
+ // Update or create answers
+ const updatePromises = answers.map(async (answerData) => {
+ const existing = existingQuestionsMap.get(answerData.questionIndex);
+
+ if (existing) {
+ // Update existing
+ return db.questionnaireQuestionAnswer.update({
+ where: {
+ id: existing.id,
+ },
+ data: {
+ answer: answerData.answer || null,
+ status: answerData.status === 'generated' ? 'generated' : 'manual',
+ sources: answerData.sources ? (answerData.sources as any) : null,
+ generatedAt: answerData.status === 'generated' ? new Date() : null,
+ updatedBy: answerData.status === 'manual' ? userId || null : null,
+ updatedAt: new Date(),
+ },
+ });
+ } else {
+ // Create new (shouldn't happen, but handle it)
+ return db.questionnaireQuestionAnswer.create({
+ data: {
+ questionnaireId,
+ question: '', // Will be updated from parse results
+ questionIndex: answerData.questionIndex,
+ answer: answerData.answer || null,
+ status: answerData.status === 'generated' ? 'generated' : 'manual',
+ sources: answerData.sources ? (answerData.sources as any) : null,
+ generatedAt: answerData.status === 'generated' ? new Date() : null,
+ updatedBy: answerData.status === 'manual' ? userId || null : null,
+ },
+ });
+ }
+ });
+
+ await Promise.all(updatePromises);
+
+ // Update answered questions count
+ const answeredCount = await db.questionnaireQuestionAnswer.count({
+ where: {
+ questionnaireId,
+ answer: {
+ not: null,
+ },
+ },
+ });
+
+ await db.questionnaire.update({
+ where: {
+ id: questionnaireId,
+ },
+ data: {
+ answeredQuestions: answeredCount,
+ },
+ });
+
+ const headersList = await headers();
+ let path = headersList.get('x-pathname') || headersList.get('referer') || '';
+ path = path.replace(/\/[a-z]{2}\//, '/');
+
+ revalidatePath(path);
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ console.error('Error saving answers batch:', error);
+ return {
+ success: false,
+ error: 'Failed to save answers',
+ };
+ }
+ });
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts
new file mode 100644
index 000000000..ad55ed9b3
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts
@@ -0,0 +1,182 @@
+'use server';
+
+import { authActionClient } from '@/actions/safe-action';
+import { db } from '@db';
+import { headers } from 'next/headers';
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+import { syncManualAnswerToVector } from '@/lib/vector/sync/sync-manual-answer';
+import { logger } from '@/utils/logger';
+
+const updateAnswerSchema = z.object({
+ questionnaireId: z.string(),
+ questionAnswerId: z.string(),
+ answer: z.string(),
+});
+
+export const updateQuestionnaireAnswer = authActionClient
+ .inputSchema(updateAnswerSchema)
+ .metadata({
+ name: 'update-questionnaire-answer',
+ track: {
+ event: 'update-questionnaire-answer',
+ description: 'Update Questionnaire Answer',
+ channel: 'server',
+ },
+ })
+ .action(async ({ parsedInput, ctx }) => {
+ const { questionnaireId, questionAnswerId, answer } = parsedInput;
+ const { activeOrganizationId } = ctx.session;
+ const userId = ctx.user.id;
+
+ if (!activeOrganizationId) {
+ return {
+ success: false,
+ error: 'Not authorized',
+ };
+ }
+
+ try {
+ // Verify questionnaire exists and belongs to organization
+ const questionnaire = await db.questionnaire.findUnique({
+ where: {
+ id: questionnaireId,
+ organizationId: activeOrganizationId,
+ },
+ });
+
+ if (!questionnaire) {
+ return {
+ success: false,
+ error: 'Questionnaire not found',
+ };
+ }
+
+ // Verify question answer exists and belongs to questionnaire
+ const questionAnswer = await db.questionnaireQuestionAnswer.findUnique({
+ where: {
+ id: questionAnswerId,
+ questionnaireId,
+ },
+ });
+
+ if (!questionAnswer) {
+ return {
+ success: false,
+ error: 'Question answer not found',
+ };
+ }
+
+ // Update the answer
+ await db.questionnaireQuestionAnswer.update({
+ where: {
+ id: questionAnswerId,
+ },
+ data: {
+ answer: answer.trim() || null,
+ status: 'manual',
+ updatedBy: userId || null,
+ updatedAt: new Date(),
+ },
+ });
+
+ // Also save to SecurityQuestionnaireManualAnswer if answer exists
+ if (answer && answer.trim().length > 0 && questionAnswer.question) {
+ try {
+ const manualAnswer = await db.securityQuestionnaireManualAnswer.upsert({
+ where: {
+ organizationId_question: {
+ organizationId: activeOrganizationId,
+ question: questionAnswer.question.trim(),
+ },
+ },
+ create: {
+ question: questionAnswer.question.trim(),
+ answer: answer.trim(),
+ tags: [],
+ organizationId: activeOrganizationId,
+ sourceQuestionnaireId: questionnaireId,
+ createdBy: userId || null,
+ updatedBy: userId || null,
+ },
+ update: {
+ answer: answer.trim(),
+ sourceQuestionnaireId: questionnaireId,
+ updatedBy: userId || null,
+ updatedAt: new Date(),
+ },
+ });
+
+ // Sync to vector DB SYNCHRONOUSLY
+ logger.info('🔄 Syncing manual answer to vector DB from questionnaire update', {
+ manualAnswerId: manualAnswer.id,
+ organizationId: activeOrganizationId,
+ questionId: questionAnswerId,
+ });
+
+ const syncResult = await syncManualAnswerToVector(
+ manualAnswer.id,
+ activeOrganizationId,
+ );
+
+ if (!syncResult.success) {
+ logger.error('❌ Failed to sync manual answer to vector DB', {
+ manualAnswerId: manualAnswer.id,
+ organizationId: activeOrganizationId,
+ error: syncResult.error,
+ });
+ // Don't fail the main operation - manual answer is saved in DB
+ } else {
+ logger.info('✅ Successfully synced manual answer to vector DB', {
+ manualAnswerId: manualAnswer.id,
+ embeddingId: syncResult.embeddingId,
+ organizationId: activeOrganizationId,
+ });
+ }
+ } catch (error) {
+ // Log error but don't fail the main operation
+ logger.error('Error saving to manual answers:', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ questionAnswerId,
+ organizationId: activeOrganizationId,
+ });
+ }
+ }
+
+ // Update answered questions count
+ const answeredCount = await db.questionnaireQuestionAnswer.count({
+ where: {
+ questionnaireId,
+ answer: {
+ not: null,
+ },
+ },
+ });
+
+ await db.questionnaire.update({
+ where: {
+ id: questionnaireId,
+ },
+ data: {
+ answeredQuestions: answeredCount,
+ },
+ });
+
+ const headersList = await headers();
+ let path = headersList.get('x-pathname') || headersList.get('referer') || '';
+ path = path.replace(/\/[a-z]{2}\//, '/');
+
+ revalidatePath(path);
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ console.error('Error updating answer:', error);
+ return {
+ success: false,
+ error: 'Failed to update answer',
+ };
+ }
+ });
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/KnowledgeBaseDocumentLink.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/KnowledgeBaseDocumentLink.tsx
new file mode 100644
index 000000000..1fdf1fcdd
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/KnowledgeBaseDocumentLink.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import { LinkIcon, Loader2 } from 'lucide-react';
+import { useState } from 'react';
+import { getKnowledgeBaseDocumentViewUrlAction } from '../knowledge-base/additional-documents/actions/get-document-view-url';
+
+interface KnowledgeBaseDocumentLinkProps {
+ documentId: string;
+ sourceName: string;
+ orgId: string;
+ className?: string; // Allow custom className for different contexts (cards vs table)
+}
+
+export function KnowledgeBaseDocumentLink({
+ documentId,
+ sourceName,
+ orgId,
+ className = 'font-medium text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed',
+}: KnowledgeBaseDocumentLinkProps) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleClick = async (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (isLoading) return;
+
+ setIsLoading(true);
+ try {
+ const result = await getKnowledgeBaseDocumentViewUrlAction({
+ documentId,
+ });
+
+ if (result?.data?.success && result.data.data) {
+ const { signedUrl, viewableInBrowser } = result.data.data;
+
+ if (viewableInBrowser && signedUrl) {
+ // File can be viewed in browser - open it directly
+ window.open(signedUrl, '_blank', 'noopener,noreferrer');
+ } else {
+ // File cannot be viewed in browser - navigate to knowledge base page
+ const knowledgeBaseUrl = `/${orgId}/security-questionnaire/knowledge-base`;
+ window.open(knowledgeBaseUrl, '_blank', 'noopener,noreferrer');
+ }
+ }
+ } catch (error) {
+ console.error('Error opening knowledge base document:', error);
+ // Fallback: navigate to knowledge base page
+ const knowledgeBaseUrl = `/${orgId}/security-questionnaire/knowledge-base`;
+ window.open(knowledgeBaseUrl, '_blank', 'noopener,noreferrer');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx
index d35854f8c..3f276b371 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx
@@ -23,6 +23,8 @@ interface QuestionnaireResultsProps {
isLoading: boolean;
isAutoAnswering: boolean;
isExporting: boolean;
+ isSaving?: boolean;
+ savingIndex?: number | null;
showExitDialog: boolean;
onShowExitDialogChange: (show: boolean) => void;
onExit: () => void;
@@ -54,6 +56,8 @@ export function QuestionnaireResults({
isLoading,
isAutoAnswering,
isExporting,
+ isSaving,
+ savingIndex,
showExitDialog,
onShowExitDialogChange,
onExit,
@@ -109,6 +113,8 @@ export function QuestionnaireResults({
answeringQuestionIndex={answeringQuestionIndex}
isAutoAnswering={isAutoAnswering}
hasClickedAutoAnswer={hasClickedAutoAnswer}
+ isSaving={isSaving}
+ savingIndex={savingIndex}
onEditAnswer={onEditAnswer}
onSaveAnswer={onSaveAnswer}
onCancelEdit={onCancelEdit}
@@ -129,6 +135,8 @@ export function QuestionnaireResults({
answeringQuestionIndex={answeringQuestionIndex}
isAutoAnswering={isAutoAnswering}
hasClickedAutoAnswer={hasClickedAutoAnswer}
+ isSaving={isSaving}
+ savingIndex={savingIndex}
onEditAnswer={onEditAnswer}
onSaveAnswer={onSaveAnswer}
onCancelEdit={onCancelEdit}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx
index dbf26c2a9..ced669304 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx
@@ -2,9 +2,11 @@
import { Button } from '@comp/ui/button';
import { Textarea } from '@comp/ui/textarea';
-import { BookOpen, ChevronDown, ChevronUp, Link as LinkIcon, Loader2 } from 'lucide-react';
+import { BookOpen, ChevronDown, ChevronUp, Link as LinkIcon, Loader2, Pencil } from 'lucide-react';
import Link from 'next/link';
import type { QuestionAnswer } from './types';
+import { deduplicateSources } from '../utils/deduplicate-sources';
+import { KnowledgeBaseDocumentLink } from './KnowledgeBaseDocumentLink';
interface QuestionnaireResultsCardsProps {
orgId: string;
@@ -18,6 +20,8 @@ interface QuestionnaireResultsCardsProps {
answeringQuestionIndex: number | null;
isAutoAnswering: boolean;
hasClickedAutoAnswer: boolean;
+ isSaving?: boolean;
+ savingIndex?: number | null;
onEditAnswer: (index: number) => void;
onSaveAnswer: (index: number) => void;
onCancelEdit: () => void;
@@ -37,6 +41,8 @@ export function QuestionnaireResultsCards({
answeringQuestionIndex,
isAutoAnswering,
hasClickedAutoAnswer,
+ isSaving,
+ savingIndex,
onEditAnswer,
onSaveAnswer,
onCancelEdit,
@@ -46,19 +52,35 @@ export function QuestionnaireResultsCards({
return (
{filteredResults.map((qa, index) => {
- const originalIndex = results.findIndex((r) => r === qa);
- const isEditing = editingIndex === originalIndex;
- const questionStatus = questionStatuses.get(originalIndex);
- const isProcessing = questionStatus === 'processing';
+ // Use originalIndex if available (from detail page), otherwise find by question text
+ const originalIndex = (qa as any)._originalIndex !== undefined
+ ? (qa as any)._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;
+
+ // Deduplicate sources for this question
+ const uniqueSources = qa.sources ? deduplicateSources(qa.sources) : [];
+ const isEditing = editingIndex === safeIndex;
+ const questionStatus = questionStatuses.get(safeIndex);
+ // Determine if this question is being processed
+ // It's processing if:
+ // 1. Status is explicitly 'processing'
+ // 2. This is the single question being answered
+ // 3. Auto-answer is running and this question doesn't have an answer yet (or has empty answer)
+ const isProcessing =
+ questionStatus === 'processing' ||
+ answeringQuestionIndex === safeIndex ||
+ (isAutoAnswering && hasClickedAutoAnswer && (!qa.answer || qa.answer.trim().length === 0) && questionStatus !== 'completed');
return (
- Question {originalIndex + 1}
+ Question {safeIndex + 1}
{qa.question}
@@ -74,27 +96,47 @@ export function QuestionnaireResultsCards({
autoFocus
/>
-
) : (
<>
- {qa.answer ? (
+ {qa.answer && qa.answer.trim().length > 0 ? (
onEditAnswer(originalIndex)}
+ className="group relative rounded-xs p-3 bg-muted/30 border border-border/30 cursor-pointer transition-colors duration-150 ease-in-out hover:bg-muted/50 hover:border-primary/40"
+ onClick={() => onEditAnswer(safeIndex)}
+ title="Click to edit"
>
-
{qa.answer}
+
) : isProcessing ? (
-
+
- Generating answer...
+ Finding answer...
) : (
@@ -103,10 +145,10 @@ export function QuestionnaireResultsCards({
size="sm"
onClick={(e) => {
e.stopPropagation();
- onAnswerSingleQuestion(originalIndex);
+ onAnswerSingleQuestion(safeIndex);
}}
disabled={
- answeringQuestionIndex === originalIndex ||
+ answeringQuestionIndex === safeIndex ||
(isAutoAnswering && hasClickedAutoAnswer)
}
className="w-full justify-center"
@@ -124,7 +166,7 @@ export function QuestionnaireResultsCards({
onEditAnswer(originalIndex)}
+ onClick={() => onEditAnswer(safeIndex)}
className="w-full justify-center"
>
Write Answer
@@ -135,31 +177,33 @@ export function QuestionnaireResultsCards({
)}
- {qa.sources && qa.sources.length > 0 && (
-
+ {uniqueSources.length > 0 && (
+
onToggleSource(originalIndex)}
+ onClick={() => onToggleSource(safeIndex)}
className="h-auto p-1 text-xs text-muted-foreground hover:text-foreground w-full justify-start"
>
- {expandedSources.has(originalIndex) ? (
+ {expandedSources.has(safeIndex) ? (
<>
- Hide sources ({qa.sources.length})
+ Hide sources ({uniqueSources.length})
>
) : (
<>
- Show sources ({qa.sources.length})
+ Show sources ({uniqueSources.length})
>
)}
- {expandedSources.has(originalIndex) && (
+ {expandedSources.has(safeIndex) && (
- {qa.sources.map((source, sourceIndex) => {
+ {uniqueSources.map((source, sourceIndex) => {
const isPolicy = source.sourceType === 'policy' && source.sourceId;
+ const isKnowledgeBaseDocument =
+ source.sourceType === 'knowledge_base_document' && source.sourceId;
const sourceContent = source.sourceName || source.sourceType;
return (
@@ -175,6 +219,12 @@ export function QuestionnaireResultsCards({
{sourceContent}
+ ) : isKnowledgeBaseDocument && source.sourceId ? (
+
) : (
{sourceContent}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx
index 165f5052c..8e3e877d2 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx
@@ -3,9 +3,11 @@
import { Button } from '@comp/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@comp/ui/table';
import { Textarea } from '@comp/ui/textarea';
-import { BookOpen, ChevronDown, ChevronUp, Link as LinkIcon, Loader2, Zap } from 'lucide-react';
+import { BookOpen, ChevronDown, ChevronUp, Link as LinkIcon, Loader2, Zap, Pencil } from 'lucide-react';
import Link from 'next/link';
import type { QuestionAnswer } from './types';
+import { deduplicateSources } from '../utils/deduplicate-sources';
+import { KnowledgeBaseDocumentLink } from './KnowledgeBaseDocumentLink';
interface QuestionnaireResultsTableProps {
orgId: string;
@@ -19,6 +21,8 @@ interface QuestionnaireResultsTableProps {
answeringQuestionIndex: number | null;
isAutoAnswering: boolean;
hasClickedAutoAnswer: boolean;
+ isSaving?: boolean;
+ savingIndex?: number | null;
onEditAnswer: (index: number) => void;
onSaveAnswer: (index: number) => void;
onCancelEdit: () => void;
@@ -38,6 +42,8 @@ export function QuestionnaireResultsTable({
answeringQuestionIndex,
isAutoAnswering,
hasClickedAutoAnswer,
+ isSaving,
+ savingIndex,
onEditAnswer,
onSaveAnswer,
onCancelEdit,
@@ -56,15 +62,31 @@ export function QuestionnaireResultsTable({
{filteredResults.map((qa, index) => {
- const originalIndex = results.findIndex((r) => r === qa);
- const isEditing = editingIndex === originalIndex;
- const questionStatus = questionStatuses.get(originalIndex);
- const isProcessing = questionStatus === 'processing';
+ // Use originalIndex if available (from detail page), otherwise find by question text
+ const originalIndex = (qa as any)._originalIndex !== undefined
+ ? (qa as any)._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;
+ const isEditing = editingIndex === safeIndex;
+ const questionStatus = questionStatuses.get(safeIndex);
+ // Determine if this question is being processed
+ // It's processing if:
+ // 1. Status is explicitly 'processing'
+ // 2. This is the single question being answered
+ // 3. Auto-answer is running and this question doesn't have an answer yet (or has empty answer)
+ const isProcessing =
+ questionStatus === 'processing' ||
+ answeringQuestionIndex === safeIndex ||
+ (isAutoAnswering && hasClickedAutoAnswer && (!qa.answer || qa.answer.trim().length === 0) && questionStatus !== 'completed');
+
+ // Deduplicate sources for this question
+ const uniqueSources = qa.sources ? deduplicateSources(qa.sources) : [];
return (
-
+
- {originalIndex + 1}
+ {safeIndex + 1}
{qa.question}
@@ -79,25 +101,48 @@ export function QuestionnaireResultsTable({
autoFocus
/>
- onSaveAnswer(originalIndex)}>
- Save
+ onSaveAnswer(safeIndex)}
+ disabled={isSaving && savingIndex === safeIndex}
+ >
+ {isSaving && savingIndex === safeIndex ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ 'Save'
+ )}
-
+
Cancel
) : (
- {qa.answer ? (
-
onEditAnswer(originalIndex)}>
-
- {qa.answer}
-
+ {qa.answer && qa.answer.trim().length > 0 ? (
+
onEditAnswer(safeIndex)}
+ title="Click to edit"
+ >
+
) : isProcessing ? (
-
-
+
+
Finding answer...
) : qa.failedToGenerate ? (
@@ -108,7 +153,7 @@ export function QuestionnaireResultsTable({
{
e.stopPropagation();
- onEditAnswer(originalIndex);
+ onEditAnswer(safeIndex);
}}
variant="outline"
size="sm"
@@ -121,7 +166,7 @@ export function QuestionnaireResultsTable({
{
e.stopPropagation();
- onEditAnswer(originalIndex);
+ onEditAnswer(safeIndex);
}}
variant="outline"
size="sm"
@@ -131,11 +176,11 @@ export function QuestionnaireResultsTable({
{
e.stopPropagation();
- onAnswerSingleQuestion(originalIndex);
+ onAnswerSingleQuestion(safeIndex);
}}
disabled={
isProcessing ||
- (isAutoAnswering && answeringQuestionIndex !== originalIndex)
+ (isAutoAnswering && answeringQuestionIndex !== safeIndex)
}
size="sm"
>
@@ -145,31 +190,33 @@ export function QuestionnaireResultsTable({
)}
- {qa.sources && qa.sources.length > 0 && (
-
+ {uniqueSources.length > 0 && (
+
onToggleSource(originalIndex)}
+ onClick={() => onToggleSource(safeIndex)}
className="h-auto p-1 text-xs text-muted-foreground hover:text-foreground -ml-2"
>
- {expandedSources.has(originalIndex) ? (
+ {expandedSources.has(safeIndex) ? (
<>
- Hide sources ({qa.sources.length})
+ Hide sources ({uniqueSources.length})
>
) : (
<>
- Show sources ({qa.sources.length})
+ Show sources ({uniqueSources.length})
>
)}
- {expandedSources.has(originalIndex) && (
+ {expandedSources.has(safeIndex) && (
- {qa.sources.map((source, sourceIndex) => {
+ {uniqueSources.map((source, sourceIndex) => {
const isPolicy = source.sourceType === 'policy' && source.sourceId;
+ const isKnowledgeBaseDocument =
+ source.sourceType === 'knowledge_base_document' && source.sourceId;
const sourceContent = source.sourceName || source.sourceType;
return (
@@ -187,6 +234,13 @@ export function QuestionnaireResultsTable({
{sourceContent}
+ ) : isKnowledgeBaseDocument && source.sourceId ? (
+
) : (
{sourceContent}
)}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/SecurityQuestionnaireBreadcrumb.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/SecurityQuestionnaireBreadcrumb.tsx
new file mode 100644
index 000000000..3ae039ce4
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/SecurityQuestionnaireBreadcrumb.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import { FileQuestion } from 'lucide-react';
+
+export function SecurityQuestionnaireBreadcrumb() {
+ return (
+
+ );
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/types.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/types.ts
index 34e37f3c5..e08acb40b 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/types.ts
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/types.ts
@@ -6,8 +6,10 @@ export interface QuestionAnswer {
sourceName?: string;
sourceId?: string;
policyName?: string;
+ documentName?: string;
score: number;
}>;
failedToGenerate?: boolean; // Track if auto-generation was attempted but failed
+ status?: 'untouched' | 'generated' | 'manual'; // Track answer source: untouched, AI-generated, or manually edited
}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/usePersistGeneratedAnswers.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/usePersistGeneratedAnswers.ts
new file mode 100644
index 000000000..541796671
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/usePersistGeneratedAnswers.ts
@@ -0,0 +1,324 @@
+"use client";
+
+import { type Dispatch, type SetStateAction, useEffect, useRef } from 'react';
+import type { QuestionAnswer } from '../components/types';
+
+type PersistedQuestionAnswer = QuestionAnswer & {
+ originalIndex?: number;
+ questionAnswerId?: string;
+ status?: 'untouched' | 'generated' | 'manual';
+};
+
+type UpdateAnswerAction = {
+ execute: (...args: any[]) => unknown;
+ executeAsync: (...args: any[]) => Promise
;
+};
+
+interface UsePersistGeneratedAnswersParams {
+ questionnaireId: string | null;
+ results: TResults;
+ setResults: Dispatch>;
+ autoAnswerRun: {
+ metadata?: Record;
+ status?: string;
+ output?: unknown;
+ } | null;
+ updateAnswerAction: UpdateAnswerAction;
+ setQuestionStatuses: React.Dispatch<
+ React.SetStateAction
@@ -638,6 +685,10 @@ function ComplianceFramework({
+ ) : title === 'ISO 9001' ? (
+
+
+
) : null;
return (
diff --git a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/logos.tsx b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/logos.tsx
index 3e5999279..98089e954 100644
--- a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/logos.tsx
+++ b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/logos.tsx
@@ -179,58 +179,58 @@ export const HIPAA = (props: React.SVGProps
) => (
export const SOC2Type1 = (props: React.SVGProps) => (
@@ -290,7 +290,7 @@ export const SOC2Type2 = (props: React.SVGProps) => (
-
+
@@ -330,7 +330,7 @@ export const PCIDSS = (props: React.SVGProps) => (
d="M41.6025 85.6312C42.7148 86.1791 44.0184 86.1801 45.1316 85.634L75.3157 70.8264C76.0703 70.4562 76.9448 71.0342 76.8998 71.8736V71.8736C76.879 72.2619 76.6549 72.6104 76.3102 72.7904L44.2924 89.5162C43.7122 89.8193 43.0205 89.8193 42.4403 89.5163L10.4728 72.8169C10.1 72.6221 9.86621 72.2363 9.86621 71.8156V71.8156C9.86621 70.9788 10.7443 70.4325 11.495 70.8022L41.6025 85.6312Z"
fill="#004F3B"
/>
-
+
) => (
d="M41.6025 85.6312C42.7148 86.1791 44.0184 86.1801 45.1316 85.634L75.3157 70.8264C76.0703 70.4562 76.9448 71.0342 76.8998 71.8736C76.879 72.2619 76.6549 72.6104 76.3102 72.7904L44.2924 89.5162C43.7122 89.8193 43.0205 89.8193 42.4403 89.5163L10.4728 72.8169C10.1 72.6221 9.86621 72.2363 9.86621 71.8156C9.86621 70.9788 10.7443 70.4325 11.495 70.8022L41.6025 85.6312Z"
fill="#004F3B"
/>
-
+
) => (
/>
-
+
);
+
+export const ISO9001 = (props: React.SVGProps) => (
+
+);
diff --git a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/page.tsx
index d2cd6f276..e86bb91f5 100644
--- a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/page.tsx
@@ -31,6 +31,7 @@ export default async function TrustPortalSettings({
hipaa={trustPortal?.hipaa ?? false}
pcidss={trustPortal?.pcidss ?? false}
nen7510={trustPortal?.nen7510 ?? false}
+ iso9001={trustPortal?.iso9001 ?? false}
soc2type1Status={trustPortal?.soc2type1Status ?? 'started'}
soc2type2Status={trustPortal?.soc2type2Status ?? 'started'}
iso27001Status={trustPortal?.iso27001Status ?? 'started'}
@@ -39,6 +40,7 @@ export default async function TrustPortalSettings({
hipaaStatus={trustPortal?.hipaaStatus ?? 'started'}
pcidssStatus={trustPortal?.pcidssStatus ?? 'started'}
nen7510Status={trustPortal?.nen7510Status ?? 'started'}
+ iso9001Status={trustPortal?.iso9001Status ?? 'started'}
friendlyUrl={trustPortal?.friendlyUrl ?? null}
/>
{
pcidss: trustPortal?.pci_dss,
nen7510: trustPortal?.nen7510,
soc2type1Status: trustPortal?.soc2type1_status,
- soc2type2Status: !trustPortal?.soc2type2 && trustPortal?.soc2 ? trustPortal?.soc2_status : trustPortal?.soc2type2_status,
+ soc2type2Status:
+ !trustPortal?.soc2type2 && trustPortal?.soc2
+ ? trustPortal?.soc2_status
+ : trustPortal?.soc2type2_status,
iso27001Status: trustPortal?.iso27001_status,
iso42001Status: trustPortal?.iso42001_status,
gdprStatus: trustPortal?.gdpr_status,
hipaaStatus: trustPortal?.hipaa_status,
pcidssStatus: trustPortal?.pci_dss_status,
nen7510Status: trustPortal?.nen7510_status,
+ iso9001: trustPortal?.iso9001,
+ iso9001Status: trustPortal?.iso9001_status,
isVercelDomain: trustPortal?.isVercelDomain,
vercelVerification: trustPortal?.vercelVerification,
friendlyUrl: trustPortal?.friendlyUrl,
diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx
new file mode 100644
index 000000000..492b40de8
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx
@@ -0,0 +1,112 @@
+'use client';
+
+import type { AccessGrant } from '@/hooks/use-access-requests';
+import { Badge } from '@comp/ui/badge';
+import { Button } from '@comp/ui/button';
+import type { ColumnDef } from '@tanstack/react-table';
+import { Copy } from 'lucide-react';
+import { toast } from 'sonner';
+
+export type GrantTableRow = AccessGrant;
+
+interface GrantColumnHandlers {
+ onRevoke: (row: AccessGrant) => void;
+}
+
+export function buildGrantColumns({
+ onRevoke,
+}: GrantColumnHandlers): ColumnDef[] {
+ return [
+ {
+ id: 'date',
+ accessorKey: 'createdAt',
+ header: 'Date',
+ cell: ({ row }) => {
+ return (
+
+ {new Date(row.original.createdAt).toLocaleDateString()}
+
+ );
+ },
+ },
+ {
+ id: 'identity',
+ accessorKey: 'subjectEmail',
+ header: 'Identity',
+ cell: ({ row }) => {
+ return {row.original.subjectEmail};
+ },
+ },
+ {
+ id: 'status',
+ accessorKey: 'status',
+ header: 'Status',
+ cell: ({ row }) => {
+ const status = row.original.status;
+ return (
+
+ {status}
+
+ );
+ },
+ },
+ {
+ id: 'expires',
+ accessorKey: 'expiresAt',
+ header: 'Expires',
+ cell: ({ row }) => {
+ return (
+
+ {new Date(row.original.expiresAt).toLocaleDateString()}
+
+ );
+ },
+ },
+ {
+ id: 'revokedAt',
+ accessorKey: 'revokedAt',
+ header: 'Revoked',
+ cell: ({ row }) => {
+ if (!row.original.revokedAt) {
+ return —;
+ }
+ return (
+
+ {new Date(row.original.revokedAt).toLocaleDateString()}
+
+ );
+ },
+ },
+ {
+ id: 'actions',
+ header: 'Actions',
+ cell: ({ row }) => {
+ const grant = row.original;
+
+ if (grant.status === 'active') {
+ return (
+ onRevoke(grant)}
+ className="h-8 px-2"
+ >
+ Revoke
+
+ );
+ }
+
+ return null;
+ },
+ },
+ ];
+}
diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/grant-data-table.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/grant-data-table.tsx
new file mode 100644
index 000000000..964f1d5d0
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/trust/components/grant-data-table.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import { DataTable } from '@/components/ui/data-table/DataTable';
+import type { AccessGrant } from '@/hooks/use-access-requests';
+import { buildGrantColumns } from './grant-columns';
+
+interface GrantDataTableProps {
+ data: AccessGrant[];
+ isLoading?: boolean;
+ onRevoke: (row: AccessGrant) => void;
+}
+
+export function GrantDataTable({ data, isLoading, onRevoke }: GrantDataTableProps) {
+ const columns = buildGrantColumns({ onRevoke });
+
+ return (
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/grants-tab.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/grants-tab.tsx
index 0e2384124..72005148e 100644
--- a/apps/app/src/app/(app)/[orgId]/trust/components/grants-tab.tsx
+++ b/apps/app/src/app/(app)/[orgId]/trust/components/grants-tab.tsx
@@ -1,97 +1,57 @@
import { useAccessGrants } from '@/hooks/use-access-requests';
-import { Badge } from '@comp/ui/badge';
-import { Button } from '@comp/ui/button';
-import { Skeleton } from '@comp/ui/skeleton';
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@comp/ui/table';
+import { Input } from '@comp/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select';
import { useState } from 'react';
+import { GrantDataTable } from './grant-data-table';
import { RevokeDialog } from './revoke-dialog';
export function GrantsTab({ orgId }: { orgId: string }) {
const { data, isLoading } = useAccessGrants(orgId);
const [revokeId, setRevokeId] = useState(null);
+ const [search, setSearch] = useState('');
+ const [status, setStatus] = useState('all');
+
+ const filtered = (data ?? []).filter((grant) => {
+ const matchesSearch =
+ !search || grant.subjectEmail.toLowerCase().includes(search.toLowerCase());
+
+ const matchesStatus = status === 'all' || grant.status === status;
+ return matchesSearch && matchesStatus;
+ });
+
return (
-
-
-
-
- Email
- Status
- Expires
- Revoked
- Actions
-
-
-
- {isLoading
- ? Array.from({ length: 5 }).map((_, index) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))
- : data && data.length > 0
- ? data.map((grant) => (
-
- {grant.subjectEmail}
-
-
- {grant.status}
-
-
- {new Date(grant.expiresAt).toLocaleDateString()}
-
- {grant.revokedAt ? new Date(grant.revokedAt).toLocaleDateString() : '-'}
-
-
- {grant.status === 'active' && (
- setRevokeId(grant.id)}
- >
- Revoke
-
- )}
-
-
- ))
- : (
-
-
- No access grants yet
-
-
- )}
-
-
+
+
+ setSearch(e.target.value)}
+ className="h-8 max-w-md"
+ />
+
+
+
+
setRevokeId(row.id)}
+ />
+
{revokeId && (
setRevokeId(null)} />
)}
);
}
+
diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/request-columns.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/request-columns.tsx
new file mode 100644
index 000000000..6fd77cf39
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/trust/components/request-columns.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import type { AccessRequest } from '@/hooks/use-access-requests';
+import { Badge } from '@comp/ui/badge';
+import { Button } from '@comp/ui/button';
+import type { ColumnDef } from '@tanstack/react-table';
+
+export type RequestTableRow = AccessRequest;
+
+interface RequestColumnHandlers {
+ onApprove: (row: AccessRequest) => void;
+ onDeny: (row: AccessRequest) => void;
+ onResendNda: (row: AccessRequest) => void;
+ onPreviewNda: (row: AccessRequest) => void;
+}
+
+export function buildRequestColumns({
+ onApprove,
+ onDeny,
+ onResendNda,
+ onPreviewNda,
+}: RequestColumnHandlers): ColumnDef
[] {
+ return [
+ {
+ id: 'date',
+ accessorKey: 'createdAt',
+ header: 'Date',
+ cell: ({ row }) => {
+ return (
+
+ {new Date(row.original.createdAt).toLocaleDateString()}
+
+ );
+ },
+ },
+ {
+ id: 'identity',
+ accessorKey: 'email',
+ header: 'Identity',
+ cell: ({ row }) => {
+ return (
+
+ {row.original.name}
+ {row.original.email}
+ {row.original.company && (
+ {row.original.company}
+ )}
+
+ );
+ },
+ },
+ {
+ id: 'purpose',
+ accessorKey: 'purpose',
+ header: 'Purpose',
+ cell: ({ row }) => {
+ return (
+
+ {row.original.purpose || '—'}
+
+ );
+ },
+ },
+ {
+ id: 'duration',
+ accessorKey: 'requestedDurationDays',
+ header: 'Duration',
+ cell: ({ row }) => {
+ return {row.original.requestedDurationDays ?? 30}d;
+ },
+ },
+ {
+ id: 'status',
+ accessorKey: 'status',
+ header: 'Status',
+ cell: ({ row }) => {
+ const status = row.original.status;
+ return (
+
+ {status.replace('_', ' ')}
+
+ );
+ },
+ },
+ {
+ id: 'ndaCheck',
+ accessorKey: 'grant',
+ header: 'NDA Status',
+ cell: ({ row }) => {
+ const ndaPending = row.original.status === 'approved' && !row.original.grant;
+
+ if (ndaPending) {
+ return Pending;
+ }
+
+ if (row.original.grant) {
+ return Signed;
+ }
+
+ return —;
+ },
+ },
+ {
+ id: 'actions',
+ header: 'Actions',
+ cell: ({ row }) => {
+ const request = row.original;
+ const ndaPending = request.status === 'approved' && !request.grant;
+
+ return (
+
+ {request.status === 'under_review' && (
+ <>
+ onApprove(request)} className="h-8 px-2">
+ Approve
+
+ onDeny(request)}
+ className="h-8 px-2"
+ >
+ Deny
+
+ >
+ )}
+
+ {ndaPending && (
+ onResendNda(request)}
+ className="h-8 px-2"
+ >
+ Resend NDA
+
+ )}
+
+ onPreviewNda(request)}
+ className="h-8 px-2"
+ >
+ Preview
+
+
+ );
+ },
+ },
+ ];
+}
diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/request-data-table.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/request-data-table.tsx
new file mode 100644
index 000000000..001556659
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/trust/components/request-data-table.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { DataTable } from '@/components/ui/data-table/DataTable';
+import type { AccessRequest } from '@/hooks/use-access-requests';
+import { buildRequestColumns } from './request-columns';
+
+interface RequestDataTableProps {
+ data: AccessRequest[];
+ isLoading?: boolean;
+ onApprove: (row: AccessRequest) => void;
+ onDeny: (row: AccessRequest) => void;
+ onResendNda: (row: AccessRequest) => void;
+ onPreviewNda: (row: AccessRequest) => void;
+}
+
+export function RequestDataTable({
+ data,
+ isLoading,
+ onApprove,
+ onDeny,
+ onResendNda,
+ onPreviewNda,
+}: RequestDataTableProps) {
+ const columns = buildRequestColumns({
+ onApprove,
+ onDeny,
+ onResendNda,
+ onPreviewNda,
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/request-tab.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/request-tab.tsx
index 7ab59c057..8814fc3d2 100644
--- a/apps/app/src/app/(app)/[orgId]/trust/components/request-tab.tsx
+++ b/apps/app/src/app/(app)/[orgId]/trust/components/request-tab.tsx
@@ -1,12 +1,11 @@
import { useAccessRequests, usePreviewNda, useResendNda } from '@/hooks/use-access-requests';
-import { Badge } from '@comp/ui/badge';
-import { Button } from '@comp/ui/button';
-import { Skeleton } from '@comp/ui/skeleton';
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@comp/ui/table';
+import { Input } from '@comp/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select';
import { useState } from 'react';
import { toast } from 'sonner';
import { ApproveDialog } from './approve-dialog';
import { DenyDialog } from './deny-dialog';
+import { RequestDataTable } from './request-data-table';
export function RequestsTab({ orgId }: { orgId: string }) {
const { data, isLoading } = useAccessRequests(orgId);
@@ -15,6 +14,9 @@ export function RequestsTab({ orgId }: { orgId: string }) {
const [approveId, setApproveId] = useState(null);
const [denyId, setDenyId] = useState(null);
+ const [search, setSearch] = useState('');
+ const [status, setStatus] = useState('all');
+
const handleResendNda = (requestId: string) => {
toast.promise(resendNda(requestId), {
loading: 'Resending...',
@@ -37,135 +39,48 @@ export function RequestsTab({ orgId }: { orgId: string }) {
);
};
+ const filtered = (data ?? []).filter((request) => {
+ const matchesSearch =
+ !search ||
+ request.email.toLowerCase().includes(search.toLowerCase()) ||
+ request.name.toLowerCase().includes(search.toLowerCase()) ||
+ (request.company ?? '').toLowerCase().includes(search.toLowerCase());
+
+ const matchesStatus = status === 'all' || request.status === status;
+ return matchesSearch && matchesStatus;
+ });
+
return (
-
-
-
-
- Requested
- Name
- Email
- Company
- Purpose
- Duration
- Status
- NDA
- Actions
-
-
-
- {isLoading ? (
- Array.from({ length: 5 }).map((_, index) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))
- ) : data && data.length > 0 ? (
- data.map((request) => {
- const ndaPending = request.status === 'approved' && !request.grant;
- return (
-
- {new Date(request.createdAt).toLocaleDateString()}
- {request.name}
- {request.email}
- {request.company || '-'}
- {request.purpose || '-'}
- {request.requestedDurationDays ?? 30}d
-
-
- {request.status}
-
-
-
- {ndaPending ? (
- pending
- ) : request.grant ? (
- signed
- ) : (
- '-'
- )}
-
-
-
- setApproveId(request.id)}
- >
- Approve
-
- setDenyId(request.id)}
- >
- Deny
-
- {ndaPending && (
- handleResendNda(request.id)}
- >
- Resend NDA
-
- )}
- handlePreviewNda(request.id)}
- >
- Preview
-
-
-
-
- );
- })
- ) : (
-
-
- No access requests yet
-
-
- )}
-
-
+
+
+ setSearch(e.target.value)}
+ className="h-8 max-w-md"
+ />
+
+
+
+
setApproveId(row.id)}
+ onDeny={(row) => setDenyId(row.id)}
+ onResendNda={(row) => handleResendNda(row.id)}
+ onPreviewNda={(row) => handlePreviewNda(row.id)}
+ />
+
{approveId && (
setApproveId(null)} />
)}
@@ -173,3 +88,4 @@ export function RequestsTab({ orgId }: { orgId: string }) {
);
}
+
diff --git a/apps/app/src/app/(app)/[orgId]/trust/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/page.tsx
index ecdde9d33..967e3e262 100644
--- a/apps/app/src/app/(app)/[orgId]/trust/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/trust/page.tsx
@@ -4,18 +4,19 @@ import { TrustAccessRequestsClient } from './components/trust-access-request-cli
export default async function TrustAccessPage({ params }: { params: Promise<{ orgId: string }> }) {
const { orgId } = await params;
+
return (
-
-
-
-
Trust Access Management
-
- Manage data access requests and grants
-
+
+
+
+
+
Trust Access Management
+
Manage data access requests and grants
+
+
-
-
-
+
+
);
}
diff --git a/apps/app/src/app/s3.ts b/apps/app/src/app/s3.ts
index 6dc732e3c..f9907505f 100644
--- a/apps/app/src/app/s3.ts
+++ b/apps/app/src/app/s3.ts
@@ -6,6 +6,7 @@ const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY;
export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME;
export const APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET = process.env.APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET;
+export const APP_AWS_KNOWLEDGE_BASE_BUCKET = process.env.APP_AWS_KNOWLEDGE_BASE_BUCKET;
let s3ClientInstance: S3Client;
diff --git a/apps/app/src/components/app-onboarding.tsx b/apps/app/src/components/app-onboarding.tsx
index 6d7920829..f56465406 100644
--- a/apps/app/src/components/app-onboarding.tsx
+++ b/apps/app/src/components/app-onboarding.tsx
@@ -108,20 +108,20 @@ export function AppOnboarding({
{cta && (
- {href ? (
+ {href ? (
-
+
-
- {cta}
-
-
+
+ {cta}
+
+
{ctaDisabled && ctaTooltip && (
@@ -134,15 +134,15 @@ export function AppOnboarding({
- setOpen('true')}
+ onClick={() => setOpen('true')}
disabled={ctaDisabled}
- >
-
- {cta}
-
+ >
+
+ {cta}
+
{ctaDisabled && ctaTooltip && (
@@ -151,7 +151,7 @@ export function AppOnboarding({
)}
- )}
+ )}
)}
diff --git a/apps/app/src/components/ui/data-table/DataTable.tsx b/apps/app/src/components/ui/data-table/DataTable.tsx
index 5335d0eff..d1bc6550e 100644
--- a/apps/app/src/components/ui/data-table/DataTable.tsx
+++ b/apps/app/src/components/ui/data-table/DataTable.tsx
@@ -255,7 +255,7 @@ export function DataTable
({
{row.getVisibleCells().map((cell) => (
({ table }: DataTableHeaderProps) {
{headerGroup.headers.map((header) => (
{header.isPlaceholder ? null : (
diff --git a/apps/app/src/env.mjs b/apps/app/src/env.mjs
index 25e1e707d..423f6315f 100644
--- a/apps/app/src/env.mjs
+++ b/apps/app/src/env.mjs
@@ -30,6 +30,7 @@ export const env = createEnv({
APP_AWS_REGION: z.string().optional(),
APP_AWS_BUCKET_NAME: z.string().optional(),
APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET: z.string().optional(),
+ APP_AWS_KNOWLEDGE_BASE_BUCKET: z.string().optional(),
NEXT_PUBLIC_PORTAL_URL: z.string(),
FIRECRAWL_API_KEY: z.string().optional(),
FLEET_URL: z.string().optional(),
@@ -85,6 +86,7 @@ export const env = createEnv({
APP_AWS_REGION: process.env.APP_AWS_REGION,
APP_AWS_BUCKET_NAME: process.env.APP_AWS_BUCKET_NAME,
APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET: process.env.APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET,
+ APP_AWS_KNOWLEDGE_BASE_BUCKET: process.env.APP_AWS_KNOWLEDGE_BASE_BUCKET,
NEXT_PUBLIC_PORTAL_URL: process.env.NEXT_PUBLIC_PORTAL_URL,
FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY,
FLEET_URL: process.env.FLEET_URL,
diff --git a/apps/app/src/jobs/tasks/vector/delete-all-manual-answers-orchestrator.ts b/apps/app/src/jobs/tasks/vector/delete-all-manual-answers-orchestrator.ts
new file mode 100644
index 000000000..40d9d1c94
--- /dev/null
+++ b/apps/app/src/jobs/tasks/vector/delete-all-manual-answers-orchestrator.ts
@@ -0,0 +1,167 @@
+import { logger, metadata, task } from '@trigger.dev/sdk';
+import { db } from '@db';
+import { deleteManualAnswerTask } from './delete-manual-answer';
+
+const BATCH_SIZE = 50; // Process 50 deletions at a time in parallel
+
+/**
+ * Orchestrator task to delete all manual answers from vector database
+ * Processes deletions in parallel batches for better performance
+ */
+export const deleteAllManualAnswersOrchestratorTask = task({
+ id: 'delete-all-manual-answers-orchestrator',
+ retry: {
+ maxAttempts: 3,
+ },
+ run: async (payload: {
+ organizationId: string;
+ manualAnswerIds?: string[]; // Optional: IDs passed directly to avoid race condition
+ }) => {
+ logger.info('Starting delete all manual answers from vector DB', {
+ organizationId: payload.organizationId,
+ manualAnswerIdsProvided: !!payload.manualAnswerIds,
+ manualAnswerIdsCount: payload.manualAnswerIds?.length || 0,
+ });
+
+ try {
+ // Use provided IDs if available, otherwise fetch from DB
+ let manualAnswers: Array<{ id: string }>;
+
+ if (payload.manualAnswerIds && payload.manualAnswerIds.length > 0) {
+ // Use IDs passed directly (avoids race condition with DB deletion)
+ manualAnswers = payload.manualAnswerIds.map((id) => ({ id }));
+ logger.info('Using provided manual answer IDs', {
+ organizationId: payload.organizationId,
+ count: manualAnswers.length,
+ });
+ } else {
+ // Fallback: get all manual answers for the organization
+ // This might return empty if DB records were already deleted
+ manualAnswers = await db.securityQuestionnaireManualAnswer.findMany({
+ where: {
+ organizationId: payload.organizationId,
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ logger.info('Fetched manual answers from DB', {
+ organizationId: payload.organizationId,
+ count: manualAnswers.length,
+ });
+ }
+
+ if (manualAnswers.length === 0) {
+ logger.info('No manual answers to delete', {
+ organizationId: payload.organizationId,
+ });
+ return {
+ success: true,
+ deletedCount: 0,
+ };
+ }
+
+ // Initialize metadata for tracking progress
+ metadata.set('totalManualAnswers', manualAnswers.length);
+ metadata.set('deletedCount', 0);
+ metadata.set('failedCount', 0);
+ metadata.set('currentBatch', 0);
+ metadata.set('totalBatches', Math.ceil(manualAnswers.length / BATCH_SIZE));
+
+ let deletedCount = 0;
+ let failedCount = 0;
+
+ // Process deletions in batches
+ for (let i = 0; i < manualAnswers.length; i += BATCH_SIZE) {
+ const batch = manualAnswers.slice(i, i + BATCH_SIZE);
+ const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
+ const totalBatches = Math.ceil(manualAnswers.length / BATCH_SIZE);
+
+ logger.info(`Processing deletion batch ${batchNumber}/${totalBatches}`, {
+ batchSize: batch.length,
+ manualAnswerIds: batch.map((ma) => ma.id),
+ });
+
+ // Update metadata
+ metadata.set('currentBatch', batchNumber);
+
+ // Trigger batch deletions in parallel
+ const batchItems = batch.map((ma) => ({
+ payload: {
+ manualAnswerId: ma.id,
+ organizationId: payload.organizationId,
+ },
+ }));
+
+ const batchHandle = await deleteManualAnswerTask.batchTriggerAndWait(batchItems);
+
+ // Process batch results
+ batchHandle.runs.forEach((run, batchIdx) => {
+ const ma = batch[batchIdx];
+
+ if (run.ok && run.output) {
+ const taskResult = run.output;
+ if (taskResult.success) {
+ deletedCount++;
+ } else {
+ failedCount++;
+ logger.warn('Failed to delete manual answer from vector DB', {
+ manualAnswerId: ma.id,
+ error: taskResult.error,
+ });
+ }
+ } else {
+ failedCount++;
+ const errorMessage =
+ run.ok === false && run.error
+ ? run.error instanceof Error
+ ? run.error.message
+ : String(run.error)
+ : 'Unknown error';
+ logger.error('Task failed to delete manual answer', {
+ manualAnswerId: ma.id,
+ error: errorMessage,
+ });
+ }
+ });
+
+ // Update metadata
+ metadata.set('deletedCount', deletedCount);
+ metadata.set('failedCount', failedCount);
+
+ logger.info(`Batch ${batchNumber}/${totalBatches} completed`, {
+ batchSize: batch.length,
+ deletedSoFar: deletedCount,
+ failedSoFar: failedCount,
+ remaining: manualAnswers.length - deletedCount - failedCount,
+ });
+ }
+
+ logger.info('Delete all manual answers from vector DB completed', {
+ organizationId: payload.organizationId,
+ total: manualAnswers.length,
+ deleted: deletedCount,
+ failed: failedCount,
+ });
+
+ // Mark as completed
+ metadata.set('completed', true);
+
+ return {
+ success: true,
+ deletedCount,
+ failedCount,
+ total: manualAnswers.length,
+ };
+ } catch (error) {
+ logger.error('Failed to delete all manual answers from vector DB', {
+ organizationId: payload.organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ errorStack: error instanceof Error ? error.stack : undefined,
+ });
+ throw error;
+ }
+ },
+});
+
diff --git a/apps/app/src/jobs/tasks/vector/delete-knowledge-base-document.ts b/apps/app/src/jobs/tasks/vector/delete-knowledge-base-document.ts
new file mode 100644
index 000000000..d752af191
--- /dev/null
+++ b/apps/app/src/jobs/tasks/vector/delete-knowledge-base-document.ts
@@ -0,0 +1,264 @@
+import { logger, task } from '@trigger.dev/sdk';
+import { findEmbeddingsForSource } from '@/lib/vector/core/find-existing-embeddings';
+import { vectorIndex } from '@/lib/vector/core/client';
+import { db } from '@db';
+
+/**
+ * Task to delete all embeddings for a Knowledge Base document from vector database
+ */
+export const deleteKnowledgeBaseDocumentTask = task({
+ id: 'delete-knowledge-base-document-from-vector',
+ retry: {
+ maxAttempts: 3,
+ },
+ run: async (payload: {
+ documentId: string;
+ organizationId: string;
+ }) => {
+ logger.info('Deleting Knowledge Base document from vector DB', {
+ documentId: payload.documentId,
+ organizationId: payload.organizationId,
+ });
+
+ try {
+ // Fetch document info to use document name in query (helps find all chunks)
+ let documentName: string | undefined;
+ try {
+ const document = await db.knowledgeBaseDocument.findUnique({
+ where: {
+ id: payload.documentId,
+ organizationId: payload.organizationId,
+ },
+ select: {
+ name: true,
+ },
+ });
+ documentName = document?.name;
+ } catch (dbError) {
+ logger.warn('Could not fetch document name, proceeding without it', {
+ documentId: payload.documentId,
+ error: dbError instanceof Error ? dbError.message : 'Unknown error',
+ });
+ }
+
+ // Find all embeddings for this document
+ // Pass documentName to help find all chunks (used in query strategies)
+ const existingEmbeddings = await findEmbeddingsForSource(
+ payload.documentId,
+ 'knowledge_base_document',
+ payload.organizationId,
+ documentName, // Optional: helps find chunks semantically similar to document name
+ );
+
+ if (existingEmbeddings.length === 0) {
+ logger.info('No embeddings found for document', {
+ documentId: payload.documentId,
+ });
+ return {
+ success: true,
+ documentId: payload.documentId,
+ deletedCount: 0,
+ };
+ }
+
+ // Delete all embeddings
+ if (!vectorIndex) {
+ logger.error('Vector index not configured');
+ return {
+ success: false,
+ documentId: payload.documentId,
+ error: 'Vector index not configured',
+ };
+ }
+
+ const idsToDelete = existingEmbeddings.map((e) => e.id);
+
+ if (idsToDelete.length === 0) {
+ logger.info('No embeddings to delete for document', {
+ documentId: payload.documentId,
+ });
+ return {
+ success: true,
+ documentId: payload.documentId,
+ deletedCount: 0,
+ };
+ }
+
+ // Delete all embeddings in batches (Upstash Vector supports batch delete)
+ const batchSize = 100;
+ let deletedCount = 0;
+
+ for (let i = 0; i < idsToDelete.length; i += batchSize) {
+ const batch = idsToDelete.slice(i, i + batchSize);
+ try {
+ await vectorIndex.delete(batch);
+ deletedCount += batch.length;
+ logger.info('Deleted batch of embeddings', {
+ documentId: payload.documentId,
+ batchSize: batch.length,
+ totalDeleted: deletedCount,
+ totalToDelete: idsToDelete.length,
+ });
+ } catch (batchError) {
+ logger.error('Error deleting batch of embeddings', {
+ documentId: payload.documentId,
+ batchSize: batch.length,
+ error: batchError instanceof Error ? batchError.message : 'Unknown error',
+ });
+ // Continue with next batch even if one fails
+ }
+ }
+
+ // Verify deletion with retry logic (with delays to allow propagation)
+ // This helps catch cases where some chunks might have been missed or not found initially
+ // Use the enhanced findEmbeddingsForSource which now includes chunk content queries
+ let remainingEmbeddings = await findEmbeddingsForSource(
+ payload.documentId,
+ 'knowledge_base_document',
+ payload.organizationId,
+ documentName, // Use document name in verification queries too
+ );
+
+ logger.info('Initial verification after deletion', {
+ documentId: payload.documentId,
+ remainingCount: remainingEmbeddings.length,
+ remainingIds: remainingEmbeddings.map((e) => e.id),
+ });
+
+ // Retry deletion up to 3 times if chunks remain
+ let retryAttempt = 0;
+ const maxRetries = 3;
+
+ while (remainingEmbeddings.length > 0 && retryAttempt < maxRetries) {
+ retryAttempt++;
+ logger.warn('Some embeddings were not deleted, attempting retry deletion', {
+ documentId: payload.documentId,
+ remainingCount: remainingEmbeddings.length,
+ remainingIds: remainingEmbeddings.map((e) => e.id),
+ retryAttempt,
+ maxRetries,
+ });
+
+ // Wait before retry to allow propagation
+ await new Promise((resolve) => setTimeout(resolve, 2000 * retryAttempt)); // Increasing delay
+
+ // Try deleting remaining chunks
+ const remainingIds = remainingEmbeddings.map((e) => e.id);
+ try {
+ // Delete in batches
+ const batchSize = 100;
+ for (let i = 0; i < remainingIds.length; i += batchSize) {
+ const batch = remainingIds.slice(i, i + batchSize);
+ await vectorIndex.delete(batch);
+ deletedCount += batch.length;
+ }
+
+ logger.info('Deleted remaining embeddings in retry attempt', {
+ documentId: payload.documentId,
+ deletedCount: remainingIds.length,
+ retryAttempt,
+ });
+ } catch (retryError) {
+ logger.error('Error deleting remaining embeddings in retry attempt', {
+ documentId: payload.documentId,
+ retryAttempt,
+ error: retryError instanceof Error ? retryError.message : 'Unknown error',
+ });
+ }
+
+ // Query again to check if deletion was successful
+ await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for propagation
+ remainingEmbeddings = await findEmbeddingsForSource(
+ payload.documentId,
+ 'knowledge_base_document',
+ payload.organizationId,
+ documentName, // Use document name in retry queries too
+ );
+ }
+
+ // Final verification - if chunks still remain, try one more aggressive search
+ if (remainingEmbeddings.length > 0) {
+ logger.warn('Chunks still remain after retries, attempting final aggressive search', {
+ documentId: payload.documentId,
+ remainingCount: remainingEmbeddings.length,
+ remainingIds: remainingEmbeddings.map((e) => e.id),
+ });
+
+ // Wait a bit longer for final attempt
+ await new Promise((resolve) => setTimeout(resolve, 3000));
+
+ // Try one more time with enhanced search (now includes chunk content queries)
+ const finalRemainingEmbeddings = await findEmbeddingsForSource(
+ payload.documentId,
+ 'knowledge_base_document',
+ payload.organizationId,
+ documentName,
+ );
+
+ if (finalRemainingEmbeddings.length > 0) {
+ // Try deleting these final chunks
+ const finalIds = finalRemainingEmbeddings.map((e) => e.id);
+ try {
+ await vectorIndex.delete(finalIds);
+ deletedCount += finalIds.length;
+ logger.info('Deleted chunks in final aggressive attempt', {
+ documentId: payload.documentId,
+ deletedCount: finalIds.length,
+ });
+ } catch (finalError) {
+ logger.error('Error in final deletion attempt', {
+ documentId: payload.documentId,
+ error: finalError instanceof Error ? finalError.message : 'Unknown error',
+ });
+ }
+
+ // Final check
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ const trulyRemaining = await findEmbeddingsForSource(
+ payload.documentId,
+ 'knowledge_base_document',
+ payload.organizationId,
+ documentName,
+ );
+
+ if (trulyRemaining.length > 0) {
+ logger.error('CRITICAL: Some embeddings still remain after all deletion attempts', {
+ documentId: payload.documentId,
+ remainingCount: trulyRemaining.length,
+ remainingIds: trulyRemaining.map((e) => e.id),
+ remainingChunks: trulyRemaining.map((e) => ({
+ id: e.id,
+ sourceId: e.sourceId,
+ updatedAt: e.updatedAt,
+ })),
+ note: 'These chunks may need manual deletion or there may be a synchronization issue with Upstash Vector',
+ });
+ }
+ }
+ }
+
+ logger.info('Successfully deleted Knowledge Base document embeddings from vector DB', {
+ documentId: payload.documentId,
+ deletedCount,
+ totalFound: idsToDelete.length,
+ });
+
+ return {
+ success: true,
+ documentId: payload.documentId,
+ deletedCount,
+ };
+ } catch (error) {
+ logger.error('Error deleting Knowledge Base document from vector DB', {
+ documentId: payload.documentId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return {
+ success: false,
+ documentId: payload.documentId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ },
+});
+
diff --git a/apps/app/src/jobs/tasks/vector/delete-manual-answer.ts b/apps/app/src/jobs/tasks/vector/delete-manual-answer.ts
new file mode 100644
index 000000000..85cc52380
--- /dev/null
+++ b/apps/app/src/jobs/tasks/vector/delete-manual-answer.ts
@@ -0,0 +1,61 @@
+import { logger, task } from '@trigger.dev/sdk';
+import { deleteManualAnswerFromVector } from '@/lib/vector/sync/sync-manual-answer';
+
+/**
+ * Task to delete a single manual answer from vector database
+ * Used by orchestrator for parallel deletion
+ */
+export const deleteManualAnswerTask = task({
+ id: 'delete-manual-answer-from-vector',
+ retry: {
+ maxAttempts: 3,
+ },
+ run: async (payload: {
+ manualAnswerId: string;
+ organizationId: string;
+ }) => {
+ logger.info('Deleting manual answer from vector DB', {
+ manualAnswerId: payload.manualAnswerId,
+ organizationId: payload.organizationId,
+ });
+
+ try {
+ const result = await deleteManualAnswerFromVector(
+ payload.manualAnswerId,
+ payload.organizationId,
+ );
+
+ if (!result.success) {
+ logger.warn('Failed to delete manual answer from vector DB', {
+ manualAnswerId: payload.manualAnswerId,
+ error: result.error,
+ });
+ return {
+ success: false,
+ manualAnswerId: payload.manualAnswerId,
+ error: result.error,
+ };
+ }
+
+ logger.info('Successfully deleted manual answer from vector DB', {
+ manualAnswerId: payload.manualAnswerId,
+ });
+
+ return {
+ success: true,
+ manualAnswerId: payload.manualAnswerId,
+ };
+ } catch (error) {
+ logger.error('Error deleting manual answer from vector DB', {
+ manualAnswerId: payload.manualAnswerId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return {
+ success: false,
+ manualAnswerId: payload.manualAnswerId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ },
+});
+
diff --git a/apps/app/src/jobs/tasks/vector/helpers/extract-content-from-file.ts b/apps/app/src/jobs/tasks/vector/helpers/extract-content-from-file.ts
new file mode 100644
index 000000000..371e0f716
--- /dev/null
+++ b/apps/app/src/jobs/tasks/vector/helpers/extract-content-from-file.ts
@@ -0,0 +1,231 @@
+import { logger } from '@/utils/logger';
+import { openai } from '@ai-sdk/openai';
+import { generateText } from 'ai';
+import * as XLSX from 'xlsx';
+import mammoth from 'mammoth';
+
+const htmlEntityMap = {
+ ' ': ' ',
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+} as const;
+
+const decodeBasicHtmlEntities = (input: string) => {
+ const entityPattern = /&(nbsp|amp|lt|gt|quot);/g;
+ let decoded = input;
+ let previousValue: string;
+
+ do {
+ previousValue = decoded;
+ decoded = decoded.replace(entityPattern, (entity) => htmlEntityMap[entity as keyof typeof htmlEntityMap] ?? entity);
+ } while (decoded !== previousValue);
+
+ return decoded;
+};
+
+/**
+ * Extracts content from a file using various methods based on file type
+ * Supports: PDF, Excel (.xlsx, .xls), CSV, text files (.txt, .md), Word documents (.doc, .docx), images
+ */
+export async function extractContentFromFile(
+ fileData: string,
+ fileType: string,
+): Promise {
+ const fileBuffer = Buffer.from(fileData, 'base64');
+
+ // Handle Excel files (.xlsx, .xls)
+ if (
+ fileType === 'application/vnd.ms-excel' ||
+ fileType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
+ fileType === 'application/vnd.ms-excel.sheet.macroEnabled.12'
+ ) {
+ try {
+ const excelStartTime = Date.now();
+ const fileSizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2);
+
+ logger.info('Processing Excel file', {
+ fileType,
+ fileSizeMB,
+ });
+
+ const workbook = XLSX.read(fileBuffer, { type: 'buffer' });
+
+ // Process sheets sequentially
+ const sheets: string[] = [];
+
+ for (const sheetName of workbook.SheetNames) {
+ const worksheet = workbook.Sheets[sheetName];
+ const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
+
+ // Convert to readable text format
+ const sheetText = jsonData
+ .map((row: any) => {
+ if (Array.isArray(row)) {
+ return row.filter((cell) => cell !== null && cell !== undefined && cell !== '').join(' | ');
+ }
+ return String(row);
+ })
+ .filter((line: string) => line.trim() !== '')
+ .join('\n');
+
+ if (sheetText.trim()) {
+ sheets.push(`Sheet: ${sheetName}\n${sheetText}`);
+ }
+ }
+
+ const extractionTime = ((Date.now() - excelStartTime) / 1000).toFixed(2);
+ logger.info('Excel file processed', {
+ fileSizeMB,
+ totalSheets: workbook.SheetNames.length,
+ extractedLength: sheets.join('\n\n').length,
+ extractionTimeSeconds: extractionTime,
+ });
+
+ return sheets.join('\n\n');
+ } catch (error) {
+ throw new Error(`Failed to parse Excel file: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ // Handle CSV files
+ if (fileType === 'text/csv' || fileType === 'text/comma-separated-values') {
+ try {
+ const text = fileBuffer.toString('utf-8');
+ // Convert CSV to readable format
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
+ return lines.join('\n');
+ } catch (error) {
+ throw new Error(`Failed to parse CSV file: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ // Handle plain text files
+ if (fileType === 'text/plain' || fileType.startsWith('text/')) {
+ try {
+ return fileBuffer.toString('utf-8');
+ } catch (error) {
+ throw new Error(`Failed to read text file: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ // Handle Word documents (.docx) - extract text using mammoth library
+ if (fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
+ try {
+ const docxStartTime = Date.now();
+ const fileSizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2);
+
+ logger.info('Processing DOCX file', {
+ fileType,
+ fileSizeMB,
+ });
+
+ // Extract text from DOCX using mammoth
+ const result = await mammoth.extractRawText({ buffer: fileBuffer });
+ const text = result.value;
+
+ // Also extract formatted text (includes formatting information)
+ const formattedResult = await mammoth.convertToHtml({ buffer: fileBuffer });
+
+ // Use formatted HTML if available, otherwise use plain text
+ const extractedText = formattedResult.value || text;
+
+ const extractionTime = ((Date.now() - docxStartTime) / 1000).toFixed(2);
+ logger.info('DOCX file processed', {
+ fileSizeMB,
+ extractedLength: extractedText.length,
+ extractionTimeSeconds: extractionTime,
+ });
+
+ // Convert HTML to plain text if needed (remove HTML tags)
+ if (formattedResult.value) {
+ // Simple HTML tag removal - keep text content and decode entities safely
+ const plainText = decodeBasicHtmlEntities(
+ extractedText.replace(/<[^>]*>/g, ' '),
+ )
+ .replace(/\s+/g, ' ') // Replace multiple spaces with single space
+ .trim();
+
+ return plainText || text;
+ }
+
+ return text;
+ } catch (error) {
+ logger.error('Failed to parse DOCX file', {
+ fileType,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ throw new Error(`Failed to parse DOCX file: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ // Handle legacy Word documents (.doc) - not supported, suggest conversion
+ if (fileType === 'application/msword') {
+ throw new Error(
+ 'Legacy Word documents (.doc) are not supported. Please convert to .docx or PDF format before uploading.',
+ );
+ }
+
+ // For images and PDFs, use OpenAI vision API
+ const isImage = fileType.startsWith('image/');
+ const isPdf = fileType === 'application/pdf';
+
+ if (isImage || isPdf) {
+ const base64Data = fileData;
+ const mimeType = fileType;
+ const fileSizeMB = (Buffer.from(fileData, 'base64').length / (1024 * 1024)).toFixed(2);
+
+ logger.info('Extracting content from PDF/image using vision API', {
+ fileType: mimeType,
+ fileSizeMB,
+ });
+
+ const startTime = Date.now();
+
+ try {
+ const { text } = await generateText({
+ model: openai('gpt-4o-mini'), // Using gpt-4o-mini for better text extraction
+ messages: [
+ {
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: `Extract all text content from this document. Preserve the structure, formatting, and order of the content. Include all paragraphs, headings, lists, tables, and any other text elements. Return the extracted text in a clear, readable format.`,
+ },
+ {
+ type: 'image',
+ image: `data:${mimeType};base64,${base64Data}`,
+ },
+ ],
+ },
+ ],
+ });
+
+ const extractionTime = ((Date.now() - startTime) / 1000).toFixed(2);
+ logger.info('Content extracted from PDF/image', {
+ fileType: mimeType,
+ extractedLength: text.length,
+ extractionTimeSeconds: extractionTime,
+ });
+
+ return text;
+ } catch (error) {
+ const extractionTime = ((Date.now() - startTime) / 1000).toFixed(2);
+ logger.error('Failed to extract content from PDF/image', {
+ fileType: mimeType,
+ fileSizeMB,
+ extractionTimeSeconds: extractionTime,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ throw new Error(`Failed to extract content: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ // For other file types that might be binary formats, provide helpful error message
+ throw new Error(
+ `Unsupported file type: ${fileType}. Supported formats: PDF, images (PNG, JPG, etc.), Excel (.xlsx, .xls), CSV, text files (.txt, .md), Word documents (.docx). Legacy Word documents (.doc) should be converted to .docx or PDF.`,
+ );
+}
+
diff --git a/apps/app/src/jobs/tasks/vector/process-knowledge-base-document.ts b/apps/app/src/jobs/tasks/vector/process-knowledge-base-document.ts
new file mode 100644
index 000000000..571516fee
--- /dev/null
+++ b/apps/app/src/jobs/tasks/vector/process-knowledge-base-document.ts
@@ -0,0 +1,283 @@
+import { logger, task } from '@trigger.dev/sdk';
+import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
+import { db } from '@db';
+import { batchUpsertEmbeddings } from '@/lib/vector/core/upsert-embedding';
+import { chunkText } from '@/lib/vector/utils/chunk-text';
+import { extractContentFromFile } from './helpers/extract-content-from-file';
+
+/**
+ * Creates an S3 client instance for Trigger.dev tasks
+ */
+function createS3Client(): S3Client {
+ const region = process.env.APP_AWS_REGION || 'us-east-1';
+ const accessKeyId = process.env.APP_AWS_ACCESS_KEY_ID;
+ const secretAccessKey = process.env.APP_AWS_SECRET_ACCESS_KEY;
+
+ if (!accessKeyId || !secretAccessKey) {
+ throw new Error(
+ 'AWS S3 credentials are missing. Please set APP_AWS_ACCESS_KEY_ID and APP_AWS_SECRET_ACCESS_KEY environment variables in Trigger.dev.',
+ );
+ }
+
+ return new S3Client({
+ region,
+ credentials: {
+ accessKeyId,
+ secretAccessKey,
+ },
+ });
+}
+
+/**
+ * Extracts content from a Knowledge Base document stored in S3
+ */
+async function extractContentFromKnowledgeBaseDocument(
+ s3Key: string,
+ fileType: string,
+): Promise {
+ const knowledgeBaseBucket = process.env.APP_AWS_KNOWLEDGE_BASE_BUCKET;
+
+ if (!knowledgeBaseBucket) {
+ throw new Error('Knowledge base bucket is not configured. Please set APP_AWS_KNOWLEDGE_BASE_BUCKET environment variable in Trigger.dev.');
+ }
+
+ const s3Client = createS3Client();
+
+ const getCommand = new GetObjectCommand({
+ Bucket: knowledgeBaseBucket,
+ Key: s3Key,
+ });
+
+ const response = await s3Client.send(getCommand);
+
+ if (!response.Body) {
+ throw new Error('Failed to retrieve file from S3');
+ }
+
+ // Convert stream to buffer
+ const chunks: Uint8Array[] = [];
+ for await (const chunk of response.Body as any) {
+ chunks.push(chunk);
+ }
+ const buffer = Buffer.concat(chunks);
+ const base64Data = buffer.toString('base64');
+
+ // Use provided fileType or determine from content type
+ const detectedFileType = response.ContentType || fileType || 'application/octet-stream';
+
+ const content = await extractContentFromFile(base64Data, detectedFileType);
+
+ return content;
+}
+
+/**
+ * Task to process a Knowledge Base document and add it to the vector database
+ * Supports: PDF, Excel (.xlsx, .xls), CSV, text files (.txt, .md), Word documents (.docx), images (PNG, JPG, GIF, WebP, SVG)
+ */
+export const processKnowledgeBaseDocumentTask = task({
+ id: 'process-knowledge-base-document',
+ retry: {
+ maxAttempts: 3,
+ },
+ maxDuration: 1000 * 60 * 30, // 30 minutes for large files
+ run: async (payload: {
+ documentId: string;
+ organizationId: string;
+ }) => {
+ logger.info('Processing Knowledge Base document', {
+ documentId: payload.documentId,
+ organizationId: payload.organizationId,
+ });
+
+ try {
+ // Fetch document from database
+ const document = await db.knowledgeBaseDocument.findUnique({
+ where: {
+ id: payload.documentId,
+ organizationId: payload.organizationId,
+ },
+ });
+
+ if (!document) {
+ logger.error('Document not found', {
+ documentId: payload.documentId,
+ organizationId: payload.organizationId,
+ });
+ return {
+ success: false,
+ documentId: payload.documentId,
+ error: 'Document not found',
+ };
+ }
+
+ // Update status to processing
+ await db.knowledgeBaseDocument.update({
+ where: { id: document.id },
+ data: { processingStatus: 'processing' },
+ });
+
+ // Extract content from file in S3
+ logger.info('Extracting content from file', {
+ documentId: document.id,
+ s3Key: document.s3Key,
+ fileType: document.fileType,
+ });
+
+ const content = await extractContentFromKnowledgeBaseDocument(
+ document.s3Key,
+ document.fileType,
+ );
+
+ if (!content || content.trim().length === 0) {
+ logger.warn('No content extracted from document', {
+ documentId: document.id,
+ });
+ await db.knowledgeBaseDocument.update({
+ where: { id: document.id },
+ data: {
+ processingStatus: 'failed',
+ processedAt: new Date(),
+ },
+ });
+ return {
+ success: false,
+ documentId: document.id,
+ error: 'No content extracted from document',
+ };
+ }
+
+ logger.info('Content extracted successfully', {
+ documentId: document.id,
+ contentLength: content.length,
+ });
+
+ // Delete existing embeddings for this document (if any)
+ const { findEmbeddingsForSource } = await import('@/lib/vector/core/find-existing-embeddings');
+ const existingEmbeddings = await findEmbeddingsForSource(
+ document.id,
+ 'knowledge_base_document',
+ payload.organizationId,
+ );
+
+ if (existingEmbeddings.length > 0) {
+ const { vectorIndex } = await import('@/lib/vector/core/client');
+ if (vectorIndex) {
+ const idsToDelete = existingEmbeddings.map((e) => e.id);
+ try {
+ await vectorIndex.delete(idsToDelete);
+ logger.info('Deleted existing embeddings', {
+ documentId: document.id,
+ deletedCount: idsToDelete.length,
+ });
+ } catch (error) {
+ logger.warn('Failed to delete existing embeddings', {
+ documentId: document.id,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }
+ }
+
+ // Chunk content for embedding
+ const chunks = chunkText(content, 500, 50);
+
+ if (chunks.length === 0) {
+ logger.warn('No chunks created from content', {
+ documentId: document.id,
+ });
+ await db.knowledgeBaseDocument.update({
+ where: { id: document.id },
+ data: {
+ processingStatus: 'failed',
+ processedAt: new Date(),
+ },
+ });
+ return {
+ success: false,
+ documentId: document.id,
+ error: 'No chunks created from content',
+ };
+ }
+
+ logger.info('Created chunks for embedding', {
+ documentId: document.id,
+ chunkCount: chunks.length,
+ });
+
+ // Create embeddings for each chunk
+ const updatedAt = document.updatedAt.toISOString();
+ const chunkItems = chunks
+ .map((chunk, chunkIndex) => ({
+ id: `knowledge_base_document_${document.id}_chunk${chunkIndex}`,
+ text: chunk,
+ metadata: {
+ organizationId: payload.organizationId,
+ sourceType: 'knowledge_base_document' as const,
+ sourceId: document.id,
+ content: chunk,
+ documentName: document.name,
+ updatedAt,
+ },
+ }))
+ .filter((item) => item.text && item.text.trim().length > 0);
+
+ if (chunkItems.length > 0) {
+ await batchUpsertEmbeddings(chunkItems);
+ logger.info('Successfully created embeddings', {
+ documentId: document.id,
+ embeddingCount: chunkItems.length,
+ });
+ }
+
+ // Update status to completed
+ await db.knowledgeBaseDocument.update({
+ where: { id: document.id },
+ data: {
+ processingStatus: 'completed',
+ processedAt: new Date(),
+ },
+ });
+
+ logger.info('Successfully processed Knowledge Base document', {
+ documentId: document.id,
+ organizationId: payload.organizationId,
+ chunkCount: chunkItems.length,
+ });
+
+ return {
+ success: true,
+ documentId: document.id,
+ chunkCount: chunkItems.length,
+ };
+ } catch (error) {
+ logger.error('Error processing Knowledge Base document', {
+ documentId: payload.documentId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ errorStack: error instanceof Error ? error.stack : undefined,
+ });
+
+ // Update status to failed
+ try {
+ await db.knowledgeBaseDocument.update({
+ where: { id: payload.documentId },
+ data: {
+ processingStatus: 'failed',
+ processedAt: new Date(),
+ },
+ });
+ } catch (updateError) {
+ logger.error('Failed to update document status to failed', {
+ documentId: payload.documentId,
+ error: updateError instanceof Error ? updateError.message : 'Unknown error',
+ });
+ }
+
+ return {
+ success: false,
+ documentId: payload.documentId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ },
+});
+
diff --git a/apps/app/src/jobs/tasks/vector/process-knowledge-base-documents-orchestrator.ts b/apps/app/src/jobs/tasks/vector/process-knowledge-base-documents-orchestrator.ts
new file mode 100644
index 000000000..f84395ccf
--- /dev/null
+++ b/apps/app/src/jobs/tasks/vector/process-knowledge-base-documents-orchestrator.ts
@@ -0,0 +1,160 @@
+import { logger, metadata, task } from '@trigger.dev/sdk';
+import { processKnowledgeBaseDocumentTask } from './process-knowledge-base-document';
+
+const BATCH_SIZE = 10; // Process 10 documents at a time
+
+/**
+ * Orchestrator task to process multiple Knowledge Base documents in parallel batches
+ * Similar to vendor-questionnaire-orchestrator, this manages the processing of multiple documents
+ */
+export const processKnowledgeBaseDocumentsOrchestratorTask = task({
+ id: 'process-knowledge-base-documents-orchestrator',
+ retry: {
+ maxAttempts: 3,
+ },
+ run: async (payload: {
+ documentIds: string[];
+ organizationId: string;
+ }) => {
+ logger.info('Starting Knowledge Base documents processing orchestrator', {
+ organizationId: payload.organizationId,
+ documentCount: payload.documentIds.length,
+ });
+
+ if (payload.documentIds.length === 0) {
+ logger.info('No documents to process');
+ return {
+ success: true,
+ processed: 0,
+ failed: 0,
+ };
+ }
+
+ // Initialize metadata for tracking progress
+ metadata.set('documentsTotal', payload.documentIds.length);
+ metadata.set('documentsCompleted', 0);
+ metadata.set('documentsFailed', 0);
+ metadata.set('documentsRemaining', payload.documentIds.length);
+ metadata.set('currentBatch', 0);
+ metadata.set('totalBatches', Math.ceil(payload.documentIds.length / BATCH_SIZE));
+
+ // Initialize individual document statuses - all start as 'pending'
+ payload.documentIds.forEach((documentId, index) => {
+ metadata.set(`document_${documentId}_status`, 'pending');
+ });
+
+ const results: Array<{
+ documentId: string;
+ success: boolean;
+ chunkCount?: number;
+ error?: string;
+ }> = [];
+
+ // Process documents in batches
+ for (let i = 0; i < payload.documentIds.length; i += BATCH_SIZE) {
+ const batch = payload.documentIds.slice(i, i + BATCH_SIZE);
+ const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
+ const totalBatches = Math.ceil(payload.documentIds.length / BATCH_SIZE);
+
+ logger.info(`Processing batch ${batchNumber}/${totalBatches}`, {
+ batchSize: batch.length,
+ documentIds: batch,
+ });
+
+ // Update metadata
+ metadata.set('currentBatch', batchNumber);
+
+ // Mark documents as processing
+ batch.forEach((documentId) => {
+ metadata.set(`document_${documentId}_status`, 'processing');
+ });
+
+ // Use batchTriggerAndWait - this runs tasks in parallel and waits for all to complete
+ const batchItems = batch.map((documentId) => ({
+ payload: {
+ documentId,
+ organizationId: payload.organizationId,
+ },
+ }));
+
+ const batchHandle = await processKnowledgeBaseDocumentTask.batchTriggerAndWait(batchItems);
+
+ // Process batch results
+ batchHandle.runs.forEach((run, batchIdx) => {
+ const documentId = batch[batchIdx];
+
+ if (run.ok && run.output) {
+ const taskResult = run.output;
+ if (taskResult.success) {
+ results.push({
+ documentId,
+ success: true,
+ chunkCount: taskResult.chunkCount,
+ });
+ metadata.set(`document_${documentId}_status`, 'completed');
+ metadata.increment('documentsCompleted');
+ } else {
+ results.push({
+ documentId,
+ success: false,
+ error: taskResult.error,
+ });
+ metadata.set(`document_${documentId}_status`, 'failed');
+ metadata.increment('documentsFailed');
+ }
+ } else {
+ // Task failed
+ const errorMessage =
+ run.ok === false && run.error
+ ? run.error instanceof Error
+ ? run.error.message
+ : String(run.error)
+ : 'Unknown error';
+
+ logger.error('Document processing task failed', {
+ documentId,
+ error: errorMessage,
+ });
+ results.push({
+ documentId,
+ success: false,
+ error: errorMessage,
+ });
+ metadata.set(`document_${documentId}_status`, 'failed');
+ metadata.increment('documentsFailed');
+ }
+ });
+
+ // Update remaining count
+ const completed = results.filter((r) => r.success).length + results.filter((r) => !r.success).length;
+ metadata.set('documentsRemaining', payload.documentIds.length - completed);
+
+ logger.info(`Batch ${batchNumber}/${totalBatches} completed`, {
+ batchSize: batch.length,
+ successful: results.filter((r) => r.success).length,
+ failed: results.filter((r) => !r.success).length,
+ });
+ }
+
+ const successful = results.filter((r) => r.success).length;
+ const failed = results.filter((r) => !r.success).length;
+
+ logger.info('Knowledge Base documents processing orchestrator completed', {
+ organizationId: payload.organizationId,
+ total: payload.documentIds.length,
+ successful,
+ failed,
+ });
+
+ // Mark as completed
+ metadata.set('completed', true);
+
+ return {
+ success: true,
+ processed: successful,
+ failed,
+ results,
+ };
+ },
+});
+
diff --git a/apps/app/src/jobs/tasks/vendors/answer-question-helpers.ts b/apps/app/src/jobs/tasks/vendors/answer-question-helpers.ts
index f616ce4b5..d28520e5f 100644
--- a/apps/app/src/jobs/tasks/vendors/answer-question-helpers.ts
+++ b/apps/app/src/jobs/tasks/vendors/answer-question-helpers.ts
@@ -1,7 +1,9 @@
import { findSimilarContent } from '@/lib/vector';
+import type { SimilarContentResult } from '@/lib/vector';
import { openai } from '@ai-sdk/openai';
import { logger } from '@trigger.dev/sdk';
import { generateText } from 'ai';
+import { deduplicateSources } from '@/app/(app)/[orgId]/security-questionnaire/utils/deduplicate-sources';
export interface AnswerWithSources {
answer: string | null;
@@ -22,7 +24,7 @@ export async function generateAnswerWithRAG(
): Promise {
try {
// Find similar content from vector database
- const similarContent = await findSimilarContent(question, organizationId, 5);
+ const similarContent = await findSimilarContent(question, organizationId, 5) as SimilarContentResult[];
logger.info('Vector search results', {
question: question.substring(0, 100),
@@ -35,50 +37,35 @@ export async function generateAnswerWithRAG(
})),
});
- // Extract sources information and deduplicate by sourceName
- // Multiple chunks from the same source (same policy/context) should appear as a single source
- const sourceMap = new Map<
- string,
- {
- sourceType: string;
- sourceName?: string;
- sourceId: string;
- policyName?: string;
- score: number;
- }
- >();
-
- for (const result of similarContent) {
- // Generate sourceName first to use as deduplication key
- let sourceName: string | undefined;
- if (result.policyName) {
- sourceName = `Policy: ${result.policyName}`;
- } else if (result.vendorName && result.questionnaireQuestion) {
- sourceName = `Questionnaire: ${result.vendorName}`;
- } else if (result.contextQuestion) {
- sourceName = 'Context Q&A';
- }
-
- // Use sourceName as the unique key to prevent duplicates
- // For policies: same policy name = same source
- // For context: all context entries = single "Context Q&A" source
- const key = sourceName || result.sourceId;
-
- // If we haven't seen this source, or this chunk has a higher score, use it
- const existing = sourceMap.get(key);
- if (!existing || result.score > existing.score) {
- sourceMap.set(key, {
- sourceType: result.sourceType,
+ // Extract sources information and deduplicate using universal utility
+ // Multiple chunks from the same source (same policy/context/manual answer/knowledge base document) should appear as a single source
+ // Note: sourceName is set for some types, but knowledge_base_document will be handled by deduplication function
+ const sources = deduplicateSources(
+ similarContent.map((result) => {
+ // Use any to avoid TypeScript narrowing issues, then assert correct type
+ const r = result as any as SimilarContentResult;
+ let sourceName: string | undefined;
+ if (r.policyName) {
+ sourceName = `Policy: ${r.policyName}`;
+ } else if (r.vendorName && r.questionnaireQuestion) {
+ sourceName = `Questionnaire: ${r.vendorName}`;
+ } else if (r.contextQuestion) {
+ sourceName = 'Context Q&A';
+ } else if ((r.sourceType as string) === 'manual_answer') {
+ sourceName = 'Manual Answer';
+ }
+ // Don't set sourceName for knowledge_base_document - let deduplication function handle it with filename
+
+ return {
+ sourceType: r.sourceType,
sourceName,
- sourceId: result.sourceId,
- policyName: result.policyName,
- score: result.score,
- });
- }
- }
-
- // Convert map to array and sort by score (highest first)
- const sources = Array.from(sourceMap.values()).sort((a, b) => b.score - a.score);
+ sourceId: r.sourceId,
+ policyName: r.policyName,
+ documentName: r.documentName,
+ score: r.score,
+ };
+ }),
+ );
// If no relevant content found, return null
if (similarContent.length === 0) {
@@ -91,18 +78,31 @@ export async function generateAnswerWithRAG(
// Build context from retrieved content
const contextParts = similarContent.map((result, index) => {
+ // Use any to avoid TypeScript narrowing issues, then assert correct type
+ const r = result as any as SimilarContentResult;
let sourceInfo = '';
- if (result.policyName) {
- sourceInfo = `Source: Policy "${result.policyName}"`;
- } else if (result.vendorName && result.questionnaireQuestion) {
- sourceInfo = `Source: Questionnaire from "${result.vendorName}"`;
- } else if (result.contextQuestion) {
+ if (r.policyName) {
+ sourceInfo = `Source: Policy "${r.policyName}"`;
+ } else if (r.vendorName && r.questionnaireQuestion) {
+ sourceInfo = `Source: Questionnaire from "${r.vendorName}"`;
+ } else if (r.contextQuestion) {
sourceInfo = `Source: Context Q&A`;
+ } else if ((r.sourceType as string) === 'knowledge_base_document') {
+ const docName = r.documentName;
+ if (docName) {
+ sourceInfo = `Source: Knowledge Base Document "${docName}"`;
+ } else {
+ sourceInfo = `Source: Knowledge Base Document`;
+ }
+ } else if ((r.sourceType as string) === 'knowledge_base_document') {
+ sourceInfo = `Source: Knowledge Base Document`;
+ } else if ((r.sourceType as string) === 'manual_answer') {
+ sourceInfo = `Source: Manual Answer`;
} else {
- sourceInfo = `Source: ${result.sourceType}`;
+ sourceInfo = `Source: ${r.sourceType}`;
}
- return `[${index + 1}] ${sourceInfo}\n${result.content}`;
+ return `[${index + 1}] ${sourceInfo}\n${r.content}`;
});
const context = contextParts.join('\n\n');
diff --git a/apps/app/src/jobs/tasks/vendors/parse-questionnaire.ts b/apps/app/src/jobs/tasks/vendors/parse-questionnaire.ts
index 0facf6291..64118bc9d 100644
--- a/apps/app/src/jobs/tasks/vendors/parse-questionnaire.ts
+++ b/apps/app/src/jobs/tasks/vendors/parse-questionnaire.ts
@@ -635,6 +635,53 @@ export const parseQuestionnaireTask = task({
parseTimeSeconds: parseTime,
totalTimeSeconds: totalTime,
});
+
+ // Create questionnaire record in database
+ let questionnaireId: string;
+ try {
+ const fileName = payload.fileName || payload.url || payload.attachmentId || 'questionnaire';
+ const s3Key = payload.s3Key || '';
+ const fileType = payload.fileType || 'application/octet-stream';
+ // For s3 input, we don't have fileData, so estimate size or use 0
+ // The actual file size isn't critical for questionnaire records
+ const fileSize = payload.fileData ? Buffer.from(payload.fileData, 'base64').length : 0;
+
+ const questionnaire = await db.questionnaire.create({
+ data: {
+ filename: fileName,
+ s3Key: s3Key || '',
+ fileType,
+ fileSize,
+ organizationId: payload.organizationId,
+ status: 'completed',
+ parsedAt: new Date(),
+ totalQuestions: questionsAndAnswers.length,
+ answeredQuestions: 0,
+ questions: {
+ create: questionsAndAnswers.map((qa, index) => ({
+ question: qa.question,
+ answer: qa.answer || null,
+ questionIndex: index,
+ status: qa.answer ? 'generated' : 'untouched',
+ })),
+ },
+ },
+ });
+
+ questionnaireId = questionnaire.id;
+
+ logger.info('Questionnaire record created', {
+ questionnaireId,
+ questionCount: questionsAndAnswers.length,
+ });
+ } catch (error) {
+ logger.error('Failed to create questionnaire record', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ // Don't fail parsing if DB creation fails - we can still return results
+ // Frontend can handle saving later
+ questionnaireId = '';
+ }
// NOTE: We no longer add questionnaire Q&A pairs to the vector database
// They are not used as a source for generating answers (only Policy and Context are used)
@@ -693,6 +740,7 @@ export const parseQuestionnaireTask = task({
return {
success: true,
+ questionnaireId,
questionsAndAnswers,
extractedContent: extractedContent.substring(0, 1000), // Return first 1000 chars for preview
};
diff --git a/apps/app/src/lib/vector/README-MANUAL-ANSWERS.md b/apps/app/src/lib/vector/README-MANUAL-ANSWERS.md
new file mode 100644
index 000000000..c787b5903
--- /dev/null
+++ b/apps/app/src/lib/vector/README-MANUAL-ANSWERS.md
@@ -0,0 +1,110 @@
+# Manual Answers Vector Database Integration
+
+## Overview
+
+Manual answers are automatically synced to the Upstash Vector database to improve AI answer generation quality. This document explains how to verify embeddings and troubleshoot sync issues.
+
+## Embedding ID Format
+
+When a manual answer is saved, it gets an embedding ID in the format:
+```
+manual_answer_{manualAnswerId}
+```
+
+For example:
+- Manual Answer ID: `sqma_abc123xyz`
+- Embedding ID: `manual_answer_sqma_abc123xyz`
+
+## Verifying Embeddings
+
+### Method 1: Check Embedding ID in Response
+
+When you save a manual answer, the response includes the `embeddingId`:
+
+```typescript
+const result = await saveManualAnswer.execute({
+ question: "What is your data retention policy?",
+ answer: "We retain data for 7 years as per GDPR requirements.",
+});
+
+if (result.data?.success) {
+ console.log('Embedding ID:', result.data.embeddingId);
+ // Output: "manual_answer_sqma_abc123xyz"
+}
+```
+
+### Method 2: Search in Upstash Vector Dashboard
+
+1. Go to your Upstash Vector dashboard
+2. Use the search/filter functionality
+3. Search for the embedding ID: `manual_answer_sqma_abc123xyz`
+4. Or filter by metadata:
+ - `sourceType`: `manual_answer`
+ - `sourceId`: `sqma_abc123xyz` (the manual answer ID)
+ - `organizationId`: `org_123`
+
+## Sync Behavior
+
+### Synchronous Sync (Single Manual Answer)
+
+When a user saves a manual answer:
+1. Manual answer is saved to the database
+2. **Immediately** synced to vector DB (~1-2 seconds)
+3. Embedding ID is returned in the response
+4. Manual answer is **immediately available** for answer generation
+
+### Automatic Sync (Before Answer Generation)
+
+Before generating answers for questionnaires:
+1. `syncOrganizationEmbeddings()` is called automatically
+2. This ensures all manual answers are up-to-date
+3. Manual answers are included in the RAG search
+
+### Background Sync (Delete All)
+
+When deleting all manual answers:
+1. Orchestrator task is triggered in the background
+2. Deletions happen in parallel batches (50 at a time)
+3. Progress can be tracked via Trigger.dev dashboard
+
+## Troubleshooting
+
+### Embedding Not Found
+
+If an embedding is not found:
+
+1. **Check if sync succeeded**: Look at the `embeddingId` field in the save response - if present, sync was successful
+2. **Check logs**: Look for errors in the server logs
+3. **Manual sync**: The embedding will be synced automatically on the next `syncOrganizationEmbeddings()` call
+4. **Check Upstash Vector Dashboard**: Use the dashboard to search for the embedding ID or filter by metadata
+5. **Check Upstash Vector**: Verify the vector database is configured correctly
+
+### Sync Failed
+
+If sync fails:
+- The manual answer is still saved in the database
+- It will be synced automatically on the next organization sync
+- Check server logs for detailed error messages
+
+## Testing
+
+To verify that an embedding was created:
+
+```typescript
+// After saving a manual answer
+const saveResult = await saveManualAnswer.execute({...});
+
+if (saveResult.data?.embeddingId) {
+ console.log('Embedding ID:', saveResult.data.embeddingId);
+ // The embedding ID confirms that sync was successful
+ // You can verify it exists in the Upstash Vector Dashboard
+}
+```
+
+## Related Files
+
+- `apps/app/src/lib/vector/sync/sync-manual-answer.ts` - Sync functions
+- `apps/app/src/lib/vector/core/find-existing-embeddings.ts` - Functions to find embeddings by source
+- `apps/app/src/jobs/tasks/vector/delete-manual-answer.ts` - Single deletion task
+- `apps/app/src/jobs/tasks/vector/delete-all-manual-answers-orchestrator.ts` - Batch deletion orchestrator
+
diff --git a/apps/app/src/lib/vector/core/count-embeddings.ts b/apps/app/src/lib/vector/core/count-embeddings.ts
new file mode 100644
index 000000000..821129b8f
--- /dev/null
+++ b/apps/app/src/lib/vector/core/count-embeddings.ts
@@ -0,0 +1,142 @@
+import 'server-only';
+
+import { vectorIndex } from './client';
+import { generateEmbedding } from './generate-embedding';
+import { logger } from '@/utils/logger';
+
+/**
+ * Counts embeddings for a specific organization and source type
+ * Useful for debugging and verification
+ */
+export async function countEmbeddings(
+ organizationId: string,
+ sourceType?: 'policy' | 'context' | 'manual_answer',
+): Promise<{
+ total: number;
+ bySourceType: Record;
+ error?: string;
+}> {
+ if (!vectorIndex) {
+ return {
+ total: 0,
+ bySourceType: {},
+ error: 'Vector DB not configured',
+ };
+ }
+
+ try {
+ // Use organizationId as query to find all embeddings
+ const queryEmbedding = await generateEmbedding(organizationId);
+
+ const results = await vectorIndex.query({
+ vector: queryEmbedding,
+ topK: 1000, // Max allowed by Upstash Vector
+ includeMetadata: true,
+ });
+
+ // Filter by organizationId
+ const orgResults = results.filter((result) => {
+ const metadata = result.metadata as any;
+ return metadata?.organizationId === organizationId;
+ });
+
+ // Count by sourceType
+ const bySourceType: Record = {};
+ let total = 0;
+
+ for (const result of orgResults) {
+ const metadata = result.metadata as any;
+ const st = metadata?.sourceType || 'unknown';
+
+ if (!sourceType || st === sourceType) {
+ bySourceType[st] = (bySourceType[st] || 0) + 1;
+ total++;
+ }
+ }
+
+ logger.info('Counted embeddings', {
+ organizationId,
+ sourceType: sourceType || 'all',
+ total,
+ bySourceType,
+ });
+
+ return {
+ total,
+ bySourceType,
+ };
+ } catch (error) {
+ logger.error('Failed to count embeddings', {
+ organizationId,
+ sourceType,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return {
+ total: 0,
+ bySourceType: {},
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+}
+
+/**
+ * Lists all manual answer embeddings for an organization
+ * Useful for debugging
+ */
+export async function listManualAnswerEmbeddings(
+ organizationId: string,
+): Promise> {
+ if (!vectorIndex) {
+ return [];
+ }
+
+ try {
+ // Use organizationId as query
+ const queryEmbedding = await generateEmbedding(organizationId);
+
+ const results = await vectorIndex.query({
+ vector: queryEmbedding,
+ topK: 1000,
+ includeMetadata: true,
+ });
+
+ // Filter for manual_answer type
+ const manualAnswerEmbeddings = results
+ .filter((result) => {
+ const metadata = result.metadata as any;
+ return (
+ metadata?.organizationId === organizationId &&
+ metadata?.sourceType === 'manual_answer'
+ );
+ })
+ .map((result) => {
+ const metadata = result.metadata as any;
+ return {
+ id: String(result.id),
+ sourceId: metadata?.sourceId || '',
+ content: metadata?.content || '',
+ updatedAt: metadata?.updatedAt,
+ };
+ });
+
+ logger.info('Listed manual answer embeddings', {
+ organizationId,
+ count: manualAnswerEmbeddings.length,
+ ids: manualAnswerEmbeddings.map((e) => e.id),
+ });
+
+ return manualAnswerEmbeddings;
+ } catch (error) {
+ logger.error('Failed to list manual answer embeddings', {
+ organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return [];
+ }
+}
+
diff --git a/apps/app/src/lib/vector/core/find-existing-embeddings.ts b/apps/app/src/lib/vector/core/find-existing-embeddings.ts
index 97f973b7a..ff1284010 100644
--- a/apps/app/src/lib/vector/core/find-existing-embeddings.ts
+++ b/apps/app/src/lib/vector/core/find-existing-embeddings.ts
@@ -7,19 +7,30 @@ import { logger } from '@/utils/logger';
export interface ExistingEmbedding {
id: string;
sourceId: string;
- sourceType: 'policy' | 'context';
+ sourceType: 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document';
updatedAt?: string;
}
/**
- * Finds existing embeddings for a specific policy or context
- * On-demand approach: checks only what we need, avoids Upstash Vector 1000 limit
- * More efficient and performant than fetching all embeddings upfront
+ * Finds existing embeddings for a specific policy, context, manual answer, or knowledge base document
+ * Uses multiple query strategies to ensure we find ALL chunks:
+ * 1. Query with organizationId (finds org-wide embeddings)
+ * 2. Query with sourceId (finds source-specific embeddings)
+ * 3. Query with combined query (organizationId + sourceId)
+ * 4. Query with documentName (for knowledge_base_document, finds chunks semantically similar to filename)
+ * 5. Query with content from already-found chunks (uses both chunk content AND filename from metadata)
+ * 6. Query with generic terms (for knowledge_base_document)
+ *
+ * Note: We store `documentName` (filename) in metadata for all knowledge_base_document chunks.
+ * This allows us to use the filename as a query vector to find related chunks.
+ *
+ * This approach ensures we find all chunks even if org has >1000 total embeddings.
*/
export async function findEmbeddingsForSource(
sourceId: string,
- sourceType: 'policy' | 'context',
+ sourceType: 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document',
organizationId: string,
+ documentName?: string, // Optional: for knowledge_base_document, helps find chunks semantically similar to filename
): Promise {
if (!vectorIndex) {
return [];
@@ -30,41 +41,314 @@ export async function findEmbeddingsForSource(
}
try {
- // Create a specific query that will match this source
- // Using sourceId in the query helps find exact matches
- const queryText = sourceType === 'policy'
- ? `policy ${sourceId} security compliance`
- : `context ${sourceId} question answer`;
+ const allResults = new Map();
- const queryEmbedding = await generateEmbedding(queryText);
-
- // Use smaller topK since we're looking for specific source
- // Upstash Vector limit is 1000, but we only need a few results
- const results = await vectorIndex.query({
- vector: queryEmbedding,
- topK: 100, // Small number - we're looking for specific source
- includeMetadata: true,
- });
+ // Strategy 1: Query with organizationId
+ try {
+ const orgQueryEmbedding = await generateEmbedding(organizationId);
+ const orgResults = await vectorIndex.query({
+ vector: orgQueryEmbedding,
+ topK: 1000,
+ includeMetadata: true,
+ });
- // Filter by exact sourceId match and organizationId
- const matchingEmbeddings = results
- .filter((result) => {
+ for (const result of orgResults) {
const metadata = result.metadata as any;
- return (
+ if (
metadata?.organizationId === organizationId &&
metadata?.sourceType === sourceType &&
metadata?.sourceId === sourceId
- );
- })
- .map((result) => {
+ ) {
+ const id = String(result.id);
+ if (!allResults.has(id)) {
+ allResults.set(id, {
+ id,
+ sourceId: metadata?.sourceId || '',
+ sourceType: metadata?.sourceType as 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document',
+ updatedAt: metadata?.updatedAt,
+ });
+ }
+ }
+ }
+ } catch (error) {
+ logger.warn('Error in organizationId query strategy', {
+ sourceId,
+ sourceType,
+ organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+
+ // Strategy 2: Query with sourceId (more specific, likely to find document chunks)
+ try {
+ const sourceQueryEmbedding = await generateEmbedding(sourceId);
+ const sourceResults = await vectorIndex.query({
+ vector: sourceQueryEmbedding,
+ topK: 1000,
+ includeMetadata: true,
+ });
+
+ for (const result of sourceResults) {
const metadata = result.metadata as any;
- return {
- id: String(result.id),
- sourceId: metadata?.sourceId || '',
- sourceType: metadata?.sourceType as 'policy' | 'context',
- updatedAt: metadata?.updatedAt,
- };
+ if (
+ metadata?.organizationId === organizationId &&
+ metadata?.sourceType === sourceType &&
+ metadata?.sourceId === sourceId
+ ) {
+ const id = String(result.id);
+ if (!allResults.has(id)) {
+ allResults.set(id, {
+ id,
+ sourceId: metadata?.sourceId || '',
+ sourceType: metadata?.sourceType as 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document',
+ updatedAt: metadata?.updatedAt,
+ });
+ }
+ }
+ }
+ } catch (error) {
+ logger.warn('Error in sourceId query strategy', {
+ sourceId,
+ sourceType,
+ organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
});
+ }
+
+ // Strategy 3: Query with combined query (organizationId + sourceId)
+ // This helps find chunks that might be semantically closer to the combination
+ try {
+ const combinedQuery = `${organizationId} ${sourceId}`;
+ const combinedQueryEmbedding = await generateEmbedding(combinedQuery);
+ const combinedResults = await vectorIndex.query({
+ vector: combinedQueryEmbedding,
+ topK: 1000,
+ includeMetadata: true,
+ });
+
+ for (const result of combinedResults) {
+ const metadata = result.metadata as any;
+ if (
+ metadata?.organizationId === organizationId &&
+ metadata?.sourceType === sourceType &&
+ metadata?.sourceId === sourceId
+ ) {
+ const id = String(result.id);
+ if (!allResults.has(id)) {
+ allResults.set(id, {
+ id,
+ sourceId: metadata?.sourceId || '',
+ sourceType: metadata?.sourceType as 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document',
+ updatedAt: metadata?.updatedAt,
+ });
+ }
+ }
+ }
+ } catch (error) {
+ logger.warn('Error in combined query strategy', {
+ sourceId,
+ sourceType,
+ organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+
+ // Strategy 4: Query with documentName (for knowledge_base_document only)
+ // This helps find chunks that are semantically similar to the document filename
+ if (sourceType === 'knowledge_base_document' && documentName) {
+ try {
+ const docNameQueryEmbedding = await generateEmbedding(documentName);
+ const docNameResults = await vectorIndex.query({
+ vector: docNameQueryEmbedding,
+ topK: 1000,
+ includeMetadata: true,
+ });
+
+ for (const result of docNameResults) {
+ const metadata = result.metadata as any;
+ if (
+ metadata?.organizationId === organizationId &&
+ metadata?.sourceType === sourceType &&
+ metadata?.sourceId === sourceId
+ ) {
+ const id = String(result.id);
+ if (!allResults.has(id)) {
+ allResults.set(id, {
+ id,
+ sourceId: metadata?.sourceId || '',
+ sourceType: metadata?.sourceType as 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document',
+ updatedAt: metadata?.updatedAt,
+ });
+ }
+ }
+ }
+ } catch (error) {
+ logger.warn('Error in documentName query strategy', {
+ sourceId,
+ sourceType,
+ organizationId,
+ documentName,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }
+
+ // Strategy 5: Query with content from already-found chunks (for knowledge_base_document)
+ // This helps find chunks that are semantically similar to chunks we've already found
+ // This is especially useful for finding the "last remaining" chunks
+ if (sourceType === 'knowledge_base_document' && allResults.size > 0) {
+ try {
+ // Get a few chunks we've already found and use their content/metadata as query vectors
+ const foundChunkIds = Array.from(allResults.keys()).slice(0, 3); // Use first 3 chunks
+
+ // Query Upstash Vector to get the actual content/metadata of these chunks
+ // Then use that content AND filename to find more chunks
+ for (const chunkId of foundChunkIds) {
+ try {
+ // Fetch the chunk by ID to get its content and metadata
+ const chunkResult = await vectorIndex.fetch([chunkId]);
+ if (chunkResult && chunkResult.length > 0) {
+ const chunk = chunkResult[0];
+ if (!chunk) continue;
+ const metadata = chunk.metadata as any;
+ const chunkContent = metadata?.content as string;
+ const chunkDocumentName = metadata?.documentName as string;
+
+ // Strategy 5a: Query with chunk content
+ if (chunkContent && chunkContent.length > 50) {
+ // Use a portion of the chunk content as query (first 200 chars)
+ const contentQuery = chunkContent.substring(0, 200);
+ const contentQueryEmbedding = await generateEmbedding(contentQuery);
+ const contentResults = await vectorIndex.query({
+ vector: contentQueryEmbedding,
+ topK: 1000,
+ includeMetadata: true,
+ });
+
+ for (const result of contentResults) {
+ const resultMetadata = result.metadata as any;
+ if (
+ resultMetadata?.organizationId === organizationId &&
+ resultMetadata?.sourceType === sourceType &&
+ resultMetadata?.sourceId === sourceId
+ ) {
+ const id = String(result.id);
+ if (!allResults.has(id)) {
+ allResults.set(id, {
+ id,
+ sourceId: resultMetadata?.sourceId || '',
+ sourceType: resultMetadata?.sourceType as 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document',
+ updatedAt: resultMetadata?.updatedAt,
+ });
+ }
+ }
+ }
+ }
+
+ // Strategy 5b: Query with filename from chunk metadata (if available)
+ // This helps find chunks that might be semantically related to the filename
+ if (chunkDocumentName && chunkDocumentName.length > 0) {
+ const filenameQueryEmbedding = await generateEmbedding(chunkDocumentName);
+ const filenameResults = await vectorIndex.query({
+ vector: filenameQueryEmbedding,
+ topK: 1000,
+ includeMetadata: true,
+ });
+
+ for (const result of filenameResults) {
+ const resultMetadata = result.metadata as any;
+ if (
+ resultMetadata?.organizationId === organizationId &&
+ resultMetadata?.sourceType === sourceType &&
+ resultMetadata?.sourceId === sourceId
+ ) {
+ const id = String(result.id);
+ if (!allResults.has(id)) {
+ allResults.set(id, {
+ id,
+ sourceId: resultMetadata?.sourceId || '',
+ sourceType: resultMetadata?.sourceType as 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document',
+ updatedAt: resultMetadata?.updatedAt,
+ });
+ }
+ }
+ }
+ }
+ }
+ } catch (chunkError) {
+ logger.warn('Error querying with chunk content/filename', {
+ chunkId,
+ error: chunkError instanceof Error ? chunkError.message : 'Unknown error',
+ });
+ }
+ }
+ } catch (error) {
+ logger.warn('Error in chunk content/filename query strategy', {
+ sourceId,
+ sourceType,
+ organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }
+
+ // Strategy 6: Query with generic terms (for knowledge_base_document)
+ // Use generic terms that are likely to match document content
+ if (sourceType === 'knowledge_base_document') {
+ const genericQueries = [
+ 'document information content',
+ 'knowledge base document',
+ 'file content text',
+ ];
+
+ for (const genericQuery of genericQueries) {
+ try {
+ const genericQueryEmbedding = await generateEmbedding(genericQuery);
+ const genericResults = await vectorIndex.query({
+ vector: genericQueryEmbedding,
+ topK: 1000,
+ includeMetadata: true,
+ });
+
+ for (const result of genericResults) {
+ const metadata = result.metadata as any;
+ if (
+ metadata?.organizationId === organizationId &&
+ metadata?.sourceType === sourceType &&
+ metadata?.sourceId === sourceId
+ ) {
+ const id = String(result.id);
+ if (!allResults.has(id)) {
+ allResults.set(id, {
+ id,
+ sourceId: metadata?.sourceId || '',
+ sourceType: metadata?.sourceType as 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document',
+ updatedAt: metadata?.updatedAt,
+ });
+ }
+ }
+ }
+ } catch (error) {
+ logger.warn('Error in generic query strategy', {
+ genericQuery,
+ sourceId,
+ sourceType,
+ organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }
+ }
+
+ const matchingEmbeddings = Array.from(allResults.values());
+
+ logger.info('Found embeddings for source', {
+ sourceId,
+ sourceType,
+ organizationId,
+ count: matchingEmbeddings.length,
+ uniqueIds: matchingEmbeddings.map((e) => e.id),
+ });
return matchingEmbeddings;
} catch (error) {
@@ -116,7 +400,10 @@ export async function findAllOrganizationEmbeddings(
return (
metadata?.organizationId === organizationId &&
metadata?.sourceType !== 'questionnaire' &&
- (metadata?.sourceType === 'policy' || metadata?.sourceType === 'context')
+ (metadata?.sourceType === 'policy' ||
+ metadata?.sourceType === 'context' ||
+ metadata?.sourceType === 'manual_answer' ||
+ metadata?.sourceType === 'knowledge_base_document')
);
})
.map((result) => {
@@ -124,7 +411,7 @@ export async function findAllOrganizationEmbeddings(
return {
id: String(result.id),
sourceId: metadata?.sourceId || '',
- sourceType: metadata?.sourceType as 'policy' | 'context',
+ sourceType: metadata?.sourceType as 'policy' | 'context' | 'manual_answer' | 'knowledge_base_document',
updatedAt: metadata?.updatedAt,
};
});
diff --git a/apps/app/src/lib/vector/core/find-similar.ts b/apps/app/src/lib/vector/core/find-similar.ts
index 934932eeb..233099a51 100644
--- a/apps/app/src/lib/vector/core/find-similar.ts
+++ b/apps/app/src/lib/vector/core/find-similar.ts
@@ -8,13 +8,14 @@ export interface SimilarContentResult {
id: string;
score: number;
content: string;
- sourceType: 'policy' | 'context' | 'document_hub' | 'attachment' | 'questionnaire';
+ sourceType: 'policy' | 'context' | 'document_hub' | 'attachment' | 'questionnaire' | 'manual_answer' | 'knowledge_base_document';
sourceId: string;
policyName?: string;
contextQuestion?: string;
vendorId?: string;
vendorName?: string;
questionnaireQuestion?: string;
+ documentName?: string;
}
/**
@@ -61,24 +62,25 @@ export async function findSimilarContent(
const metadata = result.metadata as any;
const hasCorrectOrg = metadata?.organizationId === organizationId;
const hasMinScore = result.score >= MIN_SIMILARITY_SCORE;
- // Exclude questionnaire Q&A from results - we only use Policy and Context as sources
+ // Exclude questionnaire Q&A from results - we use Policy, Context, and Manual Answers as sources
const isNotQuestionnaire = metadata?.sourceType !== 'questionnaire';
return hasCorrectOrg && hasMinScore && isNotQuestionnaire;
})
.slice(0, limit) // Take only the top N after filtering
- .map((result) => {
+ .map((result): SimilarContentResult => {
const metadata = result.metadata as any;
return {
id: String(result.id),
score: result.score,
content: metadata?.content || '',
- sourceType: metadata?.sourceType || 'policy',
+ sourceType: (metadata?.sourceType || 'policy') as SimilarContentResult['sourceType'],
sourceId: metadata?.sourceId || '',
policyName: metadata?.policyName,
contextQuestion: metadata?.contextQuestion,
vendorId: metadata?.vendorId,
vendorName: metadata?.vendorName,
questionnaireQuestion: metadata?.questionnaireQuestion,
+ documentName: metadata?.documentName,
};
});
diff --git a/apps/app/src/lib/vector/core/upsert-embedding.ts b/apps/app/src/lib/vector/core/upsert-embedding.ts
index 9cf199471..61dfadd77 100644
--- a/apps/app/src/lib/vector/core/upsert-embedding.ts
+++ b/apps/app/src/lib/vector/core/upsert-embedding.ts
@@ -4,7 +4,7 @@ import { vectorIndex } from './client';
import { generateEmbedding } from './generate-embedding';
import { logger } from '@/utils/logger';
-export type SourceType = 'policy' | 'context' | 'document_hub' | 'attachment' | 'questionnaire';
+export type SourceType = 'policy' | 'context' | 'document_hub' | 'attachment' | 'questionnaire' | 'manual_answer' | 'knowledge_base_document';
export interface EmbeddingMetadata {
organizationId: string;
@@ -16,6 +16,7 @@ export interface EmbeddingMetadata {
vendorId?: string;
vendorName?: string;
questionnaireQuestion?: string;
+ documentName?: string;
updatedAt?: string; // ISO timestamp for incremental sync comparison
}
@@ -31,7 +32,14 @@ export async function upsertEmbedding(
metadata: EmbeddingMetadata,
): Promise {
if (!vectorIndex) {
- throw new Error('Upstash Vector is not configured');
+ const errorMsg = 'Upstash Vector is not configured - check UPSTASH_VECTOR_REST_URL and UPSTASH_VECTOR_REST_TOKEN';
+ logger.error(errorMsg, {
+ id,
+ sourceType: metadata.sourceType,
+ hasUrl: !!process.env.UPSTASH_VECTOR_REST_URL,
+ hasToken: !!process.env.UPSTASH_VECTOR_REST_TOKEN,
+ });
+ throw new Error(errorMsg);
}
if (!text || text.trim().length === 0) {
@@ -43,25 +51,56 @@ export async function upsertEmbedding(
// Generate embedding
const embedding = await generateEmbedding(text);
+ // Prepare metadata
+ const vectorMetadata = {
+ organizationId: metadata.organizationId,
+ sourceType: metadata.sourceType,
+ sourceId: metadata.sourceId,
+ content: text.substring(0, 1000), // Store first 1000 chars for reference
+ ...(metadata.policyName && { policyName: metadata.policyName }),
+ ...(metadata.contextQuestion && { contextQuestion: metadata.contextQuestion }),
+ ...(metadata.vendorId && { vendorId: metadata.vendorId }),
+ ...(metadata.vendorName && { vendorName: metadata.vendorName }),
+ ...(metadata.questionnaireQuestion && { questionnaireQuestion: metadata.questionnaireQuestion }),
+ ...(metadata.documentName && { documentName: metadata.documentName }),
+ ...(metadata.updatedAt && { updatedAt: metadata.updatedAt }),
+ };
+
+ // Log detailed info for manual_answer type (for debugging)
+ if (metadata.sourceType === 'manual_answer') {
+ logger.info('Upserting manual answer embedding', {
+ id,
+ embeddingId: id,
+ vectorLength: embedding.length,
+ vectorPreview: embedding.slice(0, 5).map(v => v.toFixed(6)), // First 5 dimensions
+ vectorStats: {
+ min: Math.min(...embedding),
+ max: Math.max(...embedding),
+ mean: embedding.reduce((a, b) => a + b, 0) / embedding.length,
+ },
+ metadata: vectorMetadata,
+ textPreview: text.substring(0, 200),
+ });
+ }
+
// Upsert into Upstash Vector
- await vectorIndex.upsert({
+ const upsertResult = await vectorIndex.upsert({
id,
vector: embedding,
- metadata: {
- organizationId: metadata.organizationId,
- sourceType: metadata.sourceType,
- sourceId: metadata.sourceId,
- content: text.substring(0, 1000), // Store first 1000 chars for reference
- ...(metadata.policyName && { policyName: metadata.policyName }),
- ...(metadata.contextQuestion && { contextQuestion: metadata.contextQuestion }),
- ...(metadata.vendorId && { vendorId: metadata.vendorId }),
- ...(metadata.vendorName && { vendorName: metadata.vendorName }),
- ...(metadata.questionnaireQuestion && { questionnaireQuestion: metadata.questionnaireQuestion }),
- ...(metadata.updatedAt && { updatedAt: metadata.updatedAt }),
- },
+ metadata: vectorMetadata,
});
- // Removed per-embedding success logging for performance (only log errors)
+ // Log success for manual_answer type with upsert result
+ if (metadata.sourceType === 'manual_answer') {
+ logger.info('✅ Successfully upserted manual answer embedding', {
+ id,
+ embeddingId: id,
+ organizationId: metadata.organizationId,
+ sourceId: metadata.sourceId,
+ upsertResult: upsertResult ? 'success' : 'unknown',
+ vectorIndexConfigured: !!vectorIndex,
+ });
+ }
} catch (error) {
logger.error('Failed to upsert embedding', {
id,
@@ -132,6 +171,7 @@ export async function batchUpsertEmbeddings(
...(item.metadata.questionnaireQuestion && {
questionnaireQuestion: item.metadata.questionnaireQuestion,
}),
+ ...(item.metadata.documentName && { documentName: item.metadata.documentName }),
...(item.metadata.updatedAt && { updatedAt: item.metadata.updatedAt }),
},
});
diff --git a/apps/app/src/lib/vector/index.ts b/apps/app/src/lib/vector/index.ts
index 2a950457c..8e1baa8ac 100644
--- a/apps/app/src/lib/vector/index.ts
+++ b/apps/app/src/lib/vector/index.ts
@@ -14,7 +14,9 @@ export type { ExistingEmbedding } from './core/find-existing-embeddings';
// Sync functionality
export { syncOrganizationEmbeddings } from './sync/sync-organization';
+export { syncManualAnswerToVector, deleteManualAnswerFromVector } from './sync/sync-manual-answer';
// Utilities
+export { countEmbeddings, listManualAnswerEmbeddings } from './core/count-embeddings';
export { chunkText } from './utils/chunk-text';
export { extractTextFromPolicy } from './utils/extract-policy-text';
diff --git a/apps/app/src/lib/vector/sync/sync-manual-answer.ts b/apps/app/src/lib/vector/sync/sync-manual-answer.ts
new file mode 100644
index 000000000..fcddffa55
--- /dev/null
+++ b/apps/app/src/lib/vector/sync/sync-manual-answer.ts
@@ -0,0 +1,174 @@
+import 'server-only';
+
+import { upsertEmbedding } from '../core/upsert-embedding';
+import { vectorIndex } from '../core/client';
+import { db } from '@db';
+import { logger } from '@/utils/logger';
+
+/**
+ * Syncs a single manual answer to vector database SYNCHRONOUSLY
+ * Fast operation (~1-2 seconds) - acceptable for UX
+ * This ensures manual answers are immediately available for answer generation
+ */
+export async function syncManualAnswerToVector(
+ manualAnswerId: string,
+ organizationId: string,
+): Promise<{ success: boolean; error?: string; embeddingId?: string }> {
+ // Check if vectorIndex is configured
+ if (!vectorIndex) {
+ logger.error('❌ Upstash Vector not configured - check UPSTASH_VECTOR_REST_URL and UPSTASH_VECTOR_REST_TOKEN', {
+ manualAnswerId,
+ organizationId,
+ hasUrl: !!process.env.UPSTASH_VECTOR_REST_URL,
+ hasToken: !!process.env.UPSTASH_VECTOR_REST_TOKEN,
+ });
+ return { success: false, error: 'Vector DB not configured' };
+ }
+
+ logger.info('🔍 Vector Index configuration check', {
+ vectorIndexExists: !!vectorIndex,
+ manualAnswerId,
+ organizationId,
+ });
+
+ try {
+ const manualAnswer = await db.securityQuestionnaireManualAnswer.findUnique({
+ where: { id: manualAnswerId, organizationId },
+ });
+
+ if (!manualAnswer) {
+ logger.warn('Manual answer not found for sync', { manualAnswerId, organizationId });
+ return { success: false, error: 'Manual answer not found' };
+ }
+
+ // Create embedding ID: manual_answer_{id}
+ const embeddingId = `manual_answer_${manualAnswerId}`;
+
+ // Combine question and answer for better semantic search
+ const text = `${manualAnswer.question}\n\n${manualAnswer.answer}`;
+
+ logger.info('🔄 Starting sync manual answer to vector DB', {
+ manualAnswerId,
+ organizationId,
+ embeddingId,
+ question: manualAnswer.question.substring(0, 100),
+ answer: manualAnswer.answer.substring(0, 100),
+ textLength: text.length,
+ });
+
+ await upsertEmbedding(embeddingId, text, {
+ organizationId,
+ sourceType: 'manual_answer',
+ sourceId: manualAnswerId,
+ content: text,
+ updatedAt: manualAnswer.updatedAt.toISOString(),
+ });
+
+ // Verify the embedding was actually added by querying for it
+ try {
+ const { findEmbeddingsForSource } = await import('../core/find-existing-embeddings');
+ const foundEmbeddings = await findEmbeddingsForSource(
+ manualAnswerId,
+ 'manual_answer',
+ organizationId,
+ );
+
+ const wasFound = foundEmbeddings.some((e) => e.id === embeddingId);
+
+ logger.info('✅ Successfully synced manual answer to vector DB', {
+ manualAnswerId,
+ organizationId,
+ embeddingId,
+ question: manualAnswer.question.substring(0, 100),
+ answer: manualAnswer.answer.substring(0, 100),
+ verified: wasFound,
+ foundEmbeddingsCount: foundEmbeddings.length,
+ foundEmbeddingIds: foundEmbeddings.map((e) => e.id),
+ metadata: {
+ organizationId,
+ sourceType: 'manual_answer',
+ sourceId: manualAnswerId,
+ updatedAt: manualAnswer.updatedAt.toISOString(),
+ },
+ });
+
+ if (!wasFound) {
+ logger.warn('⚠️ Embedding was upserted but not found in verification query', {
+ embeddingId,
+ manualAnswerId,
+ organizationId,
+ });
+ }
+ } catch (verifyError) {
+ logger.warn('Failed to verify embedding after upsert', {
+ embeddingId,
+ manualAnswerId,
+ error: verifyError instanceof Error ? verifyError.message : 'Unknown error',
+ });
+ }
+ return {
+ success: true,
+ embeddingId, // Return embedding ID for verification
+ };
+ } catch (error) {
+ logger.error('Failed to sync manual answer to vector DB', {
+ manualAnswerId,
+ organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+}
+
+/**
+ * Deletes manual answer from vector database
+ * Called when manual answer is deleted
+ */
+export async function deleteManualAnswerFromVector(
+ manualAnswerId: string,
+ organizationId: string,
+): Promise<{ success: boolean; error?: string }> {
+ if (!vectorIndex) {
+ return { success: false, error: 'Vector DB not configured' };
+ }
+
+ try {
+ // Find existing embeddings for this manual answer
+ // We need to search for embeddings with this sourceId
+ const embeddingId = `manual_answer_${manualAnswerId}`;
+
+ // Try to delete directly by ID (most efficient)
+ try {
+ await vectorIndex.delete([embeddingId]);
+ logger.info('Deleted manual answer from vector DB', {
+ manualAnswerId,
+ organizationId,
+ embeddingId,
+ });
+ return { success: true };
+ } catch (deleteError) {
+ // If direct delete fails (embedding might not exist), log and continue
+ logger.warn('Failed to delete manual answer embedding (may not exist)', {
+ manualAnswerId,
+ embeddingId,
+ error: deleteError instanceof Error ? deleteError.message : 'Unknown error',
+ });
+ // Still return success - embedding might not exist
+ return { success: true };
+ }
+ } catch (error) {
+ logger.error('Failed to delete manual answer from vector DB', {
+ manualAnswerId,
+ organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+}
+
diff --git a/apps/app/src/lib/vector/sync/sync-organization.ts b/apps/app/src/lib/vector/sync/sync-organization.ts
index d5b14d756..342fe5ba3 100644
--- a/apps/app/src/lib/vector/sync/sync-organization.ts
+++ b/apps/app/src/lib/vector/sync/sync-organization.ts
@@ -8,6 +8,8 @@ import { deleteOrganizationEmbeddings } from '../core/delete-embeddings';
import { findAllOrganizationEmbeddings, type ExistingEmbedding } from '../core/find-existing-embeddings';
import { vectorIndex } from '../core/client';
import { logger } from '@/utils/logger';
+import { tasks } from '@trigger.dev/sdk';
+import { processKnowledgeBaseDocumentTask } from '@/jobs/tasks/vector/process-knowledge-base-document';
/**
* Lock map to prevent concurrent syncs for the same organization
@@ -307,10 +309,165 @@ async function performSync(organizationId: string): Promise {
total: contextEntries.length,
});
- // Step 6: Delete orphaned embeddings (policies/context that no longer exist in DB)
+ // Step 6: Sync manual answers (ensure they're always up-to-date)
+ const manualAnswers = await db.securityQuestionnaireManualAnswer.findMany({
+ where: { organizationId },
+ select: {
+ id: true,
+ question: true,
+ answer: true,
+ updatedAt: true,
+ },
+ });
+
+ logger.info('Syncing manual answers', {
+ organizationId,
+ count: manualAnswers.length,
+ });
+
+ let manualAnswersCreated = 0;
+ let manualAnswersUpdated = 0;
+ let manualAnswersSkipped = 0;
+
+ if (manualAnswers.length > 0) {
+ const manualAnswerItems = manualAnswers.map((ma) => {
+ const embeddingId = `manual_answer_${ma.id}`;
+ const text = `${ma.question}\n\n${ma.answer}`;
+ const updatedAt = ma.updatedAt.toISOString();
+
+ // Check if embedding exists and needs update
+ const existingManualAnswerEmbeddings = existingEmbeddings.get(ma.id) || [];
+ const needsUpdate = existingManualAnswerEmbeddings.length === 0 ||
+ existingManualAnswerEmbeddings[0]?.updatedAt !== updatedAt;
+
+ if (needsUpdate) {
+ if (existingManualAnswerEmbeddings.length === 0) {
+ manualAnswersCreated++;
+ } else {
+ manualAnswersUpdated++;
+ }
+ } else {
+ manualAnswersSkipped++;
+ }
+
+ return {
+ id: embeddingId,
+ text,
+ metadata: {
+ organizationId,
+ sourceType: 'manual_answer' as const,
+ sourceId: ma.id,
+ content: text,
+ updatedAt,
+ },
+ };
+ });
+
+ // Batch upsert all manual answers
+ if (manualAnswerItems.length > 0) {
+ await batchUpsertEmbeddings(manualAnswerItems);
+ }
+ }
+
+ logger.info('Manual answers sync completed', {
+ organizationId,
+ created: manualAnswersCreated,
+ updated: manualAnswersUpdated,
+ skipped: manualAnswersSkipped,
+ total: manualAnswers.length,
+ });
+
+ // Step 7: Sync Knowledge Base documents
+ // Note: Documents are processed via Trigger.dev tasks, but we sync completed documents here
+ // and trigger processing for pending/failed documents
+ const knowledgeBaseDocuments = await db.knowledgeBaseDocument.findMany({
+ where: { organizationId },
+ select: {
+ id: true,
+ name: true,
+ s3Key: true,
+ fileType: true,
+ processingStatus: true,
+ updatedAt: true,
+ },
+ });
+
+ logger.info('Found Knowledge Base documents to sync', {
+ organizationId,
+ count: knowledgeBaseDocuments.length,
+ });
+
+ let documentsProcessed = 0;
+ let documentsTriggered = 0;
+ let documentsSkipped = 0;
+
+ // Trigger processing for pending/failed documents
+ for (const document of knowledgeBaseDocuments) {
+ if (document.processingStatus === 'pending' || document.processingStatus === 'failed') {
+ try {
+ // Trigger Trigger.dev task to process document
+ await tasks.trigger(
+ 'process-knowledge-base-document',
+ {
+ documentId: document.id,
+ organizationId,
+ },
+ );
+ documentsTriggered++;
+ } catch (error) {
+ logger.warn('Failed to trigger document processing', {
+ documentId: document.id,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ } else if (document.processingStatus === 'completed') {
+ // Check if embeddings exist and are up-to-date
+ const documentEmbeddings = existingEmbeddings.get(document.id) || [];
+ const documentUpdatedAt = document.updatedAt.toISOString();
+
+ const needsUpdate = documentEmbeddings.length === 0 ||
+ documentEmbeddings.some((e: ExistingEmbedding) => !e.updatedAt || e.updatedAt < documentUpdatedAt);
+
+ if (needsUpdate) {
+ // Trigger reprocessing if embeddings are outdated
+ try {
+ await tasks.trigger(
+ 'process-knowledge-base-document',
+ {
+ documentId: document.id,
+ organizationId,
+ },
+ );
+ documentsTriggered++;
+ } catch (error) {
+ logger.warn('Failed to trigger document reprocessing', {
+ documentId: document.id,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ } else {
+ documentsSkipped++;
+ }
+ documentsProcessed++;
+ } else {
+ documentsSkipped++;
+ }
+ }
+
+ logger.info('Knowledge Base documents sync completed', {
+ organizationId,
+ processed: documentsProcessed,
+ triggered: documentsTriggered,
+ skipped: documentsSkipped,
+ total: knowledgeBaseDocuments.length,
+ });
+
+ // Step 8: Delete orphaned embeddings (policies/context/manual_answers/knowledge_base_documents that no longer exist in DB)
// Use the embeddings we already fetched (no additional API call needed)
const dbPolicyIds = new Set(policies.map(p => p.id));
const dbContextIds = new Set(contextEntries.map(c => c.id));
+ const dbManualAnswerIds = new Set(manualAnswers.map(ma => ma.id));
+ const dbKnowledgeBaseDocumentIds = new Set(knowledgeBaseDocuments.map(d => d.id));
let orphanedDeleted = 0;
// Check for orphaned embeddings using the pre-fetched map
@@ -318,9 +475,13 @@ async function performSync(organizationId: string): Promise {
for (const [sourceId, embeddings] of existingEmbeddings.entries()) {
const isPolicy = embeddings[0]?.sourceType === 'policy';
const isContext = embeddings[0]?.sourceType === 'context';
+ const isManualAnswer = embeddings[0]?.sourceType === 'manual_answer';
+ const isKnowledgeBaseDocument = embeddings[0]?.sourceType === 'knowledge_base_document';
const shouldExist = (isPolicy && dbPolicyIds.has(sourceId)) ||
- (isContext && dbContextIds.has(sourceId));
+ (isContext && dbContextIds.has(sourceId)) ||
+ (isManualAnswer && dbManualAnswerIds.has(sourceId)) ||
+ (isKnowledgeBaseDocument && dbKnowledgeBaseDocumentIds.has(sourceId));
if (!shouldExist && vectorIndex) {
// Delete orphaned embeddings
@@ -330,7 +491,7 @@ async function performSync(organizationId: string): Promise {
orphanedDeleted += idsToDelete.length;
logger.info('Deleted orphaned embeddings', {
sourceId,
- sourceType: isPolicy ? 'policy' : 'context',
+ sourceType: isPolicy ? 'policy' : isContext ? 'context' : isManualAnswer ? 'manual_answer' : 'knowledge_base_document',
deletedCount: idsToDelete.length,
});
} catch (error) {
@@ -363,6 +524,18 @@ async function performSync(organizationId: string): Promise {
updated: contextUpdated,
skipped: contextSkipped,
},
+ manualAnswers: {
+ total: manualAnswers.length,
+ created: manualAnswersCreated,
+ updated: manualAnswersUpdated,
+ skipped: manualAnswersSkipped,
+ },
+ knowledgeBaseDocuments: {
+ total: knowledgeBaseDocuments.length,
+ processed: documentsProcessed,
+ triggered: documentsTriggered,
+ skipped: documentsSkipped,
+ },
orphanedDeleted,
});
} catch (error) {
diff --git a/bun.lock b/bun.lock
index 3c9f1cd5e..d94b161b1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "comp",
@@ -201,6 +202,7 @@
"geist": "^1.3.1",
"jspdf": "^3.0.2",
"lucide-react": "^0.544.0",
+ "mammoth": "^1.11.0",
"motion": "^12.9.2",
"next": "^15.4.6",
"next-safe-action": "^8.0.3",
@@ -331,7 +333,7 @@
},
"packages/db": {
"name": "@trycompai/db",
- "version": "1.3.17",
+ "version": "1.3.18",
"bin": {
"comp-prisma-postinstall": "./dist/postinstall.js",
},
@@ -2379,6 +2381,8 @@
"@webgpu/types": ["@webgpu/types@0.1.66", "", {}, "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA=="],
+ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
+
"@xobotyi/scrollbar-width": ["@xobotyi/scrollbar-width@1.9.5", "", {}, "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ=="],
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
@@ -2575,6 +2579,8 @@
"block-stream": ["block-stream@0.0.9", "", { "dependencies": { "inherits": "~2.0.0" } }, "sha512-OorbnJVPII4DuUKbjARAe8u8EfqOmkEEaSFIyoQ7OjTHn6kafxWl0wLgoZ2rXaYd7MyLcDaU4TmhfxtwgcccMQ=="],
+ "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
+
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -2973,6 +2979,8 @@
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
+ "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="],
+
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"discord-api-types": ["discord-api-types@0.38.34", "", {}, "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q=="],
@@ -3009,6 +3017,8 @@
"dub": ["dub@0.66.5", "", { "dependencies": { "zod": "^3.20.0" } }, "sha512-VIaRWbjAv0w3R317LRjPh7G4Ws9wRqMwvaBQWdFxghQfkJTh457NZFNOfywYX5DIjWBhZLPG4/itvoi3AlcxxQ=="],
+ "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
@@ -3863,6 +3873,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+ "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
+
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
@@ -3887,6 +3899,8 @@
"makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
+ "mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="],
+
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
@@ -4171,6 +4185,8 @@
"open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
+ "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
+
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
@@ -5105,6 +5121,8 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
+ "underscore": ["underscore@1.13.7", "", {}, "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="],
+
"undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -5287,7 +5305,7 @@
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
- "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
+ "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
@@ -6481,6 +6499,8 @@
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
+
"yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"@angular-devkit/core/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -6501,6 +6521,8 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
+ "@azure/core-http/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
+
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 000000000..57f75baf9
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,2 @@
+[install]
+linker = "hoisted"
diff --git a/packages/db/package.json b/packages/db/package.json
index 0bd086bf4..bcef9db8d 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -1,7 +1,7 @@
{
"name": "@trycompai/db",
"description": "Database package with Prisma client and schema for Comp AI",
- "version": "1.3.17",
+ "version": "1.3.18",
"dependencies": {
"@prisma/client": "^6.13.0",
"dotenv": "^16.4.5",
diff --git a/packages/db/prisma/migrations/20251118160603_add_supplement_documents/migration.sql b/packages/db/prisma/migrations/20251118160603_add_supplement_documents/migration.sql
new file mode 100644
index 000000000..752b440db
--- /dev/null
+++ b/packages/db/prisma/migrations/20251118160603_add_supplement_documents/migration.sql
@@ -0,0 +1,31 @@
+-- CreateEnum
+CREATE TYPE "KnowledgeBaseDocumentProcessingStatus" AS ENUM ('pending', 'processing', 'completed', 'failed');
+
+-- CreateTable
+CREATE TABLE "KnowledgeBaseDocument" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('kbd'::text),
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "s3Key" TEXT NOT NULL,
+ "fileType" TEXT NOT NULL,
+ "fileSize" INTEGER NOT NULL,
+ "processingStatus" "KnowledgeBaseDocumentProcessingStatus" NOT NULL DEFAULT 'pending',
+ "processedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "organizationId" TEXT NOT NULL,
+
+ CONSTRAINT "KnowledgeBaseDocument_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "KnowledgeBaseDocument_organizationId_idx" ON "KnowledgeBaseDocument"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "KnowledgeBaseDocument_organizationId_processingStatus_idx" ON "KnowledgeBaseDocument"("organizationId", "processingStatus");
+
+-- CreateIndex
+CREATE INDEX "KnowledgeBaseDocument_s3Key_idx" ON "KnowledgeBaseDocument"("s3Key");
+
+-- AddForeignKey
+ALTER TABLE "KnowledgeBaseDocument" ADD CONSTRAINT "KnowledgeBaseDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20251118171710_add_trigger_run_id_to_knowledge_base_document/migration.sql b/packages/db/prisma/migrations/20251118171710_add_trigger_run_id_to_knowledge_base_document/migration.sql
new file mode 100644
index 000000000..9384e0260
--- /dev/null
+++ b/packages/db/prisma/migrations/20251118171710_add_trigger_run_id_to_knowledge_base_document/migration.sql
@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "KnowledgeBaseDocument" ADD COLUMN "triggerRunId" TEXT;
+
+-- CreateIndex
+CREATE INDEX "KnowledgeBaseDocument_triggerRunId_idx" ON "KnowledgeBaseDocument"("triggerRunId");
diff --git a/packages/db/prisma/migrations/20251118183908_add_questionnaire_tables/migration.sql b/packages/db/prisma/migrations/20251118183908_add_questionnaire_tables/migration.sql
new file mode 100644
index 000000000..01003c3b3
--- /dev/null
+++ b/packages/db/prisma/migrations/20251118183908_add_questionnaire_tables/migration.sql
@@ -0,0 +1,64 @@
+-- CreateEnum
+CREATE TYPE "QuestionnaireStatus" AS ENUM ('parsing', 'completed', 'failed');
+
+-- CreateEnum
+CREATE TYPE "QuestionnaireAnswerStatus" AS ENUM ('untouched', 'generated', 'manual');
+
+-- CreateTable
+CREATE TABLE "Questionnaire" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('qst'::text),
+ "filename" TEXT NOT NULL,
+ "s3Key" TEXT NOT NULL,
+ "fileType" TEXT NOT NULL,
+ "fileSize" INTEGER NOT NULL,
+ "status" "QuestionnaireStatus" NOT NULL DEFAULT 'parsing',
+ "parsedAt" TIMESTAMP(3),
+ "totalQuestions" INTEGER NOT NULL DEFAULT 0,
+ "answeredQuestions" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "organizationId" TEXT NOT NULL,
+
+ CONSTRAINT "Questionnaire_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "QuestionnaireQuestionAnswer" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('qqa'::text),
+ "question" TEXT NOT NULL,
+ "answer" TEXT,
+ "status" "QuestionnaireAnswerStatus" NOT NULL DEFAULT 'untouched',
+ "questionIndex" INTEGER NOT NULL,
+ "sources" JSONB,
+ "generatedAt" TIMESTAMP(3),
+ "updatedBy" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "questionnaireId" TEXT NOT NULL,
+
+ CONSTRAINT "QuestionnaireQuestionAnswer_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Questionnaire_organizationId_idx" ON "Questionnaire"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "Questionnaire_organizationId_createdAt_idx" ON "Questionnaire"("organizationId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "Questionnaire_status_idx" ON "Questionnaire"("status");
+
+-- CreateIndex
+CREATE INDEX "QuestionnaireQuestionAnswer_questionnaireId_idx" ON "QuestionnaireQuestionAnswer"("questionnaireId");
+
+-- CreateIndex
+CREATE INDEX "QuestionnaireQuestionAnswer_questionnaireId_questionIndex_idx" ON "QuestionnaireQuestionAnswer"("questionnaireId", "questionIndex");
+
+-- CreateIndex
+CREATE INDEX "QuestionnaireQuestionAnswer_status_idx" ON "QuestionnaireQuestionAnswer"("status");
+
+-- AddForeignKey
+ALTER TABLE "Questionnaire" ADD CONSTRAINT "Questionnaire_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "QuestionnaireQuestionAnswer" ADD CONSTRAINT "QuestionnaireQuestionAnswer_questionnaireId_fkey" FOREIGN KEY ("questionnaireId") REFERENCES "Questionnaire"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20251118220527_add_security_questionnaire_manual_answers/migration.sql b/packages/db/prisma/migrations/20251118220527_add_security_questionnaire_manual_answers/migration.sql
new file mode 100644
index 000000000..3d9b236be
--- /dev/null
+++ b/packages/db/prisma/migrations/20251118220527_add_security_questionnaire_manual_answers/migration.sql
@@ -0,0 +1,36 @@
+-- CreateTable
+CREATE TABLE "SecurityQuestionnaireManualAnswer" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('sqma'::text),
+ "question" TEXT NOT NULL,
+ "answer" TEXT NOT NULL,
+ "tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "sourceQuestionnaireId" TEXT,
+ "createdBy" TEXT,
+ "updatedBy" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "organizationId" TEXT NOT NULL,
+
+ CONSTRAINT "SecurityQuestionnaireManualAnswer_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "SecurityQuestionnaireManualAnswer_organizationId_idx" ON "SecurityQuestionnaireManualAnswer"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "SecurityQuestionnaireManualAnswer_organizationId_question_idx" ON "SecurityQuestionnaireManualAnswer"("organizationId", "question");
+
+-- CreateIndex
+CREATE INDEX "SecurityQuestionnaireManualAnswer_tags_idx" ON "SecurityQuestionnaireManualAnswer"("tags");
+
+-- CreateIndex
+CREATE INDEX "SecurityQuestionnaireManualAnswer_createdAt_idx" ON "SecurityQuestionnaireManualAnswer"("createdAt");
+
+-- CreateUniqueIndex
+CREATE UNIQUE INDEX "SecurityQuestionnaireManualAnswer_organizationId_question_key" ON "SecurityQuestionnaireManualAnswer"("organizationId", "question");
+
+-- AddForeignKey
+ALTER TABLE "SecurityQuestionnaireManualAnswer" ADD CONSTRAINT "SecurityQuestionnaireManualAnswer_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "SecurityQuestionnaireManualAnswer" ADD CONSTRAINT "SecurityQuestionnaireManualAnswer_sourceQuestionnaireId_fkey" FOREIGN KEY ("sourceQuestionnaireId") REFERENCES "Questionnaire"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20251120210257_add_iso9001_fields/migration.sql b/packages/db/prisma/migrations/20251120210257_add_iso9001_fields/migration.sql
new file mode 100644
index 000000000..e908a1aec
--- /dev/null
+++ b/packages/db/prisma/migrations/20251120210257_add_iso9001_fields/migration.sql
@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE "public"."Trust" ADD COLUMN "iso9001" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN "iso9001_status" "public"."FrameworkStatus" NOT NULL DEFAULT 'started';
+
diff --git a/packages/db/prisma/schema/knowledge-base-document.prisma b/packages/db/prisma/schema/knowledge-base-document.prisma
new file mode 100644
index 000000000..9c2aca6d2
--- /dev/null
+++ b/packages/db/prisma/schema/knowledge-base-document.prisma
@@ -0,0 +1,32 @@
+model KnowledgeBaseDocument {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('kbd'::text)"))
+ name String // Original filename
+ description String? // Optional user description/notes
+ s3Key String // S3 storage key (e.g., "org123/knowledge-base-documents/timestamp-file.pdf")
+ fileType String // MIME type (e.g., "application/pdf")
+ fileSize Int // File size in bytes
+ processingStatus KnowledgeBaseDocumentProcessingStatus @default(pending) // Track indexing status
+ processedAt DateTime? // When indexing completed
+ triggerRunId String? // Trigger.dev run ID for tracking processing progress
+
+ // Dates
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relationships
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+ @@index([organizationId])
+ @@index([organizationId, processingStatus])
+ @@index([s3Key])
+ @@index([triggerRunId])
+}
+
+enum KnowledgeBaseDocumentProcessingStatus {
+ pending // Uploaded but not yet processed/indexed
+ processing // Currently being processed/indexed
+ completed // Successfully indexed in vector database
+ failed // Processing failed
+}
+
diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma
index a4abdfb04..a81ca74d8 100644
--- a/packages/db/prisma/schema/organization.prisma
+++ b/packages/db/prisma/schema/organization.prisma
@@ -34,6 +34,9 @@ model Organization {
trustAccessRequests TrustAccessRequest[]
trustNdaAgreements TrustNDAAgreement[]
trustDocuments TrustDocument[]
+ knowledgeBaseDocuments KnowledgeBaseDocument[]
+ questionnaires Questionnaire[]
+ securityQuestionnaireManualAnswers SecurityQuestionnaireManualAnswer[]
@@index([slug])
}
diff --git a/packages/db/prisma/schema/questionnaire.prisma b/packages/db/prisma/schema/questionnaire.prisma
new file mode 100644
index 000000000..2f0811334
--- /dev/null
+++ b/packages/db/prisma/schema/questionnaire.prisma
@@ -0,0 +1,61 @@
+model Questionnaire {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('qst'::text)"))
+ filename String // Original filename
+ s3Key String // S3 storage key for the uploaded file
+ fileType String // MIME type (e.g., "application/pdf")
+ fileSize Int // File size in bytes
+ status QuestionnaireStatus @default(parsing) // Parsing status
+ parsedAt DateTime? // When parsing completed
+ totalQuestions Int @default(0) // Total number of questions parsed
+ answeredQuestions Int @default(0) // Number of questions with answers
+
+ // Dates
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relationships
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ questions QuestionnaireQuestionAnswer[]
+ manualAnswers SecurityQuestionnaireManualAnswer[] // Manual answers saved from this questionnaire
+
+ @@index([organizationId])
+ @@index([organizationId, createdAt])
+ @@index([status])
+}
+
+model QuestionnaireQuestionAnswer {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('qqa'::text)"))
+ question String // The question text
+ answer String? // The answer (nullable if not provided in file or not generated yet)
+ status QuestionnaireAnswerStatus @default(untouched) // Answer status
+ questionIndex Int // Order/index of the question in the questionnaire
+ sources Json? // Sources used for generated answers (array of source objects)
+ generatedAt DateTime? // When answer was generated (if status is generated)
+ updatedBy String? // User ID who last updated the answer (if manual)
+
+ // Dates
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relationships
+ questionnaireId String
+ questionnaire Questionnaire @relation(fields: [questionnaireId], references: [id], onDelete: Cascade)
+
+ @@index([questionnaireId])
+ @@index([questionnaireId, questionIndex])
+ @@index([status])
+}
+
+enum QuestionnaireStatus {
+ parsing // Currently being parsed
+ completed // Successfully parsed
+ failed // Parsing failed
+}
+
+enum QuestionnaireAnswerStatus {
+ untouched // No answer yet (empty or not generated)
+ generated // AI generated answer
+ manual // Manually written/edited by user
+}
+
diff --git a/packages/db/prisma/schema/security-questionnaire-manual-answer.prisma b/packages/db/prisma/schema/security-questionnaire-manual-answer.prisma
new file mode 100644
index 000000000..c41ad7394
--- /dev/null
+++ b/packages/db/prisma/schema/security-questionnaire-manual-answer.prisma
@@ -0,0 +1,29 @@
+model SecurityQuestionnaireManualAnswer {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('sqma'::text)"))
+ question String // The question text
+ answer String // The answer text (required for saved answers)
+ tags String[] @default([]) // Optional tags for categorization
+
+ // Optional reference to original questionnaire (for tracking)
+ sourceQuestionnaireId String?
+ sourceQuestionnaire Questionnaire? @relation(fields: [sourceQuestionnaireId], references: [id], onDelete: SetNull)
+
+ // User who created/updated this answer
+ createdBy String? // User ID
+ updatedBy String? // User ID
+
+ // Dates
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relationships
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+ @@index([organizationId])
+ @@index([organizationId, question])
+ @@index([tags])
+ @@index([createdAt])
+ @@unique([organizationId, question]) // Prevent duplicate questions per organization
+}
+
diff --git a/packages/db/prisma/schema/trust.prisma b/packages/db/prisma/schema/trust.prisma
index f4faf442f..e4ef133a0 100644
--- a/packages/db/prisma/schema/trust.prisma
+++ b/packages/db/prisma/schema/trust.prisma
@@ -20,6 +20,7 @@ model Trust {
gdpr Boolean @default(false)
hipaa Boolean @default(false)
pci_dss Boolean @default(false)
+ iso9001 Boolean @default(false)
soc2_status FrameworkStatus @default(started)
soc2type1_status FrameworkStatus @default(started)
@@ -30,6 +31,7 @@ model Trust {
gdpr_status FrameworkStatus @default(started)
hipaa_status FrameworkStatus @default(started)
pci_dss_status FrameworkStatus @default(started)
+ iso9001_status FrameworkStatus @default(started)
@@id([status, organizationId])
@@unique([organizationId])
diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json
index e09c94a31..a1393b099 100644
--- a/packages/docs/openapi.json
+++ b/packages/docs/openapi.json
@@ -6909,6 +6909,7 @@
"name": "friendlyUrl",
"required": true,
"in": "path",
+ "description": "Trust Portal friendly URL or Organization ID",
"schema": {
"type": "string"
}
@@ -7369,6 +7370,7 @@
"name": "friendlyUrl",
"required": true,
"in": "path",
+ "description": "Trust Portal friendly URL or Organization ID",
"schema": {
"type": "string"
}