System Architecture
Core Features
Data Management
Frontend Components
Extensibility
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [apps/api/src/evidence-forms/evidence-forms.service.ts](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts)
- [apps/api/src/evidence-forms/evidence-forms.definitions.ts](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.definitions.ts)
- [apps/api/src/evidence-forms/evidence-forms.controller.ts](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.controller.ts)
- [apps/api/src/evidence-forms/evidence-forms.module.ts](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.module.ts)
</details>
The Evidence Forms module provides a robust system for managing and processing various types of evidence submissions within an organization. It defines a set of pre-built forms, handles their submission, validation, file uploads, and review processes. This module integrates with authentication and attachment services to ensure secure and efficient evidence management.
At a high level, the system allows users to submit structured data and associated files for different evidence types (e.g., meeting minutes, policy documents). Authorized personnel can then review these submissions, approving or rejecting them with reasons. All interactions are secured through JWT or API key authentication and scoped to specific organizations.
## Architecture Overview
The Evidence Forms module follows a standard NestJS architecture, comprising a Controller, Service, and Module. It leverages shared definitions for form structures and integrates with other core services like `AttachmentsService` for file management and the database (`@trycompai/db`) for persistence.
The `EvidenceFormsController` exposes RESTful API endpoints, handling incoming HTTP requests. These requests are then delegated to the `EvidenceFormsService`, which encapsulates the business logic, data validation, and interactions with the database and other services. The `EvidenceFormsModule` orchestrates these components, declaring dependencies and making the service available for injection.
Sources:
- [apps/api/src/evidence-forms/evidence-forms.module.ts:1-11](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.module.ts#L1-L11)
- [apps/api/src/evidence-forms/evidence-forms.controller.ts:1-50](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.controller.ts#L1-L50)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:1-26](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L1-L26)
## Data Structures and Definitions
The core of the Evidence Forms system relies on well-defined data structures, primarily sourced from a shared `@comp/company` package. These definitions dictate the structure of forms, their fields, and the schema for submissions.
### Form Definitions
Key definitions include:
* `evidenceFormTypeSchema`: A Zod schema for validating the type of an evidence form (e.g., 'meeting', 'policy').
* `evidenceFormDefinitions`: An object mapping `EvidenceFormType` to its detailed `EvidenceFormDefinition`.
* `evidenceFormDefinitionList`: An array of all available `EvidenceFormDefinition` objects.
* `evidenceFormSubmissionSchemaMap`: A map where each `EvidenceFormType` is associated with its specific Zod schema for validating submission payloads.
* `EvidenceFormFieldDefinition`: Describes a single field within an evidence form, including its key, type (e.g., 'text', 'file', 'matrix'), and validation rules.
* `EvidenceFormDefinition`: Defines an entire evidence form, including its `type`, `name`, `description`, `fields`, and `submissionDateMode`.
Sources:
- [apps/api/src/evidence-forms/evidence-forms.definitions.ts:1-10](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.definitions.ts#L1-L10)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:10-16](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L10-L16)
### Validation Schemas
The service uses Zod for robust input validation:
* `listQuerySchema`: Validates query parameters for listing submissions (search, limit, offset).
* `uploadSchema`: Validates parameters for file uploads (formType, fileName, fileType, fileData).
* `reviewSchema`: Validates the payload for reviewing a submission (action: 'approved' | 'rejected', reason).
\`\`\`typescript
// Example: listQuerySchema
const listQuerySchema = z.object({
search: z.string().trim().optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
});
\`\`\`
Sources:
- [apps/api/src/evidence-forms/evidence-forms.service.ts:18-22](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L18-L22)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:24-28](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L24-L28)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:30-33](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L30-L33)
## Core Service Logic (`EvidenceFormsService`)
The `EvidenceFormsService` handles all business logic related to evidence forms.
### Authentication and Authorization
The service enforces strict access control:
* `requireJwtUser(authContext: AuthContext)`: Ensures the request is authenticated via JWT and has a `userId`. API key authentication is explicitly denied for operations requiring a user context.
* `requirePrivilegedEvidenceAccess(authContext: AuthContext)`: Builds upon `requireJwtUser` by checking if the authenticated user possesses one of the `EVIDENCE_FORM_REVIEWER_ROLES` (`owner`, `admin`, `auditor`). This is crucial for operations like reviewing submissions or exporting data.
<Callout variant="info" title="Role-Based Access Control">
The `EVIDENCE_FORM_REVIEWER_ROLES` constant defines which user roles are authorized to perform privileged actions on evidence forms.
</Callout>
Sources:
- [apps/api/src/evidence-forms/evidence-forms.service.ts:100-109](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L100-L109)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:111-124](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L111-L124)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:35-35](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L35-L35)
### File Handling
The service provides functionality for uploading and managing files associated with evidence forms.
* `decodeBase64File(fileData: string)`: Decodes a base64 encoded file string into a Buffer, performing basic validation on the input format and size.
* `uploadFile(params: { ... })`: Handles the entire file upload process. It validates the input payload, decodes the base64 file data, checks against size limits (`MAX_UPLOAD_FILE_SIZE_BYTES`, `MAX_UPLOAD_BASE64_LENGTH`), and then delegates to `AttachmentsService` to upload the file to S3 and generate a presigned download URL.
<Callout variant="warning" title="File Size Limits">
Uploaded files are subject to a maximum size limit of 100MB. This is enforced both by checking the base64 string length and the decoded file buffer length.
</Callout>
Sources:
- [apps/api/src/evidence-forms/evidence-forms.service.ts:126-146](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L126-L146)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:37-38](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L37-L38)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:321-356](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L321-L356)
### Data Transformation and Export
Several utility functions facilitate data manipulation, especially for CSV export:
* `toCsvRow(values: string[])`: Converts an array of strings into a CSV row, handling proper escaping of double quotes.
* `flattenValue(value: unknown)`: Converts various data types (objects, numbers, booleans, strings) into a string representation suitable for CSV. Special handling is included for file objects to return their download URL.
* `flattenMatrixRows(value: unknown, field: EvidenceFormFieldDefinition)`: Specifically designed to flatten data from 'matrix' type form fields into a readable string format for CSV.
* `normalizeSubmissionFormType<T>(submission: T)`: Transforms the internal database `DbEvidenceFormType` to the external `EvidenceFormType` for API responses.
Sources:
- [apps/api/src/evidence-forms/evidence-forms.service.ts:40-42](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L40-L42)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:44-71](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L44-L71)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:73-94](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L73-L94)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:96-98](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L96-L98)
### Submission and Review Workflow
The service manages the lifecycle of evidence form submissions.
**Key methods:**
* `listForms()`: Returns a list of all available evidence form definitions.
* `getFormStatuses(organizationId: string)`: Retrieves the latest submission date for each form type within a given organization.
* `getFormWithSubmissions(params: { ... })`: Fetches a specific form definition along with its submissions for an organization. Requires privileged access and supports search, pagination.
* `getSubmission(params: { ... })`: Retrieves a single evidence form submission by ID. Requires privileged access.
* `submitForm(params: { ... })`: Creates a new evidence form submission. It validates the payload against the form's schema, handles automatic submission date population, and stores the data in the database.
* `reviewSubmission(params: { ... })`: Allows privileged users to approve or reject a pending submission. Requires a reason for rejection.
* `getMySubmissions(params: { ... })`: Retrieves all submissions made by the currently authenticated user.
* `getPendingSubmissionCount(params: { ... })`: Returns the count of pending submissions for the authenticated user.
* `exportCsv(params: { ... })`: Exports all submissions for a specific form type within an organization to a CSV format. This method requires privileged access and uses the data transformation utilities (`flattenValue`, `flattenMatrixRows`, `toCsvRow`) to format the output. It also generates presigned URLs for any attached files.
Sources:
- [apps/api/src/evidence-forms/evidence-forms.service.ts:148-150](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L148-L150)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:152-167](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L152-L167)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:169-216](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L169-L216)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:218-251](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L218-L251)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:253-319](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L253-L319)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:358-444](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L358-L444)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:446-500](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L446-L500)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:502-526](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L502-L526)
- [apps/api/src/evidence-forms/evidence-forms.service.ts:528-543](https://github.com/blade47/comp/blob/main/apps/api/src/evidence-forms/evidence-forms.service.ts#L528-L543)
## API Endpoints (`EvidenceFormsController`)
The `EvidenceFormsController` exposes a set of RESTful API endpoints for interacting with the evidence forms system. All endpoints are protected by `HybridAuthGuard` and require an `X-Organization-Id` header.
| Method | Path | Summary | Description --- File: apps/api/src/evidence-forms/evidence-forms.service.ts ---
import { AttachmentsService } from '@/attachments/attachments.service';
import type { AuthContext } from '@/auth/types';
import { db, EvidenceFormType as DbEvidenceFormType } from '@trycompai/db';
import {
toDbEvidenceFormType,
toExternalEvidenceFormType,
} from '@comp/company';
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { z } from 'zod';
import {
evidenceFormDefinitionList,
evidenceFormDefinitions,
evidenceFormSubmissionSchemaMap,
evidenceFormTypeSchema,
type EvidenceFormFieldDefinition,
type EvidenceFormType,
} from './evidence-forms.definitions';
const listQuerySchema = z.object({
search: z.string().trim().optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
});
const uploadSchema = z.object({
formType: evidenceFormTypeSchema,
fileName: z.string().min(1),
fileType: z.string().min(1),
fileData: z.string().min(1),
});
const reviewSchema = z.object({
action: z.enum(['approved', 'rejected']),
reason: z.string().trim().optional(),
});
const EVIDENCE_FORM_REVIEWER_ROLES = ['owner', 'admin', 'auditor'] as const;
const MAX_UPLOAD_FILE_SIZE_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BASE64_LENGTH = Math.ceil(MAX_UPLOAD_FILE_SIZE_BYTES / 3) * 4;
function toCsvRow(values: string[]): string {
return values.map((value) => `"${value.replace(/"/g, '""')}"`).join(',');
}
function flattenValue(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
if (
'fileName' in value &&
typeof value.fileName === 'string' &&
'downloadUrl' in value &&
typeof value.downloadUrl === 'string'
) {
return value.downloadUrl;
}
return JSON.stringify(value);
}
if (typeof value === 'string') {
return value;
}
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'bigint'
) {
return value.toString();
}
if (typeof value === 'symbol') {
return value.description ?? '';
}
return '';
}
function flattenMatrixRows(
value: unknown,
field: EvidenceFormFieldDefinition,
): string {
if (!Array.isArray(value)) {
return '';
}
const columns = Array.isArray(field.columns) ? field.columns : [];
if (columns.length === 0) {
return JSON.stringify(value);
}
return value
.filter((row) => row && typeof row === 'object')
.map((row) => {
const rowRecord = row as Record<string, unknown>;
return columns
.map((column) => {
const cellValue = rowRecord[column.key];
const normalizedValue =
typeof cellValue === 'string' ? cellValue : '';
return `${column.label}: ${normalizedValue}`;
})
.join(' | ');
})
.join(' || ');
}
function normalizeSubmissionFormType<
T extends { formType: DbEvidenceFormType },
>(submission: T): Omit<T, 'formType'> & { formType: EvidenceFormType } {
return {
...submission,
formType: toExternalEvidenceFormType(submission.formType) ?? 'meeting',
};
}
@Injectable()
export class EvidenceFormsService {
constructor(private readonly attachmentsService: AttachmentsService) {}
private requireJwtUser(authContext: AuthContext): string {
if (authContext.isApiKey || authContext.authType === 'api-key') {
throw new UnauthorizedException(
'This endpoint requires JWT authentication and does not support API key authentication',
);
}
if (!authContext.userId) {
throw new UnauthorizedException('Authenticated user session is required');
}
return authContext.userId;
}
private requirePrivilegedEvidenceAccess(authContext: AuthContext): string {
const userId = this.requireJwtUser(authContext);
const roles = authContext.userRoles ?? [];
const hasRequiredRole = EVIDENCE_FORM_REVIEWER_ROLES.some((role) =>
roles.includes(role),
);
if (!hasRequiredRole) {
throw new UnauthorizedException(
`Access denied. Required one of roles: ${EVIDENCE_FORM_REVIEWER_ROLES.join(', ')}`,
);
}
return userId;
}
private decodeBase64File(fileData: string): Buffer {
const normalized = fileData.trim();
if (normalized.length === 0 || normalized.length % 4 !== 0) {
throw new BadRequestException(
'Invalid file data. Expected base64 string.',
);
}
const base64Pattern = /^[A-Za-z0-9+/]+={0,2}$/;
if (!base64Pattern.test(normalized)) {
throw new BadRequestException(
'Invalid file data. Expected base64 string.',
);
}
const fileBuffer = Buffer.from(normalized, 'base64');
if (!fileBuffer.length) {
throw new BadRequestException('File cannot be empty');
}
return fileBuffer;
}
listForms() {
return evidenceFormDefinitionList;
}
async getFormStatuses(organizationId: string) {
const results = await db.evidenceSubmission.groupBy({
by: ['formType'],
where: { organizationId },
_max: { submittedAt: true },
});
const statuses: Record<string, { lastSubmittedAt: string | null }> = {};
for (const form of evidenceFormDefinitionList) {
const match = results.find(
(r) => r.formType === toDbEvidenceFormType(form.type),
);
statuses[form.type] = {
lastSubmittedAt: match?._max.submittedAt?.toISOString() ?? null,
};
}
return statuses;
}
async getFormWithSubmissions(params: {
organizationId: string;
authContext: AuthContext;
formType: string;
search?: string;
limit?: string;
offset?: string;
}) {
const { organizationId, formType } = params;
this.requirePrivilegedEvidenceAccess(params.authContext);
const parsedType = evidenceFormTypeSchema.safeParse(formType);
if (!parsedType.success) {
throw new BadRequestException('Unsupported form type');
}
const parsedQuery = listQuerySchema.safeParse({
search: params.search,
limit: params.limit,
offset: params.offset,
});
if (!parsedQuery.success) {
throw new BadRequestException(parsedQuery.error.flatten());
}
const query = parsedQuery.data;
const submissions = await db.evidenceSubmission.findMany({
where: {
organizationId,
formType: toDbEvidenceFormType(parsedType.data),
},
include: {
submittedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
submittedAt: 'desc',
},
});
const filtered = query.search
? submissions.filter((submission) => {
const searchTarget = JSON.stringify(submission.data).toLowerCase();
return searchTarget.includes(query.search!.toLowerCase());
})
: submissions;
const paginated = filtered.slice(query.offset, query.offset + query.limit);
return {
form: evidenceFormDefinitions[parsedType.data],
submissions: paginated.map(normalizeSubmissionFormType),
total: filtered.length,
};
}
async getSubmission(params: {
organizationId: string;
authContext: AuthContext;
formType: string;
submissionId: string;
}) {
this.requirePrivilegedEvidenceAccess(params.authContext);
const parsedType = evidenceFormTypeSchema.safeParse(params.formType);
if (!parsedType.success) {
throw new BadRequestException('Unsupported form type');
}
const submission = await db.evidenceSubmission.findFirst({
where: {
id: params.submissionId,
organizationId: params.organizationId,
formType: toDbEvidenceFormType(parsedType.data),
},
include: {
submittedBy: {
select: {
id: true,
name: true,
email: true,
},
},
reviewedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (!submission) {
throw new NotFoundException('Submission not found');
}
return {
form: evidenceFormDefinitions[parsedType.data],
submission: normalizeSubmissionFormType(submission),
};
}
async submitForm(params: {
organizationId: string;
formType: string;
payload: unknown;
authContext: AuthContext;
}) {
const parsedType = evidenceFormTypeSchema.safeParse(params.formType);
if (!parsedType.success) {
throw new BadRequestException('Unsupported form type');
}
if (!params.authContext.userId) {
throw new BadRequestException(
'Authenticated user session is required to submit evidence forms',
);
}
const formDefinition = evidenceFormDefinitions[parsedType.data];
const nowIso = new Date().toISOString();
if (!params.payload || typeof params.payload !== 'object') {
throw new BadRequestException('Submission payload must be an object');
}
const payloadObject: Record<string, unknown> = {
...(params.payload as Record<string, unknown>),
};
if (formDefinition.submissionDateMode === 'auto') {
payloadObject.submissionDate = nowIso;
}
const schema = evidenceFormSubmissionSchemaMap[parsedType.data];
const parsedPayload = schema.safeParse(payloadObject);
if (!parsedPayload.success) {
const flattened = parsedPayload.error.flatten();
const fieldErrors = Object.entries(flattened.fieldErrors)
.map(([field, messages]) => {
const msg =
Array.isArray(messages) && messages.length > 0
? messages[0]
: 'is required';
return `${field}: ${msg}`;
})
.slice(0, 5);
const message =
fieldErrors.length > 0
? `Please fix the following: ${fieldErrors.join('; ')}`
: 'Please fill in all required fields';
throw new BadRequestException(message);
}
return await db.evidenceSubmission
.create({
data: {
organizationId: params.organizationId,
formType: toDbEvidenceFormType(parsedType.data),
submittedById: params.authContext.userId,
data: parsedPayload.data,
},
include: {
submittedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
})
.then(normalizeSubmissionFormType);
}
async uploadFile(params: {
organizationId: string;
authContext: AuthContext;
payload: unknown;
}) {
if (!params.authContext.userId) {
throw new BadRequestException(
'Authenticated user session is required to upload evidence files',
);
}
const parsed = uploadSchema.safeParse(params.payload);
if (!parsed.success) {
throw new BadRequestException(parsed.error.flatten());
}
if (parsed.data.fileData.length > MAX_UPLOAD_BASE64_LENGTH) {
throw new BadRequestException(
`File exceeds the ${MAX_UPLOAD_FILE_SIZE_BYTES / (1024 * 1024)}MB limit`,
);
}
const fileBuffer = this.decodeBase64File(parsed.data.fileData);
if (fileBuffer.length > MAX_UPLOAD_FILE_SIZE_BYTES) {
throw new BadRequestException(
`File exceeds the ${MAX_UPLOAD_FILE_SIZE_BYTES / (1024 * 1024)}MB limit`,
);
}
const fileKey = await this.attachmentsService.uploadToS3(
fileBuffer,
parsed.data.fileName,
parsed.data.fileType,
params.organizationId,
'evidence-forms',
parsed.data.formType,
);
const downloadUrl =
await this.attachmentsService.getPresignedDownloadUrl(fileKey);
return {
fileName: parsed.data.fileName,
fileKey,
downloadUrl,
};
}
async exportCsv(params: {
organizationId: string;
formType: string;
authContext: AuthContext;
}) {
this.requirePrivilegedEvidenceAccess(params.authContext);
const parsedType = evidenceFormTypeSchema.safeParse(params.formType);
if (!parsedType.success) {
throw new BadRequestException('Unsupported form type');
}
const formType: EvidenceFormType = parsedType.data;
const form = evidenceFormDefinitions[formType];
const submissions = await db.evidenceSubmission.findMany({
where: {
organizationId: params.organizationId,
formType: toDbEvidenceFormType(formType),
},
include: {
submittedBy: {
select: {
name: true,
email: true,
},
},
},
orderBy: {
submittedAt: 'desc',
},
});
if (submissions.length === 0) {
throw new BadRequestException(
'No submissions available for export for this form',
);
}
const headers = [
'submissionId',
'submissionDate',
'submittedByName',
'submittedByEmail',
...form.fields
.filter((field) => field.key !== 'submissionDate')
.map((field) => field.key),
];
const rows = await Promise.all(
submissions.map(async (submission) => {
const data = submission.data as Record<string, unknown>;
const fieldValues = await Promise.all(
form.fields
.filter((field) => field.key !== 'submissionDate')
.map(async (field) => {
const rawValue = data[field.key];
if (
rawValue &&
typeof rawValue === 'object' &&
'fileKey' in rawValue &&
typeof rawValue.fileKey === 'string'
) {
const signedUrl =
await this.attachmentsService.getPresignedDownloadUrl(
rawValue.fileKey,
);
return signedUrl;
}
if (field.type === 'matrix') {
return flattenMatrixRows(rawValue, field);
}
return flattenValue(rawValue);
}),
);
return [
submission.id,
typeof data.submissionDate === 'string'
? data.submissionDate
: submission.submittedAt.toISOString(),
submission.submittedBy?.name ?? '',
submission.submittedBy?.email ?? '',
...fieldValues,
];
}),
);
const csvLines = [toCsvRow(headers), ...rows.map((row) => toCsvRow(row))];
return csvLines.join('\n');
}
async reviewSubmission(params: {
organizationId: string;
formType: string;
submissionId: string;
payload: unknown;
authContext: AuthContext;
}) {
const parsedType = evidenceFormTypeSchema.safeParse(params.formType);
if (!parsedType.success) {
throw new BadRequestException('Unsupported form type');
}
const reviewerUserId = this.requirePrivilegedEvidenceAccess(
params.authContext,
);
const parsed = reviewSchema.safeParse(params.payload);
if (!parsed.success) {
throw new BadRequestException(parsed.error.flatten());
}
if (parsed.data.action === 'rejected' && !parsed.data.reason) {
throw new BadRequestException(
'A reason is required when rejecting a submission',
);
}
const submission = await db.evidenceSubmission.findFirst({
where: {
id: params.submissionId,
organizationId: params.organizationId,
formType: toDbEvidenceFormType(parsedType.data),
},
});
if (!submission) {
throw new NotFoundException('Submission not found');
}
if (submission.status !== 'pending') {
throw new BadRequestException(
'Submission must be pending to be reviewed',
);
}
return await db.evidenceSubmission
.update({
where: { id: params.submissionId },
data: {
status: parsed.data.action,
reviewedById: reviewerUserId,
reviewedAt: new Date(),
reviewReason: parsed.data.reason,
},
include: {
submittedBy: {
select: {
id: true,
name: true,
email: true,
},
},
reviewedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
})
.then(normalizeSubmissionFormType);
}
async getMySubmissions(params: {
organizationId: string;
authContext: AuthContext;
formType?: string;
}) {
const userId = this.requireJwtUser(params.authContext);
const where: Record<string, unknown> = {
organizationId: params.organizationId,
submittedById: userId,
};
if (params.formType) {
const parsedType = evidenceFormTypeSchema.safeParse(params.formType);
if (!parsedType.success) {
throw new BadRequestException('Unsupported form type');
}
where.formType = toDbEvidenceFormType(parsedType.data);
}
return await db.evidenceSubmission
.findMany({
where,
include: {
reviewedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
submittedAt: 'desc',
},
})
.then((submissions) => submissions.map(normalizeSubmissionFormType));
}
async getPendingSubmissionCount(params: {
organizationId: string;
authContext: AuthContext;
}) {
const userId = this.requireJwtUser(params.authContext);
const count = await db.evidenceSubmission.count({
where: {
organizationId: params.organizationId,
submittedById: userId,
status: 'pending',
},
});
return { count };
}
}
--- File: apps/api/src/evidence-forms/evidence-forms.definitions.ts ---
// Single source of truth: re-export from shared @comp/company package
export {
evidenceFormTypeSchema,
evidenceFormFileSchema,
evidenceFormSubmissionSchemaMap,
evidenceFormDefinitions,
evidenceFormDefinitionList,
type EvidenceFormType,
type EvidenceFormFieldDefinition,
type EvidenceFormDefinition,
} from '@comp/company';
--- File: apps/api/src/evidence-forms/evidence-forms.controller.ts ---
import { AuthContext, OrganizationId } from '@/auth/auth-context.decorator';
import { HybridAuthGuard } from '@/auth/hybrid-auth.guard';
import type { AuthContext as AuthContextType } from '@/auth/types';
import {
Body,
Controller,
Get,
Header,
Param,
Patch,
Post,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiHeader, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { EvidenceFormsService } from './evidence-forms.service';
@ApiTags('Evidence Forms')
@Controller({ path: 'evidence-forms', version: '1' })
@UseGuards(HybridAuthGuard)
@ApiSecurity('apikey')
@ApiHeader({
name: 'X-Organization-Id',
description:
'Organization ID (required for session auth, optional for API key auth)',
required: false,
})
export class EvidenceFormsController {
constructor(private readonly evidenceFormsService: EvidenceFormsService) {}
@Get()
@ApiOperation({
summary: 'List evidence forms',
description: 'List all available pre-built evidence forms',
})
listForms() {
return this.evidenceFormsService.listForms();
}
@Get('statuses')
@ApiOperation({
summary: 'Get submission statuses for all forms',
description:
'Returns the latest submission date per form type for the active organization',
})
async getFormStatuses(@OrganizationId() organizationId: string) {
return this.evidenceFormsService.getFormStatuses(organizationId);
}
@Get('my-submissions')
@ApiOperation({
summary: 'Get current user submissions',
description:
'Returns all evidence form submissions by the authenticated user for the active organization',
})
async getMySubmissions(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Query('formType') formType?: string,
) {
return this.evidenceFormsService.getMySubmissions({
organizationId,
authContext,
formType,
});
}
@Get('my-submissions/pending-count')
@ApiOperation({
summary: 'Get pending submission count for current user',
description:
'Returns the count of pending evidence submissions for the authenticated user',
})
async getPendingSubmissionCount(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
) {
return this.evidenceFormsService.getPendingSubmissionCount({
organizationId,
authContext,
});
}
@Get(':formType')
@ApiOperation({
summary: 'Get form definition and submissions',
description:
'Fetch a specific form definition with submissions for the active organization',
})
async getFormWithSubmissions(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Param('formType') formType: string,
@Query('search') search?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.evidenceFormsService.getFormWithSubmissions({
organizationId,
authContext,
formType,
search,
limit,
offset,
});
}
@Get(':formType/submissions/:submissionId')
@ApiOperation({
summary: 'Get a single submission',
description:
'Fetch one evidence form submission for the active organization',
})
async getSubmission(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Param('formType') formType: string,
@Param('submissionId') submissionId: string,
) {
return this.evidenceFormsService.getSubmission({
organizationId,
authContext,
formType,
submissionId,
});
}
@Post(':formType/submissions')
@ApiOperation({
summary: 'Submit evidence form entry',
description:
'Create a new organization-scoped evidence form submission using Zod-validated payloads',
})
async submitForm(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Param('formType') formType: string,
@Body() body: unknown,
) {
return this.evidenceFormsService.submitForm({
organizationId,
formType,
payload: body,
authContext,
});
}
@Patch(':formType/submissions/:submissionId/review')
@ApiOperation({
summary: 'Review a submission',
description:
'Approve or reject an evidence form submission with an optional reason',
})
async reviewSubmission(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Param('formType') formType: string,
@Param('submissionId') submissionId: string,
@Body() body: unknown,
) {
return this.evidenceFormsService.reviewSubmission({
organizationId,
formType,
submissionId,
payload: body,
authContext,
});
}
@Post('uploads')
@ApiOperation({
summary: 'Upload evidence form file',
description:
'Upload a file for evidence form fields and return file metadata for submission payload',
})
async uploadFile(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Body() body: unknown,
) {
return this.evidenceFormsService.uploadFile({
organizationId,
authContext,
payload: body,
});
}
@Get(':formType/export.csv')
@ApiOperation({
summary: 'Export form submissions to CSV',
description: 'Export all form submissions for an organization as CSV',
})
@Header('Content-Type', 'text/csv')
async exportCsv(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Param('formType') formType: string,
@Res() res: Response,
) {
const csv = await this.evidenceFormsService.exportCsv({
organizationId,
authContext,
formType,
});
const filename = `${formType}-submissions-${new Date().toISOString().slice(0, 10)}.csv`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(csv);
}
}
--- File: apps/api/src/evidence-forms/evidence-forms.module.ts ---
import { Module } from '@nestjs/common';
import { AttachmentsModule } from '@/attachments/attachments.module';
import { AuthModule } from '@/auth/auth.module';
import { EvidenceFormsController } from './evidence-forms.controller';
import { EvidenceFormsService } from './evidence-forms.service';
@Module({
imports: [AuthModule, AttachmentsModule],
controllers: [EvidenceFormsController],
providers: [EvidenceFormsService],
exports: [EvidenceFormsService],
})
export class EvidenceFormsModule {}