Skip to content

Commit 9d5f535

Browse files
author
Marcel Mueller
authored
feat: establishment order upload and delete (#1319)
* chore: add establishment order upload and delete api * chore: add establishment order upload and delete hooks * chore: add establishment order upload and delete hooks * feat: add upload and delete to establishment order file section * test: add tests for establishment order upload and delete * fix: show temporary tile with spinner while uploading * chore: disable upload button while uploading
1 parent e96ca07 commit 9d5f535

24 files changed

Lines changed: 3069 additions & 144 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString } from 'class-validator';
3+
4+
export class CreateEstablishmentOrderDocBodyDto {
5+
@ApiProperty({
6+
description: 'Title of the establishment order document',
7+
example: 'Establishment Order 2024',
8+
})
9+
@IsString()
10+
@IsNotEmpty()
11+
title: string;
12+
}
13+
14+
export class CreateEstablishmentOrderDocFormDto {
15+
@ApiProperty({ type: 'string', format: 'binary', required: true })
16+
file: any;
17+
18+
@ApiProperty({ type: 'string', required: true })
19+
title: string;
20+
}

admin/backend/src/establishment-order-docs/establishment-order-docs.controller.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
1-
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
Param,
7+
Post,
8+
UploadedFile,
9+
UseGuards,
10+
UseInterceptors,
11+
} from '@nestjs/common';
12+
import { FileInterceptor } from '@nestjs/platform-express';
213
import {
314
ApiBearerAuth,
15+
ApiBody,
16+
ApiConsumes,
417
ApiOperation,
518
ApiParam,
619
ApiResponse,
720
ApiTags,
821
} from '@nestjs/swagger';
922
import { EstablishmentOrderDocsService } from './establishment-order-docs.service';
1023
import { EstablishmentOrderDocDto } from './dto/establishment-order-doc.dto';
24+
import {
25+
CreateEstablishmentOrderDocBodyDto,
26+
CreateEstablishmentOrderDocFormDto,
27+
} from './dto/create-establishment-order-doc.dto';
1128
import { AuthGuard } from '@nestjs/passport';
1229
import {
1330
AUTH_STRATEGY,
@@ -59,4 +76,93 @@ export class EstablishmentOrderDocsController {
5976
): Promise<EstablishmentOrderDocDto[]> {
6077
return this.establishmentOrderDocsService.getAll(rec_resource_id);
6178
}
79+
80+
@Post()
81+
@UseInterceptors(FileInterceptor('file'))
82+
@ApiConsumes('multipart/form-data')
83+
@ApiOperation({
84+
summary: 'Create a new establishment order document',
85+
description: 'Uploads a PDF document to S3 and creates a database record',
86+
})
87+
@ApiParam({
88+
name: 'rec_resource_id',
89+
description: 'Recreation Resource ID',
90+
example: 'REC0001',
91+
})
92+
@ApiBody({
93+
description: 'Document upload form data',
94+
type: CreateEstablishmentOrderDocFormDto,
95+
})
96+
@ApiResponse({
97+
status: 201,
98+
description: 'Establishment order document created successfully',
99+
type: EstablishmentOrderDocDto,
100+
})
101+
@ApiResponse({
102+
status: 400,
103+
description: 'Bad request',
104+
})
105+
@ApiResponse({
106+
status: 401,
107+
description: 'Unauthorized',
108+
})
109+
@ApiResponse({
110+
status: 404,
111+
description: 'Recreation resource not found',
112+
})
113+
@ApiResponse({
114+
status: 415,
115+
description: 'File type not allowed',
116+
})
117+
async create(
118+
@Param('rec_resource_id') rec_resource_id: string,
119+
@Body() body: CreateEstablishmentOrderDocBodyDto,
120+
@UploadedFile() file: Express.Multer.File,
121+
): Promise<EstablishmentOrderDocDto> {
122+
return this.establishmentOrderDocsService.create(
123+
rec_resource_id,
124+
body.title,
125+
file,
126+
);
127+
}
128+
129+
@Delete(':s3_key')
130+
@ApiOperation({
131+
summary: 'Delete an establishment order document',
132+
description:
133+
'Deletes the document from S3 and removes the database record. The s3_key should be URL-encoded.',
134+
})
135+
@ApiParam({
136+
name: 'rec_resource_id',
137+
description: 'Recreation Resource ID',
138+
example: 'REC0001',
139+
})
140+
@ApiParam({
141+
name: 's3_key',
142+
description: 'S3 key (URL-encoded, e.g., REC0001%2Ffilename.pdf)',
143+
example: 'REC0001%2Festablishment-order.pdf',
144+
})
145+
@ApiResponse({
146+
status: 200,
147+
description: 'Establishment order document deleted successfully',
148+
type: EstablishmentOrderDocDto,
149+
})
150+
@ApiResponse({
151+
status: 401,
152+
description: 'Unauthorized',
153+
})
154+
@ApiResponse({
155+
status: 404,
156+
description: 'Document not found',
157+
})
158+
async delete(
159+
@Param('rec_resource_id') rec_resource_id: string,
160+
@Param('s3_key') s3_key: string,
161+
): Promise<EstablishmentOrderDocDto> {
162+
const decodedS3Key = decodeURIComponent(s3_key);
163+
return this.establishmentOrderDocsService.delete(
164+
rec_resource_id,
165+
decodedS3Key,
166+
);
167+
}
62168
}

admin/backend/src/establishment-order-docs/establishment-order-docs.service.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { HttpException, Injectable, Logger } from '@nestjs/common';
22
import { PrismaService } from '@/prisma.service';
33
import { S3Service } from '@/s3';
44
import { EstablishmentOrderDocDto } from './dto/establishment-order-doc.dto';
5+
import * as path from 'path';
6+
7+
const allowedTypes = ['application/pdf'];
58

69
@Injectable()
710
export class EstablishmentOrderDocsService {
@@ -58,4 +61,125 @@ export class EstablishmentOrderDocsService {
5861

5962
return docsWithUrls;
6063
}
64+
65+
/**
66+
* Create a new establishment order document
67+
* Uploads file to S3 and saves metadata to database
68+
* @param rec_resource_id - Recreation Resource ID
69+
* @param title - Document title
70+
* @param file - Uploaded file
71+
* @returns Created establishment order document with presigned URL
72+
*/
73+
async create(
74+
rec_resource_id: string,
75+
title: string,
76+
file: Express.Multer.File,
77+
): Promise<EstablishmentOrderDocDto> {
78+
// Validate file type
79+
if (!allowedTypes.includes(file.mimetype)) {
80+
throw new HttpException('File Type not allowed', 415);
81+
}
82+
83+
// Verify recreation resource exists
84+
const resource = await this.prisma.recreation_resource.findUnique({
85+
where: {
86+
rec_resource_id,
87+
},
88+
});
89+
if (resource === null) {
90+
throw new HttpException('Recreation Resource not found', 404);
91+
}
92+
93+
this.logger.log(
94+
`Creating establishment order doc for rec_resource_id: ${rec_resource_id}, title: ${title}`,
95+
);
96+
97+
// Upload file to S3
98+
const extension = path.extname(file.originalname).replace('.', '');
99+
const filename = `${title}.${extension}`;
100+
const s3_key = await this.s3Service.uploadFile(
101+
rec_resource_id,
102+
file.buffer,
103+
filename,
104+
);
105+
106+
// Save metadata to database
107+
const doc = await this.prisma.recreation_establishment_order_docs.create({
108+
data: {
109+
s3_key,
110+
rec_resource_id,
111+
title,
112+
file_size: BigInt(file.size),
113+
extension,
114+
},
115+
});
116+
117+
// Generate presigned URL
118+
const url = await this.s3Service.getSignedUrl(s3_key);
119+
120+
this.logger.log(
121+
`Establishment order doc created successfully - s3_key: ${s3_key}`,
122+
);
123+
124+
return {
125+
s3_key: doc.s3_key,
126+
rec_resource_id: doc.rec_resource_id!,
127+
title: doc.title || '',
128+
file_size: doc.file_size ? Number(doc.file_size) : 0,
129+
extension: doc.extension || '',
130+
url,
131+
created_at: doc.created_at || undefined,
132+
};
133+
}
134+
135+
/**
136+
* Delete an establishment order document
137+
* Deletes file from S3 and removes metadata from database
138+
* @param rec_resource_id - Recreation Resource ID
139+
* @param s3_key - S3 object key to delete
140+
* @returns Deleted establishment order document
141+
*/
142+
async delete(
143+
rec_resource_id: string,
144+
s3_key: string,
145+
): Promise<EstablishmentOrderDocDto> {
146+
this.logger.log(
147+
`Deleting establishment order doc - rec_resource_id: ${rec_resource_id}, s3_key: ${s3_key}`,
148+
);
149+
150+
// Verify the document exists
151+
const existingDoc =
152+
await this.prisma.recreation_establishment_order_docs.findUnique({
153+
where: { s3_key },
154+
});
155+
156+
if (!existingDoc) {
157+
this.logger.error(`Document not found - s3_key: ${s3_key}`);
158+
throw new HttpException('Document not found', 404);
159+
}
160+
161+
// Delete from database first
162+
const doc = await this.prisma.recreation_establishment_order_docs.delete({
163+
where: {
164+
s3_key,
165+
},
166+
});
167+
168+
// Delete from S3
169+
await this.s3Service.deleteFile(s3_key);
170+
171+
this.logger.log(
172+
`Establishment order doc deleted successfully - s3_key: ${s3_key}`,
173+
);
174+
175+
return {
176+
s3_key: doc.s3_key,
177+
rec_resource_id: doc.rec_resource_id!,
178+
title: doc.title || '',
179+
file_size: doc.file_size ? Number(doc.file_size) : 0,
180+
extension: doc.extension || '',
181+
url: '',
182+
created_at: doc.created_at || undefined,
183+
};
184+
}
61185
}

admin/backend/src/s3/s3.service.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import {
2+
DeleteObjectCommand,
23
GetObjectCommand,
4+
HeadObjectCommand,
35
ListObjectsV2Command,
6+
PutObjectCommand,
47
S3Client,
58
} from '@aws-sdk/client-s3';
69
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
@@ -86,4 +89,84 @@ export class S3Service {
8689
throw error;
8790
}
8891
}
92+
93+
/**
94+
* Upload a file to S3
95+
* @param recResourceId - Recreation resource ID
96+
* @param file - File buffer
97+
* @param filename - Filename including extension
98+
* @returns S3 key of uploaded file
99+
*/
100+
async uploadFile(
101+
recResourceId: string,
102+
file: Buffer,
103+
filename: string,
104+
): Promise<string> {
105+
const key = `${recResourceId}/${filename}`;
106+
107+
try {
108+
// Check if file already exists
109+
await this.s3Client.send(
110+
new HeadObjectCommand({
111+
Bucket: this.bucketName,
112+
Key: key,
113+
}),
114+
);
115+
116+
// If file exists - throw error
117+
this.logger.error(`File already exists in S3 - Key: ${key}`);
118+
throw new Error(`File "${filename}" already exists`);
119+
} catch (error) {
120+
// If file doesn't exist - proceed with upload
121+
if (
122+
error.name === 'NotFound' ||
123+
error.$metadata?.httpStatusCode === 404
124+
) {
125+
this.logger.log(
126+
`Uploading file to S3 - Key: ${key}, Size: ${file.length} bytes`,
127+
);
128+
129+
await this.s3Client.send(
130+
new PutObjectCommand({
131+
Bucket: this.bucketName,
132+
Key: key,
133+
Body: file,
134+
ContentType: 'application/pdf',
135+
}),
136+
);
137+
138+
this.logger.log(`File uploaded successfully to S3 - Key: ${key}`);
139+
return key;
140+
}
141+
142+
this.logger.error(
143+
`Error uploading file to S3 for rec_resource_id ${recResourceId}: ${error.message}`,
144+
);
145+
throw error;
146+
}
147+
}
148+
149+
/**
150+
* Delete a file from S3
151+
* @param key - S3 object key
152+
*/
153+
async deleteFile(key: string): Promise<void> {
154+
try {
155+
this.logger.log(`Deleting file from S3 - Key: ${key}`);
156+
157+
await this.s3Client.send(
158+
new DeleteObjectCommand({
159+
Bucket: this.bucketName,
160+
Key: key,
161+
}),
162+
);
163+
164+
this.logger.log(`File deleted successfully from S3 - Key: ${key}`);
165+
} catch (error) {
166+
this.logger.error(
167+
`Error deleting file from S3 - Key: ${key}: ${error.message}`,
168+
);
169+
throw error;
170+
}
171+
}
89172
}

0 commit comments

Comments
 (0)