From ff951b371693a75dd5027968a89956c9a73003a2 Mon Sep 17 00:00:00 2001 From: Martin Kovachki <99181339+Martbul@users.noreply.github.com> Date: Wed, 24 Jul 2024 13:54:39 +0300 Subject: [PATCH] S3 Upload Files CampaignApplication (#658) * Merge branch 'prod-s3-task' into prod * updated campaingApplicationFile model * Merge branch 'prod-s3task-copy' into task-s3 * remove console.log * fix .env and mocks * increase files limit to 30 MB --- .env | 9 ++- .env.example | 1 + .../__mocks__/campaign-application-mocks.ts | 8 -- .../campaing-application-file-mocks.ts | 54 +++++++++++++ .../campaign-application.controller.spec.ts | 23 ++++-- .../campaign-application.controller.ts | 16 +++- .../campaign-application.module.ts | 3 +- .../campaign-application.service.spec.ts | 79 ++++++++++++++----- .../campaign-application.service.ts | 52 +++++++++++- .../create-campaignApplication-file.dto.ts | 9 +++ .../dto/create-campaignApplicationFile.dto.ts | 1 - .../dto/update-campaignApplicationFile.dto.ts | 1 - .../campaignApplicationFile.entity.ts | 2 +- apps/api/src/email/email.service.ts | 2 +- .../migration.sql | 17 ++++ schema.prisma | 3 +- 16 files changed, 234 insertions(+), 46 deletions(-) create mode 100644 apps/api/src/campaign-application/__mocks__/campaing-application-file-mocks.ts create mode 100644 apps/api/src/campaign-application/dto/create-campaignApplication-file.dto.ts create mode 100644 migrations/20240717203831_update_campaign_application_relationship/migration.sql diff --git a/.env b/.env index d690cde6f..5382ecde7 100644 --- a/.env +++ b/.env @@ -70,6 +70,10 @@ SENDGRID_API_KEY=sendgrid-key SENDGRID_SENDER_EMAIL=info@podkrepi.bg SENDGRID_INTERNAL_EMAIL=dev@podkrepi.bg SENDGRID_CONTACTS_URL=/v3/marketing/contacts +MARKETING_LIST_ID=6add1a52-f74e-4c14-af56-ec7e1d2318f0 +SENDGRID_SENDER_ID= +## if marketing notifications should be active --> true/false -> defaults to false +SEND_MARKETING_NOTIFICATIONS= ## Stripe ## ############ @@ -95,10 +99,11 @@ IRIS_USER_HASH= BANK_BIC=UNCRBGSF PLATFORM_IBAN= IMPORT_TRX_TASK_INTERVAL_MINUTES=60 +#which hour of the day to run the check for consent CHECK_IRIS_CONSENT_TASK_HOUR=10 BILLING_ADMIN_MAIL=billing_admin@podkrepi.bg -CAMPAIGN_ADMIN_MAIL= +CAMPAIGN_ADMIN_MAIL=responsible for campaign management ## Cache ## ############## -CACHE_TTL=60000 +CACHE_TTL=30000 diff --git a/.env.example b/.env.example index 6a8620a88..5382ecde7 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,7 @@ S3_ENDPOINT=https://cdn-dev.podkrepi.bg S3_ACCESS_KEY=s3-access-key S3_SECRET_ACCESS_KEY=s3-secret-access-key + ## Keycloak ## ############## KEYCLOAK_URL=http://localhost:8180/auth diff --git a/apps/api/src/campaign-application/__mocks__/campaign-application-mocks.ts b/apps/api/src/campaign-application/__mocks__/campaign-application-mocks.ts index cacc49f7b..9eb6c1441 100644 --- a/apps/api/src/campaign-application/__mocks__/campaign-application-mocks.ts +++ b/apps/api/src/campaign-application/__mocks__/campaign-application-mocks.ts @@ -18,14 +18,6 @@ export const mockNewCampaignApplication = { category: CampaignTypeCategory.medical, } -const dto: CreateCampaignApplicationDto = { - ...mockNewCampaignApplication, - acceptTermsAndConditions: true, - transparencyTermsAccepted: true, - personalInformationProcessingAccepted: true, - toEntity: new CreateCampaignApplicationDto().toEntity, -} - export const mockCampaigns = [ { id: '1', diff --git a/apps/api/src/campaign-application/__mocks__/campaing-application-file-mocks.ts b/apps/api/src/campaign-application/__mocks__/campaing-application-file-mocks.ts new file mode 100644 index 000000000..74a5ec0d8 --- /dev/null +++ b/apps/api/src/campaign-application/__mocks__/campaing-application-file-mocks.ts @@ -0,0 +1,54 @@ +import { Readable } from 'stream' +import { CampaignApplicationFileRole } from '@prisma/client' +import { CreateCampaignApplicationFileDto } from '../dto/create-campaignApplication-file.dto' + +export const mockCampaignApplicationFileFn = () => ({ + id: 'mockCampaignApplicationFileId', + filename: 'test.pdf', + mimetype: 'application/pdf', + campaignApplicationId: 'mockCampaignApplicationId', + personId: 'mockPersonId', + role: CampaignApplicationFileRole.document, +}) + +export const mockCampaignApplicationUploadFileFn = () => ({ + bucketName: 'campaignapplication-files', + ...mockCampaignApplicationFileFn(), + campaignApplicationId: 'mockCampaignApplicationId', + personId: 'mockPersonId', +}) + +export const mockCampaignApplicationFilesFn = (): Express.Multer.File[] => [ + { + fieldname: 'resume', + originalname: 'john_doe_resume.pdf', + encoding: '7bit', + mimetype: 'application/pdf', + size: 102400, + stream: new Readable(), + destination: '/uploads/resumes', + filename: 'john_doe_resume_1234.pdf', + path: '/uploads/resumes/john_doe_resume_1234.pdf', + buffer: Buffer.from(''), + }, + { + fieldname: 'cover_letter', + originalname: 'john_doe_cover_letter.pdf', + encoding: '7bit', + mimetype: 'application/pdf', + size: 51200, + stream: new Readable(), + destination: '/uploads/cover_letters', + filename: 'john_doe_cover_letter_1234.pdf', + path: '/uploads/cover_letters/john_doe_cover_letter_1234.pdf', + buffer: Buffer.from(''), + }, +] + +export const mockFileDtoFn = (): CreateCampaignApplicationFileDto => ({ + filename: 'Test Filename', + mimetype: 'Test mimetype', + campaignApplicationId: 'Test CampaignApplicationId', + personId: 'Test PersonId', + role: CampaignApplicationFileRole.document, +}) diff --git a/apps/api/src/campaign-application/campaign-application.controller.spec.ts b/apps/api/src/campaign-application/campaign-application.controller.spec.ts index 6fa91b70e..7f6f1aa87 100644 --- a/apps/api/src/campaign-application/campaign-application.controller.spec.ts +++ b/apps/api/src/campaign-application/campaign-application.controller.spec.ts @@ -8,6 +8,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common' import { PersonService } from '../person/person.service' import { mockUser, mockUserAdmin } from './../auth/__mocks__' import { mockNewCampaignApplication } from './__mocks__/campaign-application-mocks' +import { mockCampaignApplicationFilesFn } from './__mocks__/campaing-application-file-mocks' describe('CampaignApplicationController', () => { let controller: CampaignApplicationController @@ -45,20 +46,32 @@ describe('CampaignApplicationController', () => { // Arrange jest.spyOn(personService, 'findOneByKeycloakId').mockResolvedValue(mockUser) + const mockCampaignApplicationFiles = mockCampaignApplicationFilesFn() + // Act - await controller.create(mockCreateNewCampaignApplication, mockUser) + await controller.create( + mockCampaignApplicationFiles, + mockCreateNewCampaignApplication, + mockUser, + ) // Assert - expect(service.create).toHaveBeenCalledWith(mockCreateNewCampaignApplication, mockUser) + expect(service.create).toHaveBeenCalledWith( + mockCreateNewCampaignApplication, + mockUser, + mockCampaignApplicationFiles, + ) }) it('when create called with wrong user it should throw NotFoundException', async () => { jest.spyOn(personService, 'findOneByKeycloakId').mockResolvedValue(null) + const mockCampaignApplicationFiles = mockCampaignApplicationFilesFn() + // Act & Assert - await expect(controller.create(mockCreateNewCampaignApplication, mockUser)).rejects.toThrow( - NotFoundException, - ) + await expect( + controller.create(mockCampaignApplicationFiles, mockCreateNewCampaignApplication, mockUser), + ).rejects.toThrow(NotFoundException) }) it('when findAll called by a non-admin user it should throw a ForbiddenException', () => { diff --git a/apps/api/src/campaign-application/campaign-application.controller.ts b/apps/api/src/campaign-application/campaign-application.controller.ts index 3505bdf02..76d2a3f18 100644 --- a/apps/api/src/campaign-application/campaign-application.controller.ts +++ b/apps/api/src/campaign-application/campaign-application.controller.ts @@ -8,6 +8,8 @@ import { ForbiddenException, NotFoundException, Logger, + UploadedFiles, + UseInterceptors, } from '@nestjs/common' import { CampaignApplicationService } from './campaign-application.service' import { CreateCampaignApplicationDto } from './dto/create-campaign-application.dto' @@ -17,6 +19,8 @@ import { AuthenticatedUser, RoleMatchingMode, Roles } from 'nest-keycloak-connec import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak' import { PersonService } from '../person/person.service' +import { FilesInterceptor } from '@nestjs/platform-express' +import { validateFileType } from '../common/files' @ApiTags('campaign-application') @Controller('campaign-application') @@ -27,18 +31,26 @@ export class CampaignApplicationController { ) {} @Post('create') + @UseInterceptors( + FilesInterceptor('file', 10, { + limits: { fileSize: 1024 * 1024 * 30 }, + fileFilter: (_req: Request, file, cb) => { + validateFileType(file, cb) + }, + }), + ) async create( + @UploadedFiles() files: Express.Multer.File[], @Body() createCampaignApplicationDto: CreateCampaignApplicationDto, @AuthenticatedUser() user: KeycloakTokenParsed, ) { const person = await this.personService.findOneByKeycloakId(user.sub) - if (!person) { Logger.error('No person found in database') throw new NotFoundException('No person found in database') } - return this.campaignApplicationService.create(createCampaignApplicationDto, person) + return this.campaignApplicationService.create(createCampaignApplicationDto, person, files) } @Get('list') diff --git a/apps/api/src/campaign-application/campaign-application.module.ts b/apps/api/src/campaign-application/campaign-application.module.ts index 92c20c273..5b0047d23 100644 --- a/apps/api/src/campaign-application/campaign-application.module.ts +++ b/apps/api/src/campaign-application/campaign-application.module.ts @@ -4,9 +4,10 @@ import { CampaignApplicationController } from './campaign-application.controller import { PrismaModule } from '../prisma/prisma.module' import { PersonModule } from '../person/person.module' import { OrganizerModule } from '../organizer/organizer.module' +import { S3Service } from '../s3/s3.service' @Module({ imports: [PrismaModule, PersonModule, OrganizerModule], controllers: [CampaignApplicationController], - providers: [CampaignApplicationService], + providers: [CampaignApplicationService, S3Service], }) export class CampaignApplicationModule {} diff --git a/apps/api/src/campaign-application/campaign-application.service.spec.ts b/apps/api/src/campaign-application/campaign-application.service.spec.ts index 9c5123eaa..804e200fb 100644 --- a/apps/api/src/campaign-application/campaign-application.service.spec.ts +++ b/apps/api/src/campaign-application/campaign-application.service.spec.ts @@ -2,9 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing' import { CampaignApplicationService } from './campaign-application.service' import { CreateCampaignApplicationDto } from './dto/create-campaign-application.dto' import { BadRequestException } from '@nestjs/common' -import { CampaignApplicationState, CampaignTypeCategory, Person } from '@prisma/client' +import { CampaignApplicationFileRole, CampaignTypeCategory, Person } from '@prisma/client' import { prismaMock, MockPrismaService } from '../prisma/prisma-client.mock' -import { EmailService } from '../email/email.service' import { OrganizerService } from '../organizer/organizer.service' import { personMock } from '../person/__mock__/personMock' import { @@ -12,6 +11,12 @@ import { mockCreatedCampaignApplication, mockNewCampaignApplication, } from './__mocks__/campaign-application-mocks' +import { S3Service } from '../s3/s3.service' +import { + mockCampaignApplicationFileFn, + mockCampaignApplicationFilesFn, + mockCampaignApplicationUploadFileFn, +} from './__mocks__/campaing-application-file-mocks' describe('CampaignApplicationService', () => { let service: CampaignApplicationService @@ -30,22 +35,17 @@ describe('CampaignApplicationService', () => { }), } + const mockS3Service = { + uploadObject: jest.fn(), + } + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ CampaignApplicationService, MockPrismaService, - { - provide: EmailService, - useValue: { - sendFromTemplate: jest.fn(() => true), - }, - }, - MockPrismaService, - { - provide: OrganizerService, - useValue: mockOrganizerService, - }, + { provide: OrganizerService, useValue: mockOrganizerService }, + { provide: S3Service, useValue: mockS3Service }, ], }).compile() @@ -65,7 +65,9 @@ describe('CampaignApplicationService', () => { toEntity: new CreateCampaignApplicationDto().toEntity, } - await expect(service.create(dto, mockPerson)).rejects.toThrow( + const mockCampaignApplicationFiles = mockCampaignApplicationFilesFn() + + await expect(service.create(dto, mockPerson, mockCampaignApplicationFiles)).rejects.toThrow( new BadRequestException('All agreements must be checked'), ) }) @@ -79,7 +81,9 @@ describe('CampaignApplicationService', () => { toEntity: new CreateCampaignApplicationDto().toEntity, } - await expect(service.create(dto, mockPerson)).rejects.toThrow( + const mockCampaignApplicationFiles = mockCampaignApplicationFilesFn() + + await expect(service.create(dto, mockPerson, mockCampaignApplicationFiles)).rejects.toThrow( new BadRequestException('All agreements must be checked'), ) }) @@ -93,12 +97,14 @@ describe('CampaignApplicationService', () => { toEntity: new CreateCampaignApplicationDto().toEntity, } - await expect(service.create(dto, mockPerson)).rejects.toThrow( + const mockCampaignApplicationFiles = mockCampaignApplicationFilesFn() + + await expect(service.create(dto, mockPerson, mockCampaignApplicationFiles)).rejects.toThrow( new BadRequestException('All agreements must be checked'), ) }) - it('should add a new campaign-application if all agreements are true', async () => { + it('should add a new campaign-application to db if all agreements are true', async () => { const dto: CreateCampaignApplicationDto = { ...mockNewCampaignApplication, acceptTermsAndConditions: true, @@ -107,6 +113,10 @@ describe('CampaignApplicationService', () => { toEntity: new CreateCampaignApplicationDto().toEntity, } + const mockCampaignApplicationFiles = mockCampaignApplicationFilesFn() + const mockCampaignApplicationFile = mockCampaignApplicationFileFn() + const mockCampaignApplicationUploadFile = mockCampaignApplicationUploadFileFn() + const mockOrganizerId = 'mockOrganizerId' jest.spyOn(mockOrganizerService, 'create').mockResolvedValue({ id: mockOrganizerId, @@ -117,7 +127,13 @@ describe('CampaignApplicationService', () => { .spyOn(prismaMock.campaignApplication, 'create') .mockResolvedValue(mockCreatedCampaignApplication) - const result = await service.create(dto, mockPerson) + jest + .spyOn(prismaMock.campaignApplicationFile, 'create') + .mockResolvedValue(mockCampaignApplicationFile) + + jest.spyOn(mockS3Service, 'uploadObject').mockResolvedValue(mockCampaignApplicationUploadFile) + + const result = await service.create(dto, mockPerson, mockCampaignApplicationFiles) expect(result).toEqual(mockCreatedCampaignApplication) @@ -145,11 +161,36 @@ describe('CampaignApplicationService', () => { }, }) + mockCampaignApplicationFiles.forEach((file) => { + const fileDto = { + data: { + filename: file.originalname, + mimetype: file.mimetype, + campaignApplicationId: mockCreatedCampaignApplication.id, + personId: mockPerson.id, + role: CampaignApplicationFileRole.document, + }, + } + expect(prismaMock.campaignApplicationFile.create).toHaveBeenCalledWith(fileDto) + }) + + mockCampaignApplicationFiles.forEach((file) => { + expect(mockS3Service.uploadObject).toHaveBeenCalledWith( + 'campaignapplication-files', + mockCampaignApplicationFile.id, + file.filename, + file.mimetype, + file.buffer, + 'CampaignApplicationFile', + mockCreatedCampaignApplication.id, + mockPerson.id, + ) + }) + expect(mockOrganizerService.create).toHaveBeenCalledTimes(1) expect(prismaMock.campaignApplication.create).toHaveBeenCalledTimes(1) }) }) - describe('findAll', () => { it('should return an array of campaign-applications', async () => { prismaMock.campaignApplication.findMany.mockResolvedValue(mockCampaigns) diff --git a/apps/api/src/campaign-application/campaign-application.service.ts b/apps/api/src/campaign-application/campaign-application.service.ts index 2952e469f..6a79aef69 100644 --- a/apps/api/src/campaign-application/campaign-application.service.ts +++ b/apps/api/src/campaign-application/campaign-application.service.ts @@ -3,16 +3,27 @@ import { CreateCampaignApplicationDto } from './dto/create-campaign-application. import { UpdateCampaignApplicationDto } from './dto/update-campaign-application.dto' import { PrismaService } from '../prisma/prisma.service' import { OrganizerService } from '../organizer/organizer.service' -import { Person } from '@prisma/client' - +import { CampaignApplicationFileRole, Person } from '@prisma/client' +import { S3Service } from './../s3/s3.service' +import { CreateCampaignApplicationFileDto } from './dto/create-campaignApplication-file.dto' @Injectable() export class CampaignApplicationService { - constructor(private prisma: PrismaService, private organizerService: OrganizerService) {} + private readonly bucketName: string = 'campaignapplication-files' + constructor( + private prisma: PrismaService, + private organizerService: OrganizerService, + private s3: S3Service, + ) {} + async getCampaignByIdWithPersonIds(id: string): Promise { throw new Error('Method not implemented.') } - async create(createCampaignApplicationDto: CreateCampaignApplicationDto, person: Person) { + async create( + createCampaignApplicationDto: CreateCampaignApplicationDto, + person: Person, + files: Express.Multer.File[], + ) { try { if ( createCampaignApplicationDto.acceptTermsAndConditions === false || @@ -54,6 +65,14 @@ export class CampaignApplicationService { data: campaingApplicationData, }) + if (files) { + await Promise.all( + files.map((file) => { + return this.campaignApplicationFilesCreate(file, person.id, newCampaignApplication.id) + }), + ) + } + return newCampaignApplication } catch (error) { Logger.error('Error in create():', error) @@ -76,4 +95,29 @@ export class CampaignApplicationService { remove(id: string) { return `This action removes a #${id} campaignApplication` } + + async campaignApplicationFilesCreate(file, personId: string, campaignApplicationId: string) { + const fileDto: CreateCampaignApplicationFileDto = { + filename: file.originalname, + mimetype: file.mimetype, + campaignApplicationId: campaignApplicationId, + personId, + role: CampaignApplicationFileRole.document, + } + + const createFileInDb = await this.prisma.campaignApplicationFile.create({ + data: fileDto, + }) + + await this.s3.uploadObject( + this.bucketName, + createFileInDb.id, + file.filename, + file.mimetype, + file.buffer, + 'CampaignApplicationFile', + campaignApplicationId, + personId, + ) + } } diff --git a/apps/api/src/campaign-application/dto/create-campaignApplication-file.dto.ts b/apps/api/src/campaign-application/dto/create-campaignApplication-file.dto.ts new file mode 100644 index 000000000..0b6f9c187 --- /dev/null +++ b/apps/api/src/campaign-application/dto/create-campaignApplication-file.dto.ts @@ -0,0 +1,9 @@ +import { CampaignApplicationFileRole } from '@prisma/client' + +export class CreateCampaignApplicationFileDto { + filename: string + mimetype: string + campaignApplicationId: string + personId: string + role: CampaignApplicationFileRole +} diff --git a/apps/api/src/domain/generated/campaignApplicationFile/dto/create-campaignApplicationFile.dto.ts b/apps/api/src/domain/generated/campaignApplicationFile/dto/create-campaignApplicationFile.dto.ts index 854d351ce..dcc816411 100644 --- a/apps/api/src/domain/generated/campaignApplicationFile/dto/create-campaignApplicationFile.dto.ts +++ b/apps/api/src/domain/generated/campaignApplicationFile/dto/create-campaignApplicationFile.dto.ts @@ -3,7 +3,6 @@ import { ApiProperty } from '@nestjs/swagger' export class CreateCampaignApplicationFileDto { filename: string - campaignApplicationId: string personId: string mimetype: string @ApiProperty({ enum: CampaignApplicationFileRole }) diff --git a/apps/api/src/domain/generated/campaignApplicationFile/dto/update-campaignApplicationFile.dto.ts b/apps/api/src/domain/generated/campaignApplicationFile/dto/update-campaignApplicationFile.dto.ts index 247faad15..61fcdcb0a 100644 --- a/apps/api/src/domain/generated/campaignApplicationFile/dto/update-campaignApplicationFile.dto.ts +++ b/apps/api/src/domain/generated/campaignApplicationFile/dto/update-campaignApplicationFile.dto.ts @@ -3,7 +3,6 @@ import { ApiProperty } from '@nestjs/swagger' export class UpdateCampaignApplicationFileDto { filename?: string - campaignApplicationId?: string personId?: string mimetype?: string @ApiProperty({ enum: CampaignApplicationFileRole }) diff --git a/apps/api/src/domain/generated/campaignApplicationFile/entities/campaignApplicationFile.entity.ts b/apps/api/src/domain/generated/campaignApplicationFile/entities/campaignApplicationFile.entity.ts index 314a52a70..5a2d014b7 100644 --- a/apps/api/src/domain/generated/campaignApplicationFile/entities/campaignApplicationFile.entity.ts +++ b/apps/api/src/domain/generated/campaignApplicationFile/entities/campaignApplicationFile.entity.ts @@ -8,5 +8,5 @@ export class CampaignApplicationFile { personId: string mimetype: string role: CampaignApplicationFileRole - campaignApplication?: CampaignApplication[] + campaignApplication?: CampaignApplication } diff --git a/apps/api/src/email/email.service.ts b/apps/api/src/email/email.service.ts index 2d773622e..4ef3a40e1 100644 --- a/apps/api/src/email/email.service.ts +++ b/apps/api/src/email/email.service.ts @@ -41,8 +41,8 @@ export class EmailService { if (!emailInfo.to) { throw new Error('emailInfo.to is required') } - const { html, metadata } = await this.template.getTemplate(template) + const { html, metadata } = await this.template.getTemplate(template) this.send( { to: emailInfo.to, diff --git a/migrations/20240717203831_update_campaign_application_relationship/migration.sql b/migrations/20240717203831_update_campaign_application_relationship/migration.sql new file mode 100644 index 000000000..fed5c1e2d --- /dev/null +++ b/migrations/20240717203831_update_campaign_application_relationship/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the `_CampaignApplicationToCampaignApplicationFile` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_CampaignApplicationToCampaignApplicationFile" DROP CONSTRAINT "_CampaignApplicationToCampaignApplicationFile_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_CampaignApplicationToCampaignApplicationFile" DROP CONSTRAINT "_CampaignApplicationToCampaignApplicationFile_B_fkey"; + +-- DropTable +DROP TABLE "_CampaignApplicationToCampaignApplicationFile"; + +-- AddForeignKey +ALTER TABLE "campaign_application_files" ADD CONSTRAINT "campaign_application_files_campaign_application_id_fkey" FOREIGN KEY ("campaign_application_id") REFERENCES "campaign_applications"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/schema.prisma b/schema.prisma index 26a50c97f..d5bb2c695 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1057,7 +1057,8 @@ model CampaignApplicationFile { personId String @map("person_id") @db.Uuid mimetype String @db.VarChar(100) role CampaignApplicationFileRole - campaignApplication CampaignApplication[] + campaignApplication CampaignApplication @relation(fields: [campaignApplicationId], references: [id]) + @@map("campaign_application_files") }