Skip to content

Commit 2dfa45e

Browse files
committed
chore: merge main into release for new releases
2 parents 060258f + d110131 commit 2dfa45e

11 files changed

Lines changed: 306 additions & 6 deletions

File tree

‎apps/api/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
$ npm install
3232
```
3333

34-
## Compile and run the project
34+
## Compile and run the project.
3535

3636
```bash
3737
# development

‎apps/api/src/evidence-forms/evidence-forms.controller.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,26 @@ export class EvidenceFormsController {
147147
});
148148
}
149149

150+
@Post(':formType/upload-submission')
151+
@ApiOperation({
152+
summary: 'Upload a file as an evidence submission',
153+
description:
154+
'Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation',
155+
})
156+
async uploadSubmission(
157+
@OrganizationId() organizationId: string,
158+
@AuthContext() authContext: AuthContextType,
159+
@Param('formType') formType: string,
160+
@Body() body: unknown,
161+
) {
162+
return this.evidenceFormsService.uploadSubmission({
163+
organizationId,
164+
formType,
165+
authContext,
166+
payload: body,
167+
});
168+
}
169+
150170
@Patch(':formType/submissions/:submissionId/review')
151171
@ApiOperation({
152172
summary: 'Review a submission',

‎apps/api/src/evidence-forms/evidence-forms.service.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ const uploadSchema = z.object({
3434
fileData: z.string().min(1),
3535
});
3636

37+
const uploadSubmissionBodySchema = z.object({
38+
fileName: z.string().min(1),
39+
fileType: z.string().min(1),
40+
fileData: z.string().min(1),
41+
});
42+
3743
const reviewSchema = z.object({
3844
action: z.enum(['approved', 'rejected']),
3945
reason: z.string().trim().optional(),
@@ -432,6 +438,77 @@ export class EvidenceFormsService {
432438
};
433439
}
434440

441+
async uploadSubmission(params: {
442+
organizationId: string;
443+
formType: string;
444+
authContext: AuthContext;
445+
payload: unknown;
446+
}) {
447+
const parsedType = evidenceFormTypeSchema.safeParse(params.formType);
448+
if (!parsedType.success) {
449+
throw new BadRequestException('Unsupported form type');
450+
}
451+
452+
const userId = this.requireJwtUser(params.authContext);
453+
454+
const parsed = uploadSubmissionBodySchema.safeParse(params.payload);
455+
if (!parsed.success) {
456+
throw new BadRequestException(parsed.error.flatten());
457+
}
458+
459+
if (parsed.data.fileData.length > MAX_UPLOAD_BASE64_LENGTH) {
460+
throw new BadRequestException(
461+
`File exceeds the ${MAX_UPLOAD_FILE_SIZE_BYTES / (1024 * 1024)}MB limit`,
462+
);
463+
}
464+
465+
const fileBuffer = this.decodeBase64File(parsed.data.fileData);
466+
if (fileBuffer.length > MAX_UPLOAD_FILE_SIZE_BYTES) {
467+
throw new BadRequestException(
468+
`File exceeds the ${MAX_UPLOAD_FILE_SIZE_BYTES / (1024 * 1024)}MB limit`,
469+
);
470+
}
471+
472+
const fileKey = await this.attachmentsService.uploadToS3(
473+
fileBuffer,
474+
parsed.data.fileName,
475+
parsed.data.fileType,
476+
params.organizationId,
477+
'evidence-forms',
478+
parsedType.data,
479+
);
480+
481+
const downloadUrl =
482+
await this.attachmentsService.getPresignedDownloadUrl(fileKey);
483+
484+
return await db.evidenceSubmission
485+
.create({
486+
data: {
487+
organizationId: params.organizationId,
488+
formType: toDbEvidenceFormType(parsedType.data),
489+
submittedById: userId,
490+
data: {
491+
submissionDate: new Date().toISOString(),
492+
evidenceFile: {
493+
fileName: parsed.data.fileName,
494+
fileKey,
495+
downloadUrl,
496+
},
497+
},
498+
},
499+
include: {
500+
submittedBy: {
501+
select: {
502+
id: true,
503+
name: true,
504+
email: true,
505+
},
506+
},
507+
},
508+
})
509+
.then(normalizeSubmissionFormType);
510+
}
511+
435512
async exportCsv(params: {
436513
organizationId: string;
437514
formType: string;

‎apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx‎

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,20 @@ import {
2929
TableRow,
3030
Text,
3131
} from '@trycompai/design-system';
32-
import { Add, Catalog, Download, Search } from '@trycompai/design-system/icons';
32+
import { Add, Catalog, Download, Search, Upload } from '@trycompai/design-system/icons';
33+
import {
34+
Dialog,
35+
DialogContent,
36+
DialogDescription,
37+
DialogFooter,
38+
DialogHeader,
39+
DialogTitle,
40+
} from '@comp/ui/dialog';
3341
import Link from 'next/link';
3442
import { useRouter } from 'next/navigation';
35-
import { useMemo, useState } from 'react';
43+
import { useCallback, useMemo, useRef, useState } from 'react';
3644
import { toast } from 'sonner';
37-
import useSWR from 'swr';
45+
import useSWR, { useSWRConfig } from 'swr';
3846
import { DocumentFindingsSection } from './DocumentFindingsSection';
3947
import { StatusBadge, formatSubmissionDate } from './submission-utils';
4048

@@ -84,6 +92,19 @@ function getMatrixRowCount(value: unknown): number {
8492
return value.filter((row) => row && typeof row === 'object').length;
8593
}
8694

95+
function fileToBase64(file: File): Promise<string> {
96+
return new Promise((resolve, reject) => {
97+
const reader = new FileReader();
98+
reader.onload = () => {
99+
const result = reader.result as string;
100+
const base64 = result.split(',')[1] ?? '';
101+
resolve(base64);
102+
};
103+
reader.onerror = reject;
104+
reader.readAsDataURL(file);
105+
});
106+
}
107+
87108
async function evidenceFormFetcher([endpoint, orgId]: readonly [
88109
string,
89110
string,
@@ -107,8 +128,13 @@ export function CompanyFormPageClient({
107128
isPlatformAdmin: boolean;
108129
}) {
109130
const router = useRouter();
131+
const { mutate: globalMutate } = useSWRConfig();
110132
const [search, setSearch] = useState('');
111133
const [isExporting, setIsExporting] = useState(false);
134+
const [isUploadOpen, setIsUploadOpen] = useState(false);
135+
const [isUploading, setIsUploading] = useState(false);
136+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
137+
const fileInputRef = useRef<HTMLInputElement>(null);
112138

113139
const { data: activeMember } = useActiveMember();
114140
const memberRoles = activeMember?.role?.split(',').map((role: string) => role.trim()) || [];
@@ -248,6 +274,47 @@ export function CompanyFormPageClient({
248274
}
249275
};
250276

277+
const handleUploadEvidence = useCallback(async () => {
278+
if (!selectedFile) return;
279+
280+
setIsUploading(true);
281+
try {
282+
const fileData = await fileToBase64(selectedFile);
283+
const submitFormType = isMeeting ? MEETING_SUB_TYPES[0] : formType;
284+
285+
const response = await api.post(
286+
`/v1/evidence-forms/${submitFormType}/upload-submission`,
287+
{
288+
fileName: selectedFile.name,
289+
fileType: selectedFile.type || 'application/octet-stream',
290+
fileData,
291+
},
292+
organizationId,
293+
);
294+
295+
if (response.error) {
296+
throw new Error(response.error);
297+
}
298+
299+
toast.success('Evidence uploaded');
300+
setIsUploadOpen(false);
301+
setSelectedFile(null);
302+
if (fileInputRef.current) fileInputRef.current.value = '';
303+
304+
if (isMeeting) {
305+
for (const subType of MEETING_SUB_TYPES) {
306+
globalMutate([`/v1/evidence-forms/${subType}${query}`, organizationId]);
307+
}
308+
} else {
309+
globalMutate([`/v1/evidence-forms/${formType}${query}`, organizationId]);
310+
}
311+
} catch (error) {
312+
toast.error(error instanceof Error ? error.message : 'Upload failed');
313+
} finally {
314+
setIsUploading(false);
315+
}
316+
}, [selectedFile, isMeeting, formType, organizationId, query, globalMutate]);
317+
251318
return (
252319
<div className="space-y-6">
253320
<PageHeader
@@ -257,6 +324,14 @@ export function CompanyFormPageClient({
257324
<Link href={`/${organizationId}/documents/${formType}/new`}>
258325
<Button iconLeft={<Add size={16} />}>New Submission</Button>
259326
</Link>
327+
<Button
328+
type="button"
329+
variant="secondary"
330+
iconLeft={<Upload size={16} />}
331+
onClick={() => setIsUploadOpen(true)}
332+
>
333+
Upload Evidence
334+
</Button>
260335
<Button
261336
type="button"
262337
variant="secondary"
@@ -414,6 +489,58 @@ export function CompanyFormPageClient({
414489
isPlatformAdmin={isPlatformAdmin}
415490
isAdminOrOwner={isAdminOrOwner}
416491
/>
492+
493+
<Dialog
494+
open={isUploadOpen}
495+
onOpenChange={(open) => {
496+
setIsUploadOpen(open);
497+
if (!open) {
498+
setSelectedFile(null);
499+
if (fileInputRef.current) fileInputRef.current.value = '';
500+
}
501+
}}
502+
>
503+
<DialogContent>
504+
<DialogHeader>
505+
<DialogTitle>Upload Evidence</DialogTitle>
506+
<DialogDescription>
507+
Upload a PDF or image as evidence for this document.
508+
</DialogDescription>
509+
</DialogHeader>
510+
<div className="space-y-4">
511+
<input
512+
ref={fileInputRef}
513+
type="file"
514+
accept=".pdf,.png,.jpg,.jpeg"
515+
onChange={(e) => setSelectedFile(e.target.files?.[0] ?? null)}
516+
className="block w-full text-sm text-foreground file:mr-4 file:rounded-md file:border-0 file:bg-muted file:px-4 file:py-2 file:text-sm file:font-medium file:text-foreground hover:file:bg-muted/80 file:cursor-pointer cursor-pointer"
517+
/>
518+
{selectedFile && (
519+
<Text size="sm" variant="muted">
520+
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
521+
</Text>
522+
)}
523+
</div>
524+
<DialogFooter>
525+
<Button
526+
type="button"
527+
variant="secondary"
528+
onClick={() => setIsUploadOpen(false)}
529+
disabled={isUploading}
530+
>
531+
Cancel
532+
</Button>
533+
<Button
534+
type="button"
535+
onClick={handleUploadEvidence}
536+
disabled={!selectedFile || isUploading}
537+
loading={isUploading}
538+
>
539+
Upload
540+
</Button>
541+
</DialogFooter>
542+
</DialogContent>
543+
</Dialog>
417544
</div>
418545
);
419546
}

‎apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionDetailPageClient.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,17 @@ export function CompanySubmissionDetailPageClient({
289289
</div>
290290
</div>
291291
))}
292+
{Boolean(submission.data.evidenceFile) &&
293+
!fields.some((f) => f.key === 'evidenceFile') && (
294+
<div className="grid grid-cols-1 gap-2 p-4 lg:grid-cols-3">
295+
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
296+
Uploaded Evidence
297+
</div>
298+
<div className="lg:col-span-2 text-sm">
299+
{renderSubmissionValue(submission.data.evidenceFile)}
300+
</div>
301+
</div>
302+
)}
292303
{matrixFields.map((field) => {
293304
const rows = normalizeMatrixRows(submission.data[field.key]);
294305
return (

‎apps/app/src/app/(app)/[orgId]/frameworks/lib/getPeople.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { trainingVideos } from '@/lib/data/training-videos';
22
import { db } from '@db';
33

44
export async function getPeopleScore(organizationId: string) {
5-
// Get all active members (employees and contractors)
5+
// Get all active members (employees and contractors); exclude inactive/deactivated
66
const allMembers = await db.member.findMany({
77
where: {
88
organizationId,
9+
isActive: true,
910
deactivated: false,
1011
},
1112
include: {

‎apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/check-dns-record.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ export const checkDnsRecordAction = authActionClient
119119
expectedVercelTxtValue ?? null,
120120
);
121121

122-
const isVerified = isCnameVerified && isTxtVerified && isVercelTxtVerified;
122+
const requiresVercelTxt = isVercelDomain?.isVercelDomain === true;
123+
const isVerified = isCnameVerified && isTxtVerified && (!requiresVercelTxt || isVercelTxtVerified);
123124

124125
if (!isVerified) {
125126
return {

‎apps/portal/src/app/globals.css‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@import 'tailwindcss';
2+
@config '../../tailwind.config.ts';

‎apps/portal/src/app/layout.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { auth } from '@/app/lib/auth';
22
import { env } from '@/env.mjs';
33
import { initializeServer } from '@comp/analytics/server';
44
import { cn } from '@comp/ui/cn';
5+
import './globals.css';
56
import '@trycompai/design-system/globals.css';
67
import { GeistMono } from 'geist/font/mono';
78
import type { Metadata } from 'next';

‎apps/portal/tailwind.config.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import path from 'node:path';
2+
import { fileURLToPath } from 'node:url';
3+
import type { Config } from 'tailwindcss';
4+
import uiConfig from '@trycompai/ui/tailwind.config';
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
8+
/**
9+
* Portal uses the same Tailwind theme as @comp/ui so that UI components
10+
* (Dialog, Button, etc.) get correct utilities and design tokens.
11+
* Content paths are resolved from this config file so they work regardless
12+
* of cwd (e.g. when build runs from monorepo root).
13+
*/
14+
export default {
15+
...uiConfig,
16+
content: [
17+
path.join(__dirname, 'src', '**', '*.{ts,tsx}'),
18+
path.join(__dirname, '..', '..', 'packages', 'ui', 'src', '**', '*.{ts,tsx}'),
19+
path.join(__dirname, 'node_modules', '@trycompai', 'ui', 'src', '**', '*.{ts,tsx}'),
20+
],
21+
} satisfies Config;

0 commit comments

Comments
 (0)