Skip to content

Commit cb19833

Browse files
[dev] [Marfuen] feat/auditor-view (#1835)
* feat(auditor): add auditor view page with AI-generated content - Add new auditor page visible only to users with auditor role - Implement role-based sidebar visibility (hide Settings/Integrations for auditor-only users) - Add Trigger.dev task for generating auditor content sections - Use Firecrawl for website scraping and GPT for content generation - Add realtime progress tracking with useRealtimeRun hook - Sections: Company Background, Services, Mission/Vision, System Description, Critical Vendors, Subservice Organizations * chore(auditor): add layout and save content functionality for auditor view - Create layout component for auditor view page - Implement save action for auditor content with upsert functionality - Enhance AuditorView component to handle content updates and display editable sections - Integrate real-time content generation tracking and updates * refactor(auditor): remove save-auditor-content action and update AuditorView * refactor(auditor): simplify AuditorView component and remove orgId prop * chore(organization): add actions for updating and removing organization logo * refactor(onboarding): remove unnecessary blank line in backfill task * feat(onboarding): add backfill queue for executive context task * refactor(auditor): remove trigger-auditor-content action * chore(onboarding): update message to reflect AI personalization * chore(onboarding): update message to clarify AI personalization * chore(env): add APP_AWS_ORG_ASSETS_BUCKET for organization static assets --------- Co-authored-by: Mariano Fuentes <[email protected]>
1 parent fbbe666 commit cb19833

File tree

28 files changed

+2103
-134
lines changed

28 files changed

+2103
-134
lines changed

SELF_HOSTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ App (`apps/app`):
4646
- **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).
4747
- **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.
4848
- **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.
49+
- **APP_AWS_ORG_ASSETS_BUCKET**: AWS S3 bucket name for organization static assets (e.g., company logos). Required for logo uploads in organization settings. If not set, logo upload will fail.
4950
- **OPENAI_API_KEY**: Enables AI features that call OpenAI models.
5051
- **UPSTASH_REDIS_REST_URL**, **UPSTASH_REDIS_REST_TOKEN**: Optional Redis (Upstash) used for rate limiting/queues/caching.
5152
- **NEXT_PUBLIC_POSTHOG_KEY**, **NEXT_PUBLIC_POSTHOG_HOST**: Client analytics via PostHog; leave unset to disable.
@@ -153,6 +154,7 @@ NEXT_PUBLIC_BETTER_AUTH_URL_PORTAL=http://localhost:3002
153154
# APP_AWS_BUCKET_NAME=
154155
# APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET=
155156
# APP_AWS_KNOWLEDGE_BASE_BUCKET=
157+
# APP_AWS_ORG_ASSETS_BUCKET=
156158
# OPENAI_API_KEY=
157159
# UPSTASH_REDIS_REST_URL=
158160
# UPSTASH_REDIS_REST_TOKEN=
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
'use server';
2+
3+
import { authActionClient } from '@/actions/safe-action';
4+
import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3';
5+
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
6+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
7+
import { db } from '@db';
8+
import { revalidatePath } from 'next/cache';
9+
import { z } from 'zod';
10+
11+
const updateLogoSchema = z.object({
12+
fileName: z.string(),
13+
fileType: z.string(),
14+
fileData: z.string(), // base64 encoded
15+
});
16+
17+
export const updateOrganizationLogoAction = authActionClient
18+
.inputSchema(updateLogoSchema)
19+
.metadata({
20+
name: 'update-organization-logo',
21+
track: {
22+
event: 'update-organization-logo',
23+
channel: 'server',
24+
},
25+
})
26+
.action(async ({ parsedInput, ctx }) => {
27+
const { fileName, fileType, fileData } = parsedInput;
28+
const organizationId = ctx.session.activeOrganizationId;
29+
30+
if (!organizationId) {
31+
throw new Error('No active organization');
32+
}
33+
34+
// Validate file type
35+
if (!fileType.startsWith('image/')) {
36+
throw new Error('Only image files are allowed');
37+
}
38+
39+
// Check S3 client
40+
if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) {
41+
throw new Error('File upload service is not available');
42+
}
43+
44+
// Convert base64 to buffer
45+
const fileBuffer = Buffer.from(fileData, 'base64');
46+
47+
// Validate file size (2MB limit for logos)
48+
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
49+
if (fileBuffer.length > MAX_FILE_SIZE_BYTES) {
50+
throw new Error('Logo must be less than 2MB');
51+
}
52+
53+
// Generate S3 key
54+
const timestamp = Date.now();
55+
const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
56+
const key = `${organizationId}/logo/${timestamp}-${sanitizedFileName}`;
57+
58+
// Upload to S3
59+
const putCommand = new PutObjectCommand({
60+
Bucket: APP_AWS_ORG_ASSETS_BUCKET,
61+
Key: key,
62+
Body: fileBuffer,
63+
ContentType: fileType,
64+
});
65+
await s3Client.send(putCommand);
66+
67+
// Update organization with new logo key
68+
await db.organization.update({
69+
where: { id: organizationId },
70+
data: { logo: key },
71+
});
72+
73+
// Generate signed URL for immediate display
74+
const getCommand = new GetObjectCommand({
75+
Bucket: APP_AWS_ORG_ASSETS_BUCKET,
76+
Key: key,
77+
});
78+
const signedUrl = await getSignedUrl(s3Client, getCommand, {
79+
expiresIn: 3600,
80+
});
81+
82+
revalidatePath(`/${organizationId}/settings`);
83+
84+
return { success: true, logoUrl: signedUrl };
85+
});
86+
87+
export const removeOrganizationLogoAction = authActionClient
88+
.inputSchema(z.object({}))
89+
.metadata({
90+
name: 'remove-organization-logo',
91+
track: {
92+
event: 'remove-organization-logo',
93+
channel: 'server',
94+
},
95+
})
96+
.action(async ({ ctx }) => {
97+
const organizationId = ctx.session.activeOrganizationId;
98+
99+
if (!organizationId) {
100+
throw new Error('No active organization');
101+
}
102+
103+
// Remove logo from organization
104+
await db.organization.update({
105+
where: { id: organizationId },
106+
data: { logo: null },
107+
});
108+
109+
revalidatePath(`/${organizationId}/settings`);
110+
111+
return { success: true };
112+
});
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
'use client';
2+
3+
import { Download } from 'lucide-react';
4+
import Image from 'next/image';
5+
6+
interface AuditorViewProps {
7+
initialContent: Record<string, string>;
8+
organizationName: string;
9+
logoUrl: string | null;
10+
employeeCount: string | null;
11+
cSuite: { name: string; title: string }[];
12+
reportSignatory: { fullName: string; jobTitle: string; email: string } | null;
13+
}
14+
15+
export function AuditorView({
16+
initialContent,
17+
organizationName,
18+
logoUrl,
19+
employeeCount,
20+
cSuite,
21+
reportSignatory,
22+
}: AuditorViewProps) {
23+
return (
24+
<div className="flex flex-col gap-10">
25+
{/* Header */}
26+
<div className="flex items-center gap-4">
27+
{logoUrl && (
28+
<a
29+
href={logoUrl}
30+
download={`${organizationName.replace(/[^a-zA-Z0-9]/g, '_')}_logo`}
31+
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border bg-background transition-all hover:shadow-md"
32+
title="Download logo"
33+
>
34+
<Image src={logoUrl} alt={`${organizationName} logo`} fill className="object-contain" />
35+
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
36+
<Download className="h-4 w-4 text-white" />
37+
</div>
38+
</a>
39+
)}
40+
<div>
41+
<h1 className="text-foreground text-xl font-semibold tracking-tight">
42+
{organizationName}
43+
</h1>
44+
<p className="text-muted-foreground text-sm">Company Overview</p>
45+
</div>
46+
</div>
47+
48+
{/* Company Information */}
49+
<Section title="Company Information">
50+
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
51+
<InfoCell
52+
label="Employees"
53+
value={employeeCount || '—'}
54+
className="lg:border-r lg:border-border lg:pr-6"
55+
/>
56+
<InfoCell
57+
label="Report Signatory"
58+
className="lg:border-r lg:border-border lg:pr-6"
59+
value={
60+
reportSignatory ? (
61+
<div>
62+
<div className="flex items-baseline gap-2">
63+
<span className="font-medium">{reportSignatory.fullName}</span>
64+
<span className="text-muted-foreground text-xs">
65+
{reportSignatory.jobTitle}
66+
</span>
67+
</div>
68+
<div className="text-muted-foreground text-xs mt-0.5">
69+
{reportSignatory.email}
70+
</div>
71+
</div>
72+
) : (
73+
'—'
74+
)
75+
}
76+
/>
77+
<InfoCell
78+
label="Executive Team"
79+
className="sm:col-span-2 lg:col-span-1"
80+
value={
81+
cSuite.length > 0 ? (
82+
<div className="space-y-1">
83+
{cSuite.map((exec, i) => (
84+
<div key={i} className="flex items-baseline gap-2 text-sm">
85+
<span className="font-medium">{exec.name}</span>
86+
<span className="text-muted-foreground text-xs">{exec.title}</span>
87+
</div>
88+
))}
89+
</div>
90+
) : (
91+
'—'
92+
)
93+
}
94+
/>
95+
</div>
96+
</Section>
97+
98+
{/* Business Overview */}
99+
<Section title="Business Overview">
100+
<div className="space-y-6">
101+
<ContentRow
102+
title="Company Background & Overview of Operations"
103+
content={initialContent['Company Background & Overview of Operations']}
104+
/>
105+
<ContentRow
106+
title="Types of Services Provided"
107+
content={initialContent['Types of Services Provided']}
108+
/>
109+
<ContentRow title="Mission & Vision" content={initialContent['Mission & Vision']} />
110+
</div>
111+
</Section>
112+
113+
{/* System Architecture */}
114+
<Section title="System Architecture">
115+
<ContentRow title="System Description" content={initialContent['System Description']} />
116+
</Section>
117+
118+
{/* Third Party Dependencies */}
119+
<Section title="Third Party Dependencies">
120+
<div className="grid gap-6 lg:grid-cols-2">
121+
<ContentRow title="Critical Vendors" content={initialContent['Critical Vendors']} />
122+
<ContentRow
123+
title="Subservice Organizations"
124+
content={initialContent['Subservice Organizations']}
125+
/>
126+
</div>
127+
</Section>
128+
</div>
129+
);
130+
}
131+
132+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
133+
return (
134+
<div className="space-y-4">
135+
<div className="flex items-center gap-3 border-b border-border pb-2">
136+
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
137+
{title}
138+
</h2>
139+
</div>
140+
{children}
141+
</div>
142+
);
143+
}
144+
145+
function InfoCell({
146+
label,
147+
value,
148+
className,
149+
}: {
150+
label: string;
151+
value: React.ReactNode;
152+
className?: string;
153+
}) {
154+
return (
155+
<div className={className || ''}>
156+
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-1.5">
157+
{label}
158+
</div>
159+
<div className="text-sm text-foreground">{value}</div>
160+
</div>
161+
);
162+
}
163+
164+
function ContentRow({ title, content }: { title: string; content?: string }) {
165+
const hasContent = content?.trim().length;
166+
167+
return (
168+
<div className="space-y-1.5">
169+
<h3 className="text-sm font-medium text-foreground">{title}</h3>
170+
{hasContent ? (
171+
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">
172+
{content}
173+
</p>
174+
) : (
175+
<p className="text-xs text-muted-foreground/50">Not yet available</p>
176+
)}
177+
</div>
178+
);
179+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export default function Layout({ children }: { children: React.ReactNode }) {
2+
return <div className="m-auto max-w-[1200px] py-8">{children}</div>;
3+
}
4+

0 commit comments

Comments
 (0)