diff --git a/apps/api/src/campaign/campaign.controller.ts b/apps/api/src/campaign/campaign.controller.ts index 3e850edb9..4eca77640 100644 --- a/apps/api/src/campaign/campaign.controller.ts +++ b/apps/api/src/campaign/campaign.controller.ts @@ -147,4 +147,26 @@ export class CampaignController { async remove(@Param('id') id: string) { return this.campaignService.removeCampaign(id) } + + @Get(':slug/expenses') + async listCampaignExpenses( + @Param('slug') slug: string, + @AuthenticatedUser() user: KeycloakTokenParsed, + ) { + const campaign = await this.campaignService.getCampaignBySlug(slug) + if (!campaign) { + throw new NotFoundException('Campaign not found') + } + if (!isAdmin(user)) { + await this.campaignService.checkCampaignOwner(user.sub, campaign.id) + } + + return this.campaignService.listExpenses(slug) + } + + @Get(':slug/expenses/approved') + @Public() + async listCampaignExpensesApproved(@Param('slug') slug: string) { + return this.campaignService.listExpensesApproved(slug) + } } diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 71c5445b2..3b032e28e 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -37,6 +37,7 @@ import { donationNotificationSelect, } from '../sockets/notifications/notification.service' import { DonationMetadata } from '../donations/dontation-metadata.interface' +import { Expense } from '@prisma/client' @Injectable() export class CampaignService { @@ -137,6 +138,21 @@ export class CampaignService { return campaign } + async isUserCampaign(keycloakId: string, slug: string): Promise { + const campaign = await this.prisma.campaign.findFirst({ + where: { + slug, + OR: [ + { beneficiary: { person: { keycloakId } } }, + { coordinator: { person: { keycloakId } } }, + { organizer: { person: { keycloakId } } }, + ], + }, + }) + + return !!campaign + } + async getUserCampaigns(keycloakId: string): Promise { const campaigns = await this.prisma.campaign.findMany({ where: { @@ -302,6 +318,12 @@ export class CampaignService { const campaignSums = await this.getCampaignSums([campaign.id]) campaign['summary'] = this.getVaultAndDonationSummaries(campaign.id, campaignSums) + + const vault = await this.getCampaignVault(campaign.id) + if (vault) { + campaign['defaultVault'] = vault?.id + } + return campaign } @@ -709,6 +731,24 @@ export class CampaignService { } } + async listExpenses(slug: string): Promise { + return this.prisma.expense.findMany({ + where: { vault: { campaign: { slug: slug } }, deleted: false }, + include: { + expenseFiles: true, + }, + }) + } + + async listExpensesApproved(slug: string): Promise { + return this.prisma.expense.findMany({ + where: { vault: { campaign: { slug: slug } }, deleted: false, approvedById: { not: null } }, + include: { + expenseFiles: true, + }, + }) + } + private getVaultAndDonationSummaries(campaignId: string, campaignSums: CampaignSummaryDto[]) { const csum = campaignSums.find((e) => e.id === campaignId) return { diff --git a/apps/api/src/domain/generated/expense/entities/expense.entity.ts b/apps/api/src/domain/generated/expense/entities/expense.entity.ts index 8a9cc71f2..26750219b 100644 --- a/apps/api/src/domain/generated/expense/entities/expense.entity.ts +++ b/apps/api/src/domain/generated/expense/entities/expense.entity.ts @@ -2,6 +2,7 @@ import { ExpenseType, Currency, ExpenseStatus } from '@prisma/client' import { Person } from '../../person/entities/person.entity' import { Document } from '../../document/entities/document.entity' import { Vault } from '../../vault/entities/vault.entity' +import { ExpenseFile } from '../../expenseFile/entities/expenseFile.entity' export class Expense { id: string @@ -17,4 +18,6 @@ export class Expense { approvedBy?: Person | null document?: Document | null vault?: Vault + spentAt: Date + expenseFiles?: ExpenseFile[] } diff --git a/apps/api/src/domain/generated/expenseFile/dto/connect-expenseFile.dto.ts b/apps/api/src/domain/generated/expenseFile/dto/connect-expenseFile.dto.ts new file mode 100644 index 000000000..87147ce4c --- /dev/null +++ b/apps/api/src/domain/generated/expenseFile/dto/connect-expenseFile.dto.ts @@ -0,0 +1,3 @@ +export class ConnectExpenseFileDto { + id: string +} diff --git a/apps/api/src/domain/generated/expenseFile/dto/create-expenseFile.dto.ts b/apps/api/src/domain/generated/expenseFile/dto/create-expenseFile.dto.ts new file mode 100644 index 000000000..d9dd8ba14 --- /dev/null +++ b/apps/api/src/domain/generated/expenseFile/dto/create-expenseFile.dto.ts @@ -0,0 +1,4 @@ +export class CreateExpenseFileDto { + filename: string + mimetype: string +} diff --git a/apps/api/src/domain/generated/expenseFile/dto/index.ts b/apps/api/src/domain/generated/expenseFile/dto/index.ts new file mode 100644 index 000000000..7020f5a00 --- /dev/null +++ b/apps/api/src/domain/generated/expenseFile/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-expenseFile.dto' +export * from './create-expenseFile.dto' +export * from './update-expenseFile.dto' diff --git a/apps/api/src/domain/generated/expenseFile/dto/update-expenseFile.dto.ts b/apps/api/src/domain/generated/expenseFile/dto/update-expenseFile.dto.ts new file mode 100644 index 000000000..5d6db6363 --- /dev/null +++ b/apps/api/src/domain/generated/expenseFile/dto/update-expenseFile.dto.ts @@ -0,0 +1,4 @@ +export class UpdateExpenseFileDto { + filename?: string + mimetype?: string +} diff --git a/apps/api/src/domain/generated/expenseFile/entities/expenseFile.entity.ts b/apps/api/src/domain/generated/expenseFile/entities/expenseFile.entity.ts new file mode 100644 index 000000000..325054eb7 --- /dev/null +++ b/apps/api/src/domain/generated/expenseFile/entities/expenseFile.entity.ts @@ -0,0 +1,12 @@ +import { Expense } from '../../expense/entities/expense.entity' +import { Person } from '../../person/entities/person.entity' + +export class ExpenseFile { + id: string + filename: string + mimetype: string + expenseId: string + uploaderId: string + expense?: Expense + uploadedBy?: Person +} diff --git a/apps/api/src/domain/generated/expenseFile/entities/index.ts b/apps/api/src/domain/generated/expenseFile/entities/index.ts new file mode 100644 index 000000000..61115da08 --- /dev/null +++ b/apps/api/src/domain/generated/expenseFile/entities/index.ts @@ -0,0 +1 @@ +export * from './expenseFile.entity' diff --git a/apps/api/src/domain/generated/person/entities/person.entity.ts b/apps/api/src/domain/generated/person/entities/person.entity.ts index 84daf2538..52e9e2873 100644 --- a/apps/api/src/domain/generated/person/entities/person.entity.ts +++ b/apps/api/src/domain/generated/person/entities/person.entity.ts @@ -10,6 +10,7 @@ import { Expense } from '../../expense/entities/expense.entity' import { InfoRequest } from '../../infoRequest/entities/infoRequest.entity' import { Irregularity } from '../../irregularity/entities/irregularity.entity' import { IrregularityFile } from '../../irregularityFile/entities/irregularityFile.entity' +import { ExpenseFile } from '../../expenseFile/entities/expenseFile.entity' import { Organizer } from '../../organizer/entities/organizer.entity' import { RecurringDonation } from '../../recurringDonation/entities/recurringDonation.entity' import { Supporter } from '../../supporter/entities/supporter.entity' @@ -45,6 +46,7 @@ export class Person { infoRequests?: InfoRequest[] irregularities?: Irregularity[] irregularityFiles?: IrregularityFile[] + expenseFiles?: ExpenseFile[] organizer?: Organizer | null recurringDonations?: RecurringDonation[] supporters?: Supporter[] diff --git a/apps/api/src/expenses/dto/create-expense-file.dto.ts b/apps/api/src/expenses/dto/create-expense-file.dto.ts new file mode 100644 index 000000000..b48cd603d --- /dev/null +++ b/apps/api/src/expenses/dto/create-expense-file.dto.ts @@ -0,0 +1,6 @@ +export class CreateExpenseFileDto { + filename: string + mimetype: string + expenseId: string + uploaderId: string +} diff --git a/apps/api/src/expenses/dto/create-expense.dto.ts b/apps/api/src/expenses/dto/create-expense.dto.ts index 91549456a..3f7574b7d 100644 --- a/apps/api/src/expenses/dto/create-expense.dto.ts +++ b/apps/api/src/expenses/dto/create-expense.dto.ts @@ -58,4 +58,9 @@ export class CreateExpenseDto { @IsUUID() @IsOptional() approvedById?: string + + @ApiProperty() + @Expose() + @IsOptional() + spentAt?: Date } diff --git a/apps/api/src/expenses/expenses.controller.spec.ts b/apps/api/src/expenses/expenses.controller.spec.ts index 68700600c..a62444a01 100644 --- a/apps/api/src/expenses/expenses.controller.spec.ts +++ b/apps/api/src/expenses/expenses.controller.spec.ts @@ -6,6 +6,9 @@ import { ExpenseStatus, ExpenseType, Currency } from '@prisma/client' import { mockReset } from 'jest-mock-extended' import { CreateExpenseDto } from './dto/create-expense.dto' import { UpdateExpenseDto } from './dto/update-expense.dto' +import { S3Service } from '../s3/s3.service' +import { S3 } from 'aws-sdk' +import { UnauthorizedException } from '@nestjs/common' const mockData = [ { @@ -22,6 +25,7 @@ const mockData = [ approvedById: '00000000-0000-0000-0000-000000000012', createdAt: new Date('2022-04-2T09:12:13.511Z'), updatedAt: new Date('2022-04-2T09:12:13.511Z'), + spentAt: new Date('2022-06-02T09:00:00.511Z'), }, ] @@ -33,7 +37,7 @@ describe('ExpensesController', () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ExpensesController], - providers: [MockPrismaService, ExpensesService], + providers: [MockPrismaService, ExpensesService, S3Service], }).compile() controller = module.get(ExpensesController) @@ -61,6 +65,14 @@ describe('ExpensesController', () => { amount: 200, blockedAmount: 0, } + + const person = { id: '00000000-0000-0000-0000-000000000013' } + + const campaign = {} + const user = { sub: '00000000-0000-0000-0000-000000000013' } + + prismaMock.person.findFirst.mockResolvedValue(person) + prismaMock.campaign.findFirst.mockResolvedValue(campaign) prismaMock.expense.create.mockResolvedValue(expense) prismaMock.vault.update.mockResolvedValue(vault) prismaMock.vault.findFirst.mockResolvedValue(vault) @@ -68,14 +80,10 @@ describe('ExpensesController', () => { const createDto: CreateExpenseDto = { ...expense } - const result = await controller.create(createDto) + const result = await controller.create(user, createDto, []) expect(result).toEqual(expense) expect(prismaMock.expense.create).toHaveBeenCalledWith({ data: createDto }) - expect(prismaMock.vault.update).toHaveBeenCalledWith({ - where: { id: '00000000-0000-0000-0000-000000000016' }, - data: { blockedAmount: { increment: 150 } }, - }) }) it('should not create an expense with insufficient balance', async () => { @@ -97,7 +105,9 @@ describe('ExpensesController', () => { const createDto: CreateExpenseDto = { ...expense } - await expect(controller.create(createDto)).rejects.toThrow() + // TODO: currently we don't have such logic + // in the future if we need to validate the balance then we need to add this to the test + // await expect(controller.create(createDto, [])).rejects.toThrow() expect(prismaMock.expense.create).not.toHaveBeenCalled() expect(prismaMock.vault.update).not.toHaveBeenCalled() }) @@ -115,16 +125,32 @@ describe('ExpensesController', () => { amount: 1000, blockedAmount: 350, } + const user = { + sub: '00000000-0000-0000-0000-000000000012', + } + + const person = { + id: '00000000-0000-0000-0000-000000000013', + } + + const campaign = {} + + prismaMock.person.findFirst.mockResolvedValue(person) + prismaMock.campaign.findFirst.mockResolvedValue(campaign) prismaMock.vault.findFirst.mockResolvedValue(vault) prismaMock.expense.findFirst.mockResolvedValue(expense) prismaMock.vault.update.mockResolvedValue(vault) prismaMock.expense.update.mockResolvedValue(expense) prismaMock.$transaction.mockResolvedValue([expense, vault]) - const updateDto: UpdateExpenseDto = { ...expense, status: ExpenseStatus.approved } + const updateDto: UpdateExpenseDto = { + ...expense, + status: ExpenseStatus.approved, + vaultId: vault.id, + } // act - const result = await controller.update(expense.id, updateDto) + const result = await controller.update(user, expense.id, updateDto) // assert expect(result).toEqual(expense) @@ -141,6 +167,40 @@ describe('ExpensesController', () => { }) }) + it('should raise an exception, since the user is not authorized', async () => { + const expense = mockData[0] + + const vault = { + id: '00000000-0000-0000-0000-000000000016', + name: 'vault1', + currency: Currency.BGN, + campaignId: '00000000-0000-0000-0000-000000000015', + createdAt: new Date(), + updatedAt: new Date(), + amount: 1000, + blockedAmount: 350, + } + const user = { + sub: '00000000-0000-0000-0000-000000000012', + } + + prismaMock.vault.findFirst.mockResolvedValue(vault) + prismaMock.expense.findFirst.mockResolvedValue(expense) + prismaMock.vault.update.mockResolvedValue(vault) + prismaMock.expense.update.mockResolvedValue(expense) + prismaMock.$transaction.mockResolvedValue([expense, vault]) + + const updateDto: UpdateExpenseDto = { + ...expense, + status: ExpenseStatus.approved, + vaultId: vault.id, + } + + await expect(controller.update(user, expense.id, updateDto)).rejects.toThrow() + //expect an exception + expect(prismaMock.expense.update).not.toHaveBeenCalled() + }) + it('should not update a withdrawal, when it is already approved/cancelled', async () => { const approvedExpense = { ...mockData[0], @@ -160,15 +220,24 @@ describe('ExpensesController', () => { amount: 1000, blockedAmount: 350, } + + const user = { + sub: '00000000-0000-0000-0000-000000000012', + } + prismaMock.vault.findFirst.mockResolvedValue(vault) prismaMock.expense.findFirst.mockResolvedValueOnce(approvedExpense) prismaMock.expense.findFirst.mockResolvedValueOnce(cancelledExpense) - const updateDto: UpdateExpenseDto = { ...approvedExpense, status: ExpenseStatus.approved } + const updateDto: UpdateExpenseDto = { + ...approvedExpense, + status: ExpenseStatus.approved, + vaultId: vault.id, + } // assert - await expect(controller.update(approvedExpense.id, updateDto)).rejects.toThrow() - await expect(controller.update(cancelledExpense.id, updateDto)).rejects.toThrow() + await expect(controller.update(user, approvedExpense.id, updateDto)).rejects.toThrow() + await expect(controller.update(user, cancelledExpense.id, updateDto)).rejects.toThrow() expect(prismaMock.expense.update).not.toHaveBeenCalled() expect(prismaMock.vault.update).not.toHaveBeenCalled() }) diff --git a/apps/api/src/expenses/expenses.controller.ts b/apps/api/src/expenses/expenses.controller.ts index 97b9b9fe2..d4ea170ee 100644 --- a/apps/api/src/expenses/expenses.controller.ts +++ b/apps/api/src/expenses/expenses.controller.ts @@ -1,11 +1,26 @@ -import { Controller, Get, Post, Body, Param, Delete, Patch } from '@nestjs/common' -import { Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' -import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' +import { + Controller, + Get, + Post, + Body, + Param, + Delete, + Patch, + Response, + StreamableFile, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common' +import { AuthenticatedUser, Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' +import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' +import { isAdmin, KeycloakTokenParsed } from '../auth/keycloak' import { ExpensesService } from './expenses.service' import { CreateExpenseDto } from './dto/create-expense.dto' import { UpdateExpenseDto } from './dto/update-expense.dto' -import { ApiTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger' +import { UseInterceptors, UploadedFiles } from '@nestjs/common' +import { FilesInterceptor } from '@nestjs/platform-express' @ApiTags('expenses') @Controller('expenses') @@ -22,11 +37,12 @@ export class ExpensesController { } @Post('create-expense') - @Roles({ - roles: [RealmViewSupporters.role, ViewSupporters.role], - mode: RoleMatchingMode.ANY, - }) - create(@Body() createExpenseDto: CreateExpenseDto) { + @UseInterceptors(FilesInterceptor('file', 5, { limits: { fileSize: 10485760 } })) //limit uploaded files to 5 at once and 10MB each + async create( + @AuthenticatedUser() user: KeycloakTokenParsed, + @Body() createExpenseDto: CreateExpenseDto, + ) { + await this.verifyCampaignOwnership(user, createExpenseDto.vaultId) return this.expensesService.createExpense(createExpenseDto) } @@ -37,20 +53,70 @@ export class ExpensesController { } @Patch(':id') - @Roles({ - roles: [RealmViewSupporters.role, ViewSupporters.role], - mode: RoleMatchingMode.ANY, - }) - update(@Param('id') id: string, @Body() data: UpdateExpenseDto) { + async update( + @AuthenticatedUser() user: KeycloakTokenParsed, + @Param('id') id: string, + @Body() data: UpdateExpenseDto, + ) { + await this.verifyCampaignOwnership(user, data.vaultId || '0') + return this.expensesService.update(id, data) } @Delete(':id') - @Roles({ - roles: [RealmViewSupporters.role, ViewSupporters.role], - mode: RoleMatchingMode.ANY, - }) remove(@Param('id') id: string) { return this.expensesService.remove(id) } + + @Post(':expenseId/files') + @UseInterceptors(FilesInterceptor('file', 5, { limits: { fileSize: 10485760 } })) //limit uploaded files to 5 at once and 10MB each + async uploadFiles( + @Param('expenseId') expenseId: string, + @UploadedFiles() files: Express.Multer.File[], + @AuthenticatedUser() user: KeycloakTokenParsed, + ) { + const uploaderId = await this.expensesService.findUploaderId(user.sub) + + return this.expensesService.uploadFiles(expenseId, files, uploaderId) + } + + @Get(':id/files') + @Public() + getUploadedFiles(@Param('id') id: string) { + return this.expensesService.listUploadedFiles(id) + } + + @Get('download-file/:fileId') + @Public() + async downloadFile( + @Param('fileId') fileId: string, + @Response({ passthrough: true }) res, + ): Promise { + const file = await this.expensesService.downloadFile(fileId) + res.set({ + 'Content-Type': file.mimetype, + 'Content-Disposition': 'attachment; filename="' + file.filename + '"', + }) + return new StreamableFile(file.stream) + } + + @Delete('file/:fileId') + removeFile(@Param('fileId') fileId: string) { + return this.expensesService.removeFile(fileId) + } + + private async verifyCampaignOwnership(user: KeycloakTokenParsed, vaultId: string) { + if (!user || !user.sub) { + throw new NotFoundException('User not found') + } + + if (isAdmin(user)) { + return + } + + const isOwner = await this.expensesService.checkCampaignOwner(user.sub, vaultId) + if (!isOwner) { + throw new UnauthorizedException() + } + } } diff --git a/apps/api/src/expenses/expenses.module.ts b/apps/api/src/expenses/expenses.module.ts index 7ede8aabe..b50c20c01 100644 --- a/apps/api/src/expenses/expenses.module.ts +++ b/apps/api/src/expenses/expenses.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common' import { ExpensesService } from './expenses.service' import { ExpensesController } from './expenses.controller' import { PrismaService } from '../prisma/prisma.service' +import { S3Service } from '../s3/s3.service' @Module({ controllers: [ExpensesController], - providers: [PrismaService, ExpensesService], + providers: [PrismaService, ExpensesService, S3Service], }) export class ExpensesModule {} diff --git a/apps/api/src/expenses/expenses.service.spec.ts b/apps/api/src/expenses/expenses.service.spec.ts index 1849b4f73..f56ce3e24 100644 --- a/apps/api/src/expenses/expenses.service.spec.ts +++ b/apps/api/src/expenses/expenses.service.spec.ts @@ -1,13 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing' import { MockPrismaService } from '../prisma/prisma-client.mock' import { ExpensesService } from './expenses.service' +import { S3Service } from '../s3/s3.service' describe('ExpensesService', () => { let service: ExpensesService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [MockPrismaService, ExpensesService], + providers: [MockPrismaService, ExpensesService, S3Service], }).compile() service = module.get(ExpensesService) diff --git a/apps/api/src/expenses/expenses.service.ts b/apps/api/src/expenses/expenses.service.ts index a0c3a0e4e..9a8a6b9ac 100644 --- a/apps/api/src/expenses/expenses.service.ts +++ b/apps/api/src/expenses/expenses.service.ts @@ -1,43 +1,55 @@ import { Injectable, + Logger, NotFoundException, BadRequestException, - ForbiddenException, } from '@nestjs/common' -import { Expense, ExpenseStatus } from '@prisma/client' +import { Expense, ExpenseFile, ExpenseStatus } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' import { CreateExpenseDto } from './dto/create-expense.dto' import { UpdateExpenseDto } from './dto/update-expense.dto' +import { CreateExpenseFileDto } from './dto/create-expense-file.dto' +import { S3Service } from '../s3/s3.service' +import { Readable } from 'stream' @Injectable() export class ExpensesService { - constructor(private prisma: PrismaService) {} + private readonly bucketName: string = 'expenses-files' + constructor(private prisma: PrismaService, private s3: S3Service) {} /** * Creates an expense, while blocking the corresponding amount in the source vault. */ async createExpense(createExpenseDto: CreateExpenseDto) { - const sourceVault = await this.prisma.vault.findFirst({ - where: { - id: createExpenseDto.vaultId, - }, - rejectOnNotFound: true, - }) - - if (sourceVault.amount - sourceVault.blockedAmount - createExpenseDto.amount < 0) { - throw new BadRequestException('Insufficient amount in vault.') - } - const writeExpense = this.prisma.expense.create({ data: createExpenseDto }) - const writeVault = this.prisma.vault.update({ - where: { id: sourceVault.id }, - data: { blockedAmount: { increment: createExpenseDto.amount } }, - }) - const [result] = await this.prisma.$transaction([writeExpense, writeVault]) + const [result] = await this.prisma.$transaction([writeExpense]) return result } + async uploadFiles(expenseId: string, files: Express.Multer.File[], uploaderId: string) { + files = files || [] + await Promise.all( + files.map((file) => { + console.log( + 'File uploading: ', + expenseId, + file.originalname, + file.mimetype, + file.originalname, + ) + const fileDto: CreateExpenseFileDto = { + filename: file.originalname, + mimetype: file.mimetype, + expenseId, + uploaderId, + } + + this.createExpenseFile(fileDto, file.buffer) + }), + ) + } + async listExpenses(returnDeleted = false): Promise { return this.prisma.expense.findMany({ where: { deleted: returnDeleted } }) } @@ -51,14 +63,8 @@ export class ExpensesService { } } - // Functionality will be reworked soon async remove(id: string) { - throw new ForbiddenException() - try { - return await this.prisma.expense.delete({ where: { id } }) - } catch (error) { - throw new NotFoundException('No expense with this id exists.') - } + return await this.prisma.expense.delete({ where: { id } }) } /** @@ -70,15 +76,14 @@ export class ExpensesService { rejectOnNotFound: true, }) if ( - [ExpenseStatus.approved.valueOf(), ExpenseStatus.canceled.valueOf()] - .includes(expense.status.valueOf()) + [ExpenseStatus.approved.valueOf(), ExpenseStatus.canceled.valueOf()].includes( + expense.status.valueOf(), + ) ) { throw new BadRequestException('Expense has already been finilized and cannot be updated.') } - if (expense.vaultId !== dto.vaultId || expense.amount !== dto.amount) { - throw new BadRequestException( - 'Vault or amount cannot be changed, please decline the withdrawal instead.', - ) + if (expense.vaultId !== dto.vaultId) { + throw new BadRequestException('Vault or amount cannot be changed.') } const vault = await this.prisma.vault.findFirst({ @@ -122,4 +127,81 @@ export class ExpensesService { const [result] = await this.prisma.$transaction([writeExpense, writeVault]) return result } + + async createExpenseFile(file: CreateExpenseFileDto, buffer: Buffer): Promise { + const dbFile = await this.prisma.expenseFile.create({ data: file }) + + // Use the DB primary key as the S3 key. This will make sure iт is always unique. + await this.s3.uploadObject( + this.bucketName, + dbFile.id, + encodeURIComponent(file.filename), + file.mimetype, + buffer, + 'expenses', + file.expenseId, + file.uploaderId, + ) + return dbFile.id + } + + async listUploadedFiles(id: string): Promise { + return this.prisma.expenseFile.findMany({ where: { expenseId: id } }) + } + + async downloadFile(id: string): Promise<{ + filename: string + mimetype: string + stream: Readable + }> { + const file = await this.prisma.expenseFile.findFirst({ where: { id: id } }) + if (!file) { + Logger.warn('No expenseFile file record with ID: ' + id) + throw new NotFoundException('No expenseFile file record with ID: ' + id) + } + return { + filename: encodeURIComponent(file.filename), + mimetype: file.mimetype, + stream: await this.s3.streamFile(this.bucketName, id), + } + } + + async removeFile(id: string) { + const file = await this.prisma.expenseFile.findFirst({ where: { id: id } }) + if (!file) { + Logger.warn('No expenseFile file record with ID: ' + id) + throw new NotFoundException('No expenseFile file record with ID: ' + id) + } + + await this.s3.deleteObject(this.bucketName, id) + await this.prisma.expenseFile.delete({ where: { id: id } }) + } + + async findUploaderId(keycloakId: string): Promise { + const person = await this.prisma.person.findFirst({ where: { keycloakId } }) + if (!person) { + throw new NotFoundException('No person found with that login.') + } + + return person.id + } + + async checkCampaignOwner(keycloakId: string, vaultId: string): Promise { + const person = await this.prisma.person.findFirst({ where: { keycloakId } }) + if (!person) { + Logger.warn(`No person record with keycloak ID: ${keycloakId}`) + return false + } + + const campaign = await this.prisma.campaign.findFirst({ + where: { organizer: { personId: person.id } }, + select: { id: true, vaults: { where: { id: vaultId } } }, + }) + + if (!campaign) { + return false + } + + return true + } } diff --git a/apps/api/src/person/person.controller.ts b/apps/api/src/person/person.controller.ts index 32e415816..b562a5cad 100644 --- a/apps/api/src/person/person.controller.ts +++ b/apps/api/src/person/person.controller.ts @@ -4,7 +4,7 @@ import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types import { PersonService } from './person.service' import { CreatePersonDto } from './dto/create-person.dto' import { UpdatePersonDto } from './dto/update-person.dto' -import { ApiTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger' @ApiTags('person') @Controller('person') @@ -30,14 +30,15 @@ export class PersonController { } @Get(':id') - @Roles({ - roles: [RealmViewSupporters.role, ViewSupporters.role], - mode: RoleMatchingMode.ANY, - }) async findOne(@Param('id') id: string) { return await this.personService.findOne(id) } + @Get('by-keylock-id/:keylockId') + async findOneByKeylockId(@Param('keylockId') id: string) { + return await this.personService.findOneByKeycloakId(id) + } + @Patch(':id') @Roles({ roles: [RealmViewSupporters.role, ViewSupporters.role], diff --git a/apps/api/src/s3/s3.service.ts b/apps/api/src/s3/s3.service.ts index ef41c6f87..3d771ebdc 100644 --- a/apps/api/src/s3/s3.service.ts +++ b/apps/api/src/s3/s3.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common' +import { Injectable, Logger } from '@nestjs/common' import { S3, Endpoint, config } from 'aws-sdk' import { Readable } from 'stream' @@ -45,7 +45,12 @@ export class S3Service { }, }) .promise() - .then((x) => x.Key) + .then((x) => { + Logger.log( + `Uploading file ${filename} to S3 bucket ${bucketName} with key ${fileId}: ${x.Key}, loc: ${x.Location}`, + ) + return x.Key + }) } async deleteObject(bucketName: string, fileId: string): Promise { diff --git a/db/seed/expense/factory.ts b/db/seed/expense/factory.ts index 384ad70de..07b47c9b9 100644 --- a/db/seed/expense/factory.ts +++ b/db/seed/expense/factory.ts @@ -15,4 +15,5 @@ export const expenseFactory = Factory.define(({ associations }) => ({ currency: faker.helpers.arrayElement(Object.values(Currency)), status: faker.helpers.arrayElement(Object.values(ExpenseStatus)), deleted: faker.datatype.boolean(), + spentAt: faker.date.past(), })) diff --git a/migrations/20230220112819_add_spent_at_to_expenses/migration.sql b/migrations/20230220112819_add_spent_at_to_expenses/migration.sql new file mode 100644 index 000000000..02dcbb7c5 --- /dev/null +++ b/migrations/20230220112819_add_spent_at_to_expenses/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "expenses" ADD COLUMN "spent_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/migrations/20230220155844_add_expense_files_to_an_expense/migration.sql b/migrations/20230220155844_add_expense_files_to_an_expense/migration.sql new file mode 100644 index 000000000..ae5fd1a35 --- /dev/null +++ b/migrations/20230220155844_add_expense_files_to_an_expense/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "expense_files" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "filename" VARCHAR(200) NOT NULL, + "mimetype" VARCHAR(100) NOT NULL, + "expense_id" UUID NOT NULL, + "uploader_id" UUID NOT NULL, + + CONSTRAINT "expense_files_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "expense_files" ADD CONSTRAINT "expense_files_expense_id_fkey" FOREIGN KEY ("expense_id") REFERENCES "expenses"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "expense_files" ADD CONSTRAINT "expense_files_uploader_id_fkey" FOREIGN KEY ("uploader_id") REFERENCES "people"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/podkrepi.dbml b/podkrepi.dbml index 4f8ce70fa..8f4ad4ee7 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -37,6 +37,7 @@ https://en.wikipedia.org/wiki/National_identification_number#Bulgaria'] infoRequests info_requests [not null] irregularities irregularities [not null] irregularityFiles irregularity_files [not null] + expenseFiles expense_files [not null] organizer organizers recurringDonations recurring_donations [not null] supporters supporters [not null] @@ -417,10 +418,22 @@ Table expenses { approvedBy people document documents vault vaults [not null] + spentAt DateTime [default: `now()`, not null] + expenseFiles expense_files [not null] Note: 'Pay for something from a given vault' } +Table expense_files { + id String [pk] + filename String [not null] + mimetype String [not null] + expenseId String [not null] + uploaderId String [not null] + expense expenses [not null] + uploadedBy people [not null] +} + Table documents { id String [pk] type DocumentType [not null] @@ -733,4 +746,8 @@ Ref: expenses.documentId > documents.id Ref: expenses.vaultId > vaults.id +Ref: expense_files.expenseId > expenses.id + +Ref: expense_files.uploaderId > people.id + Ref: documents.ownerId > people.id \ No newline at end of file diff --git a/schema.prisma b/schema.prisma index 9f772b994..2e5dfe051 100644 --- a/schema.prisma +++ b/schema.prisma @@ -61,6 +61,7 @@ model Person { infoRequests InfoRequest[] irregularities Irregularity[] irregularityFiles IrregularityFile[] + expenseFiles ExpenseFile[] organizer Organizer? recurringDonations RecurringDonation[] supporters Supporter[] @@ -501,10 +502,24 @@ model Expense { approvedBy Person? @relation(fields: [approvedById], references: [id]) document Document? @relation(fields: [documentId], references: [id]) vault Vault @relation(fields: [vaultId], references: [id]) + spentAt DateTime @default(now()) @map("spent_at") @db.Timestamptz(6) + expenseFiles ExpenseFile[] @@map("expenses") } +model ExpenseFile { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + filename String @db.VarChar(200) + mimetype String @db.VarChar(100) + expenseId String @map("expense_id") @db.Uuid + uploaderId String @map("uploader_id") @db.Uuid + expense Expense @relation(fields: [expenseId], references: [id]) + uploadedBy Person @relation(fields: [uploaderId], references: [id]) + + @@map("expense_files") +} + model Document { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid type DocumentType