Skip to content
Open
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
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ LOCALSTACK_ENDPOINT=http://localhost:4566
IMAGES_BUCKET=rst-storage-images-dev
DOCUMENTS_BUCKET=rst-storage-public-documents-dev
CONSENT_BUCKET=rst-storage-consent-forms-dev
EXHIBIT_A_BUCKET=rst-lza-exhibit-a-docs-dev
CORS_CONFIG='{"CORSRules":[{"AllowedHeaders":["*"],"AllowedMethods":["PUT","GET","HEAD"],"AllowedOrigins":["http://localhost:3001"],"ExposeHeaders":["ETag"],"MaxAgeSeconds":3000}]}'

.PHONY: localstack
Expand Down Expand Up @@ -111,6 +112,7 @@ localstack-buckets: ## Create S3 buckets in LocalStack
@aws --endpoint-url=$(LOCALSTACK_ENDPOINT) s3 mb s3://$(IMAGES_BUCKET) 2>/dev/null || true
@aws --endpoint-url=$(LOCALSTACK_ENDPOINT) s3 mb s3://$(DOCUMENTS_BUCKET) 2>/dev/null || true
@aws --endpoint-url=$(LOCALSTACK_ENDPOINT) s3 mb s3://$(CONSENT_BUCKET) 2>/dev/null || true
@aws --endpoint-url=$(LOCALSTACK_ENDPOINT) s3 mb s3://$(EXHIBIT_A_BUCKET) 2>/dev/null || true
@echo "Buckets created."

.PHONY: localstack-cors
Expand All @@ -119,6 +121,7 @@ localstack-cors: ## Configure CORS rules for LocalStack S3 buckets
@aws --endpoint-url=$(LOCALSTACK_ENDPOINT) s3api put-bucket-cors --bucket $(IMAGES_BUCKET) --cors-configuration $(CORS_CONFIG)
@aws --endpoint-url=$(LOCALSTACK_ENDPOINT) s3api put-bucket-cors --bucket $(DOCUMENTS_BUCKET) --cors-configuration $(CORS_CONFIG)
@aws --endpoint-url=$(LOCALSTACK_ENDPOINT) s3api put-bucket-cors --bucket $(CONSENT_BUCKET) --cors-configuration $(CORS_CONFIG)
@aws --endpoint-url=$(LOCALSTACK_ENDPOINT) s3api put-bucket-cors --bucket $(EXHIBIT_A_BUCKET) --cors-configuration $(CORS_CONFIG)
@echo "CORS configured."

.PHONY: localstack-setup
Expand Down
1 change: 1 addition & 0 deletions admin/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ RST_STORAGE_IMAGES_BUCKET=rst-storage-images-dev
RST_STORAGE_PUBLIC_DOCUMENTS_BUCKET=rst-storage-public-documents-dev
RST_STORAGE_CONSENT_FORMS_BUCKET=rst-storage-consent-forms-dev
RST_STORAGE_CLOUDFRONT_URL=http://localstack:4566
EXHIBIT_A_DOCS_BUCKET=rst-lza-exhibit-a-docs-dev
1 change: 1 addition & 0 deletions admin/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ ACT_CSS_CLIENT_ID=
# CSS client ID provisioned for the BCGW integration. The BCGW Keycloak strategy validates the JWT
# `aud` claim against this value to isolate BCGW tokens from ACT and RST admin tokens.
BCGW_CSS_CLIENT_ID=
EXHIBIT_A_DOCS_BUCKET=rst-lza-exhibit-a-docs-dev
36 changes: 36 additions & 0 deletions admin/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ model recreation_resource {
recreation_defined_campsite recreation_defined_campsite[]
recreation_driving_direction recreation_driving_direction?
recreation_establishment_order_docs recreation_establishment_order_docs[]
recreation_exhibit_a_doc recreation_exhibit_a_doc[]
recreation_feature recreation_feature[]
recreation_fee recreation_fee[]
recreation_map_feature recreation_map_feature[]
Expand Down Expand Up @@ -1154,6 +1155,41 @@ model recreation_resource_image_history {
@@schema("rst")
}

model recreation_exhibit_a_doc {
doc_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
rec_resource_id String @db.VarChar(10)
file_name String @db.VarChar
extension String @db.VarChar
file_size BigInt?
s3_key String @unique @db.VarChar
updated_at DateTime? @default(now()) @db.Timestamp(6)
updated_by String?
created_at DateTime? @default(now()) @db.Timestamp(6)
created_by String?
sys_period Unsupported("tstzrange") @default(dbgenerated("tstzrange(CURRENT_TIMESTAMP, NULL::timestamp with time zone)"))
recreation_resource recreation_resource @relation(fields: [rec_resource_id], references: [rec_resource_id], onDelete: NoAction, onUpdate: NoAction)

@@index([rec_resource_id], map: "idx_recreation_exhibit_a_doc_rec_resource_id")
@@schema("rst")
}

model recreation_exhibit_a_doc_history {
doc_id String @db.Uuid
rec_resource_id String @db.VarChar(10)
file_name String @db.VarChar
extension String @db.VarChar
file_size BigInt?
s3_key String @db.VarChar
updated_at DateTime? @db.Timestamp(6)
updated_by String?
created_at DateTime? @db.Timestamp(6)
created_by String?
sys_period Unsupported("tstzrange")

@@ignore
@@schema("rst")
}

/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model recreation_image_consent_form {
consent_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
Expand Down
4 changes: 4 additions & 0 deletions admin/backend/src/app-config/app-config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export class EnvironmentVariables {
@IsNotEmpty()
ESTABLISHMENT_ORDER_DOCS_BUCKET: string;

@IsString()
@IsNotEmpty()
EXHIBIT_A_DOCS_BUCKET: string;

@IsString()
@IsNotEmpty()
RST_STORAGE_IMAGES_BUCKET: string;
Expand Down
6 changes: 6 additions & 0 deletions admin/backend/src/app-config/app-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ export class AppConfigService {
})!;
}

get exhibitADocsBucket(): string {
return this.configService.get('EXHIBIT_A_DOCS_BUCKET', {
infer: true,
})!;
}

get recResourceImagesBucket(): string {
return this.configService.get('RST_STORAGE_IMAGES_BUCKET', {
infer: true,
Expand Down
14 changes: 14 additions & 0 deletions admin/backend/src/common/services/base-storage-file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,18 @@ export abstract class BaseStorageFileService {
throw new HttpException(`Invalid ${entityType}`, 400);
}
}

/**
* Safely delete a file from S3, logging a warning on failure instead of
* throwing so that the calling request is not affected by S3 errors.
* @param s3Key - The S3 object key to delete
*/
protected async deleteS3FileSafely(s3Key: string): Promise<void> {
try {
await this.s3Service.deleteFile(s3Key);
this.logger.log(`Successfully deleted file from S3: ${s3Key}`);
} catch (error) {
this.logger.warn(`Failed to delete file from S3: ${s3Key}`, error.stack);
}
}
}
92 changes: 92 additions & 0 deletions admin/backend/src/exhibit-a-docs/dto/exhibit-a-doc.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';

export class ExhibitADocDto {
@ApiProperty({
description: 'Document UUID',
example: 'a7c1e5f3-8d2b-4c9a-b1e6-f3d8c7a2e5b9',
})
document_id: string;

@ApiProperty({ description: 'Recreation Resource ID', example: 'REC0001' })
rec_resource_id: string;

@ApiProperty({
description: 'File name without extension',
example: 'exhibit-a-2024',
})
file_name: string;

@ApiProperty({ description: 'File extension', example: 'pdf' })
extension: string;

@ApiProperty({
description: 'File size in bytes',
example: 1024000,
required: false,
})
file_size?: number;

@ApiProperty({
description: 'S3 object key',
example: 'REC0001/exhibit-a-2024.pdf',
})
s3_key: string;

@ApiProperty({
description: 'Presigned download URL',
example: 'https://s3.amazonaws.com/...',
})
url: string;

@ApiProperty({
description: 'When the document was created',
required: false,
})
created_at?: Date;
}

export class FinalizeExhibitAUploadRequestDto {
@ApiProperty({
description: 'Document ID returned from presign endpoint',
example: 'a7c1e5f3-8d2b-4c9a-b1e6-f3d8c7a2e5b9',
})
@IsNotEmpty()
@IsString()
document_id: string;

@ApiProperty({
description: 'File name without extension',
example: 'exhibit-a-2024',
})
@IsNotEmpty()
@IsString()
file_name: string;

@ApiProperty({ description: 'File extension without dot', example: 'pdf' })
@IsNotEmpty()
@IsString()
extension: string;

@ApiProperty({ description: 'File size in bytes', example: 2097152 })
@IsNotEmpty()
@IsNumber()
file_size: number;
}

export class PresignExhibitAUploadResponseDto {
@ApiProperty({
description: 'Allocated document ID (UUID)',
example: 'a7c1e5f3-8d2b-4c9a-b1e6-f3d8c7a2e5b9',
})
document_id: string;

@ApiProperty({
description: 'S3 object key',
example: 'REC0001/a7c1e5f3-8d2b-4c9a-b1e6-f3d8c7a2e5b9/exhibit-a.pdf',
})
key: string;

@ApiProperty({ description: 'Presigned PUT URL for uploading to S3' })
url: string;
}
159 changes: 159 additions & 0 deletions admin/backend/src/exhibit-a-docs/exhibit-a-docs.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
AUTH_STRATEGY,
AuthRoles,
AuthRolesGuard,
RecreationResourceAuthRole,
ROLE_MODE,
SuperAdminGuard,
} from '@/auth';
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import {
ExhibitADocDto,
FinalizeExhibitAUploadRequestDto,
PresignExhibitAUploadResponseDto,
} from './dto/exhibit-a-doc.dto';
import { ExhibitADocsService } from './exhibit-a-docs.service';

@ApiTags('recreation-resources')
@Controller({
path: 'recreation-resources/:rec_resource_id/exhibit-a-docs',
version: '1',
})
@ApiBearerAuth(AUTH_STRATEGY.KEYCLOAK)
@UseGuards(AuthGuard(AUTH_STRATEGY.KEYCLOAK), AuthRolesGuard)
@AuthRoles([RecreationResourceAuthRole.RST_ADMIN], ROLE_MODE.ALL)
export class ExhibitADocsController {
constructor(private readonly exhibitADocsService: ExhibitADocsService) {}

@AuthRoles(
[
RecreationResourceAuthRole.RST_VIEWER,
RecreationResourceAuthRole.RST_ADMIN,
],
ROLE_MODE.ANY,
)
@Get()
@ApiOperation({
operationId: 'getAllExhibitADocs',
summary: 'Get all Exhibit A documents for a recreation resource',
})
@ApiParam({
name: 'rec_resource_id',
description: 'Recreation Resource ID',
example: 'REC0001',
})
@ApiResponse({
status: 200,
description: 'List of Exhibit A documents',
type: [ExhibitADocDto],
})
@ApiResponse({ status: 404, description: 'Recreation resource not found' })
async getAll(
@Param('rec_resource_id') rec_resource_id: string,
): Promise<ExhibitADocDto[]> {
return this.exhibitADocsService.getAll(rec_resource_id);
}

@UseGuards(SuperAdminGuard)
@Post('presign')
@ApiOperation({
Comment thread
ayeshmcg marked this conversation as resolved.
operationId: 'presignExhibitAUpload',
summary: 'Request presigned URL for direct S3 Exhibit A document upload',
})
@ApiParam({
name: 'rec_resource_id',
description: 'Recreation Resource ID',
example: 'REC0001',
})
@ApiQuery({
name: 'fileName',
required: true,
description: 'File name with extension',
example: 'exhibit-a.pdf',
})
@ApiResponse({
status: 200,
description: 'Presigned URL generated',
type: PresignExhibitAUploadResponseDto,
})
@ApiResponse({ status: 404, description: 'Recreation Resource not found' })
async presignUpload(
@Param('rec_resource_id') rec_resource_id: string,
@Query('fileName') fileName: string,
): Promise<PresignExhibitAUploadResponseDto> {
return this.exhibitADocsService.presignUpload(rec_resource_id, fileName);
}

@UseGuards(SuperAdminGuard)
@Post('finalize')
Comment thread
ayeshmcg marked this conversation as resolved.
@ApiOperation({
operationId: 'finalizeExhibitAUpload',
summary: 'Finalize Exhibit A document upload and create database record',
})
@ApiParam({
name: 'rec_resource_id',
description: 'Recreation Resource ID',
example: 'REC0001',
})
@ApiBody({ required: true, type: FinalizeExhibitAUploadRequestDto })
@ApiResponse({
status: 200,
description: 'Document record created',
type: ExhibitADocDto,
})
@ApiResponse({ status: 404, description: 'Recreation Resource not found' })
async finalizeUpload(
@Param('rec_resource_id') rec_resource_id: string,
@Body() body: FinalizeExhibitAUploadRequestDto,
): Promise<ExhibitADocDto> {
return this.exhibitADocsService.finalizeUpload(rec_resource_id, body);
}

@UseGuards(SuperAdminGuard)
@Delete(':document_id')
Comment thread
ayeshmcg marked this conversation as resolved.
@ApiOperation({
operationId: 'deleteExhibitADoc',
summary: 'Delete an Exhibit A document',
})
@ApiParam({
name: 'rec_resource_id',
description: 'Recreation Resource ID',
example: 'REC0001',
})
@ApiParam({
name: 'document_id',
description: 'Document UUID',
example: 'a7c1e5f3-8d2b-4c9a-b1e6-f3d8c7a2e5b9',
})
@ApiResponse({
status: 200,
description: 'Document deleted',
type: ExhibitADocDto,
})
@ApiResponse({ status: 404, description: 'Document not found' })
async delete(
@Param('rec_resource_id') rec_resource_id: string,
@Param('document_id') document_id: string,
): Promise<ExhibitADocDto> {
return this.exhibitADocsService.delete(rec_resource_id, document_id);
}
}
Loading
Loading