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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions apps/api/src/trust-portal/dto/trust-document.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';

export class UploadTrustDocumentDto {
@ApiProperty({
description: 'Organization ID that owns the document',
example: 'org_6914cd0e16e4c7dccbb54426',
})
@IsString()
organizationId!: string;

@ApiProperty({
description: 'Original file name',
example: 'security-overview.pdf',
})
@IsString()
fileName!: string;

@ApiPropertyOptional({
description: 'MIME type (optional)',
example: 'application/pdf',
})
@IsOptional()
@IsString()
fileType?: string;

@ApiProperty({
description: 'Base64-encoded file contents (no data URL prefix)',
})
@IsString()
fileData!: string;

@ApiPropertyOptional({
description: 'Optional description shown in the trust portal',
example: 'Overview of our security program',
})
@IsOptional()
@IsString()
description?: string;
}

export class TrustDocumentResponseDto {
@ApiProperty({ example: 'tdoc_abc123' })
@IsString()
id!: string;

@ApiProperty({ example: 'security-overview.pdf' })
@IsString()
name!: string;

@ApiPropertyOptional({ example: 'Overview of our security program' })
@IsOptional()
@IsString()
description?: string | null;

@ApiProperty({ example: '2026-01-02T10:15:00.000Z' })
@IsString()
createdAt!: string;

@ApiProperty({ example: '2026-01-02T10:15:00.000Z' })
@IsString()
updatedAt!: string;
}

export class TrustDocumentSignedUrlDto {
@ApiProperty({
description: 'Organization ID that owns the document',
example: 'org_6914cd0e16e4c7dccbb54426',
})
@IsString()
organizationId!: string;
}

export class TrustDocumentUrlResponseDto {
@ApiProperty()
@IsString()
signedUrl!: string;

@ApiProperty()
@IsString()
fileName!: string;
}

export class DeleteTrustDocumentDto {
@ApiProperty({
description: 'Organization ID that owns the document',
example: 'org_6914cd0e16e4c7dccbb54426',
})
@IsString()
organizationId!: string;
}


64 changes: 64 additions & 0 deletions apps/api/src/trust-portal/trust-access.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,70 @@ export class TrustAccessController {
return this.trustAccessService.getComplianceResourcesByAccessToken(token);
}

@Get('access/:token/documents')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'List additional documents by access token',
description:
'Get list of trust portal additional documents available for download',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Documents list returned',
})
async getTrustDocumentsByAccessToken(@Param('token') token: string) {
return this.trustAccessService.getTrustDocumentsByAccessToken(token);
}

@Get('access/:token/documents/download-all')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Download all additional documents as a ZIP by access token',
description:
'Creates a ZIP archive of all active trust portal additional documents and returns a signed download URL',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Signed URL for ZIP archive returned',
})
async downloadAllTrustDocuments(@Param('token') token: string) {
return this.trustAccessService.downloadAllTrustDocumentsByAccessToken(token);
}

@Get('access/:token/documents/:documentId')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Download additional document by access token',
description:
'Get signed URL to download a specific trust portal additional document',
})
@ApiParam({
name: 'documentId',
description: 'Trust document ID',
example: 'tdoc_abc123',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Signed URL for document returned',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Document not found',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Invalid access token',
})
async getTrustDocumentUrlByAccessToken(
@Param('token') token: string,
@Param('documentId') documentId: string,
) {
return this.trustAccessService.getTrustDocumentUrlByAccessToken(
token,
documentId,
);
}

@Get('access/:token/compliance-resources/:framework')
@HttpCode(HttpStatus.OK)
@ApiOperation({
Expand Down
210 changes: 209 additions & 1 deletion apps/api/src/trust-portal/trust-access.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import { TrustEmailService } from './email.service';
import { NdaPdfService } from './nda-pdf.service';
import { AttachmentsService } from '../attachments/attachments.service';
import { PolicyPdfRendererService } from './policy-pdf-renderer.service';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '../app/s3';
import { TrustFramework } from '@prisma/client';
import archiver from 'archiver';
import { PassThrough, Readable } from 'stream';

@Injectable()
export class TrustAccessService {
Expand Down Expand Up @@ -1187,6 +1189,212 @@ export class TrustAccessService {
}));
}

async getTrustDocumentsByAccessToken(token: string) {
const grant = await this.validateAccessToken(token);

const documents = await db.trustDocument.findMany({
where: {
organizationId: grant.accessRequest.organizationId,
isActive: true,
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'desc' },
});

return documents.map((d) => ({
id: d.id,
name: d.name,
description: d.description,
createdAt: d.createdAt.toISOString(),
updatedAt: d.updatedAt.toISOString(),
}));
}

async getTrustDocumentUrlByAccessToken(token: string, documentId: string) {
const grant = await this.validateAccessToken(token);

if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) {
throw new InternalServerErrorException(
'Organization assets bucket is not configured',
);
}

const document = await db.trustDocument.findFirst({
where: {
id: documentId,
organizationId: grant.accessRequest.organizationId,
isActive: true,
},
select: {
name: true,
s3Key: true,
},
});

if (!document) {
throw new NotFoundException('Document not found');
}

const getCommand = new GetObjectCommand({
Bucket: APP_AWS_ORG_ASSETS_BUCKET,
Key: document.s3Key,
ResponseContentDisposition: `attachment; filename="${document.name.replaceAll('"', '')}"`,
});

const signedUrl = await getSignedUrl(s3Client, getCommand, {
expiresIn: 900,
});

return {
signedUrl,
fileName: document.name,
};
}

async downloadAllTrustDocumentsByAccessToken(token: string) {
const grant = await this.validateAccessToken(token);

if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) {
throw new InternalServerErrorException(
'Organization assets bucket is not configured',
);
}

const organizationId = grant.accessRequest.organizationId;
const documents = await db.trustDocument.findMany({
where: {
organizationId,
isActive: true,
},
select: {
id: true,
name: true,
s3Key: true,
},
orderBy: { createdAt: 'desc' },
});

if (documents.length === 0) {
throw new NotFoundException('No additional documents available');
}

const timestamp = Date.now();
const zipKey = `${organizationId}/trust-documents/bundles/${grant.id}-${timestamp}.zip`;

const archive = archiver('zip', { zlib: { level: 9 } });
const zipStream = new PassThrough();
let putPromise:
| Promise<unknown>
| undefined;

try {
putPromise = s3Client.send(
new PutObjectCommand({
Bucket: APP_AWS_ORG_ASSETS_BUCKET,
Key: zipKey,
Body: zipStream,
ContentType: 'application/zip',
Metadata: {
organizationId,
grantId: grant.id,
kind: 'trust_documents_bundle',
},
}),
);

archive.on('error', (err) => {
zipStream.destroy(err);
});

archive.pipe(zipStream);

// Track names case-insensitively to avoid collisions on case-insensitive filesystems
// (e.g. Windows/macOS): "Report.pdf" vs "report.pdf"
const usedNamesLower = new Set<string>();
const toSafeName = (name: string): string => {
const sanitized =
name.replace(/[^\w.\-() ]/g, '_').trim() || 'document';
const dot = sanitized.lastIndexOf('.');
const base = dot > 0 ? sanitized.slice(0, dot) : sanitized;
const ext = dot > 0 ? sanitized.slice(dot) : '';

let candidate = `${base}${ext}`;
let i = 1;
while (usedNamesLower.has(candidate.toLowerCase())) {
candidate = `${base} (${i})${ext}`;
i += 1;
}
usedNamesLower.add(candidate.toLowerCase());
return candidate;
};

for (const doc of documents) {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: APP_AWS_ORG_ASSETS_BUCKET,
Key: doc.s3Key,
}),
);

if (!response.Body) {
throw new InternalServerErrorException(
`No file data received from S3 for document ${doc.id}`,
);
}

const bodyStream =
response.Body instanceof Readable
? response.Body
: Readable.from(response.Body as any);

archive.append(bodyStream, { name: toSafeName(doc.name) });
}

await archive.finalize();
await putPromise;
} catch (error) {
// Ensure the upload stream is closed, otherwise the S3 PutObject may hang/reject later.
try {
archive.abort();
} catch {
// ignore
}

if (!zipStream.destroyed) {
zipStream.destroy(
error instanceof Error ? error : new Error('ZIP generation failed'),
);
}

// Avoid unhandled rejections from an in-flight S3 put.
await putPromise?.catch(() => undefined);

throw error;
}

const signedUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: APP_AWS_ORG_ASSETS_BUCKET,
Key: zipKey,
ResponseContentDisposition: `attachment; filename="additional-documents-${timestamp}.zip"`,
}),
{ expiresIn: 900 },
);

return {
name: 'Additional Documents',
fileCount: documents.length,
downloadUrl: signedUrl,
};
}

/**
* Get FAQ markdown for a published trust portal.
*
Expand Down
Loading
Loading