From e5b8362f794c8483e21a418f8ffb37547cb828b6 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 7 Jun 2023 15:43:21 +0300 Subject: [PATCH 01/27] prisma: Add schema for campaign news --- .../dto/connect-campaignNews.dto.ts | 5 ++++ .../dto/create-campaignNews.dto.ts | 15 ++++++++++ .../generated/campaignNews/dto/index.ts | 4 +++ .../dto/update-campaignNews.dto.ts | 15 ++++++++++ .../entities/campaignNews.entity.ts | 22 +++++++++++++++ .../generated/campaignNews/entities/index.ts | 2 ++ .../migration.sql | 26 +++++++++++++++++ schema.prisma | 28 +++++++++++++++++++ 8 files changed, 117 insertions(+) create mode 100644 apps/api/src/domain/generated/campaignNews/dto/connect-campaignNews.dto.ts create mode 100644 apps/api/src/domain/generated/campaignNews/dto/create-campaignNews.dto.ts create mode 100644 apps/api/src/domain/generated/campaignNews/dto/index.ts create mode 100644 apps/api/src/domain/generated/campaignNews/dto/update-campaignNews.dto.ts create mode 100644 apps/api/src/domain/generated/campaignNews/entities/campaignNews.entity.ts create mode 100644 apps/api/src/domain/generated/campaignNews/entities/index.ts create mode 100644 migrations/20230607124230_add_campaign_news/migration.sql diff --git a/apps/api/src/domain/generated/campaignNews/dto/connect-campaignNews.dto.ts b/apps/api/src/domain/generated/campaignNews/dto/connect-campaignNews.dto.ts new file mode 100644 index 000000000..f12e24daa --- /dev/null +++ b/apps/api/src/domain/generated/campaignNews/dto/connect-campaignNews.dto.ts @@ -0,0 +1,5 @@ + + export class ConnectCampaignNewsDto { + id: string; + } + \ No newline at end of file diff --git a/apps/api/src/domain/generated/campaignNews/dto/create-campaignNews.dto.ts b/apps/api/src/domain/generated/campaignNews/dto/create-campaignNews.dto.ts new file mode 100644 index 000000000..c66e7e93d --- /dev/null +++ b/apps/api/src/domain/generated/campaignNews/dto/create-campaignNews.dto.ts @@ -0,0 +1,15 @@ + + + + + + +export class CreateCampaignNewsDto { + slug: string; +title: string; +author: string; +sourceLink?: string; +publishedAt?: Date; +editedAt?: Date; +description: string; +} diff --git a/apps/api/src/domain/generated/campaignNews/dto/index.ts b/apps/api/src/domain/generated/campaignNews/dto/index.ts new file mode 100644 index 000000000..d8692302f --- /dev/null +++ b/apps/api/src/domain/generated/campaignNews/dto/index.ts @@ -0,0 +1,4 @@ + +export * from './connect-campaignNews.dto'; +export * from './create-campaignNews.dto'; +export * from './update-campaignNews.dto'; \ No newline at end of file diff --git a/apps/api/src/domain/generated/campaignNews/dto/update-campaignNews.dto.ts b/apps/api/src/domain/generated/campaignNews/dto/update-campaignNews.dto.ts new file mode 100644 index 000000000..b97165ede --- /dev/null +++ b/apps/api/src/domain/generated/campaignNews/dto/update-campaignNews.dto.ts @@ -0,0 +1,15 @@ + + + + + + +export class UpdateCampaignNewsDto { + slug?: string; +title?: string; +author?: string; +sourceLink?: string; +publishedAt?: Date; +editedAt?: Date; +description?: string; +} diff --git a/apps/api/src/domain/generated/campaignNews/entities/campaignNews.entity.ts b/apps/api/src/domain/generated/campaignNews/entities/campaignNews.entity.ts new file mode 100644 index 000000000..637e6b6ad --- /dev/null +++ b/apps/api/src/domain/generated/campaignNews/entities/campaignNews.entity.ts @@ -0,0 +1,22 @@ + +import {CampaignNewsState} from '@prisma/client' +import {Campaign} from '../../campaign/entities/campaign.entity' +import {Person} from '../../person/entities/person.entity' + + +export class CampaignNews { + id: string ; +campaignId: string ; +publisherId: string ; +slug: string ; +title: string ; +author: string ; +sourceLink: string | null; +state: CampaignNewsState ; +createdAt: Date ; +publishedAt: Date | null; +editedAt: Date | null; +description: string ; +campaign?: Campaign ; +publisher?: Person ; +} diff --git a/apps/api/src/domain/generated/campaignNews/entities/index.ts b/apps/api/src/domain/generated/campaignNews/entities/index.ts new file mode 100644 index 000000000..a356a01d1 --- /dev/null +++ b/apps/api/src/domain/generated/campaignNews/entities/index.ts @@ -0,0 +1,2 @@ + +export * from './campaignNews.entity'; \ No newline at end of file diff --git a/migrations/20230607124230_add_campaign_news/migration.sql b/migrations/20230607124230_add_campaign_news/migration.sql new file mode 100644 index 000000000..e6d7442ef --- /dev/null +++ b/migrations/20230607124230_add_campaign_news/migration.sql @@ -0,0 +1,26 @@ +-- CreateEnum +CREATE TYPE "campaign_news_state" AS ENUM ('draft', 'published'); + +-- CreateTable +CREATE TABLE "campaign_news" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "campaign_id" UUID NOT NULL, + "publisher_id" UUID NOT NULL, + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "author" TEXT NOT NULL, + "source_link" TEXT, + "state" "campaign_news_state" NOT NULL DEFAULT 'draft', + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published_at" TIMESTAMPTZ(6), + "edited_at" TIMESTAMPTZ(6), + "description" TEXT NOT NULL, + + CONSTRAINT "campaign_news_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "campaign_news" ADD CONSTRAINT "campaign_news_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "campaign_news" ADD CONSTRAINT "campaign_news_publisher_id_fkey" FOREIGN KEY ("publisher_id") REFERENCES "people"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/schema.prisma b/schema.prisma index 3ce5fb770..3dc09ad3b 100644 --- a/schema.prisma +++ b/schema.prisma @@ -68,6 +68,7 @@ model Person { supporters Supporter[] transfers Transfer[] withdrawals Withdrawal[] + publishedNews CampaignNews[] @@index([keycloakId], map: "keycloak_id_idx") @@index([stripeCustomerId], map: "stripe_customer_id_idx") @@ -211,10 +212,30 @@ model Campaign { vaults Vault[] withdrawals Withdrawal[] slugArchive SlugArchive[] + campaignNews CampaignNews[] @@map("campaigns") } +model CampaignNews { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + campaignId String @map("campaign_id") @db.Uuid + publisherId String @map("publisher_id") @db.Uuid + slug String + title String + author String + sourceLink String? @map("source_link") + state CampaignNewsState @default(draft) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + publishedAt DateTime? @map("published_at") @db.Timestamptz(6) + editedAt DateTime? @map("edited_at") @db.Timestamptz(6) + description String + campaign Campaign @relation(fields: [campaignId], references: [id]) + publisher Person @relation(fields: [publisherId], references: [id]) + + @@map("campaign_news") +} + /// Keeps track of previous slugs that are not used currently in any active campaign model SlugArchive { slug String @id @unique @db.VarChar(250) @@ -624,6 +645,13 @@ enum CampaignState { @@map("campaign_state") } +enum CampaignNewsState { + draft + published + + @@map("campaign_news_state") +} + enum Currency { BGN EUR From ec26952898a3af1503bf5499d3a8fd5493ccaf4f Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 7 Jun 2023 15:56:12 +0300 Subject: [PATCH 02/27] api/src: Add campaign news module --- apps/api/src/app/app.module.ts | 2 + .../campaign-news.controller.spec.ts | 34 ++++ .../campaign-news/campaign-news.controller.ts | 115 ++++++++++++ .../src/campaign-news/campaign-news.module.ts | 12 ++ .../campaign-news.service.spec.ts | 19 ++ .../campaign-news/campaign-news.service.ts | 164 ++++++++++++++++++ .../dto/create-campaign-news.dto.ts | 63 +++++++ .../dto/update-campaign-news.dto.ts | 4 + 8 files changed, 413 insertions(+) create mode 100644 apps/api/src/campaign-news/campaign-news.controller.spec.ts create mode 100644 apps/api/src/campaign-news/campaign-news.controller.ts create mode 100644 apps/api/src/campaign-news/campaign-news.module.ts create mode 100644 apps/api/src/campaign-news/campaign-news.service.spec.ts create mode 100644 apps/api/src/campaign-news/campaign-news.service.ts create mode 100644 apps/api/src/campaign-news/dto/create-campaign-news.dto.ts create mode 100644 apps/api/src/campaign-news/dto/update-campaign-news.dto.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 5f73df514..7d4c059c4 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -52,6 +52,7 @@ import { NotificationModule } from '../sockets/notifications/notification.module import { ScheduleModule } from '@nestjs/schedule' import { TasksModule } from '../tasks//bank-import/tasks.module' import { BankTransactionsModule } from '../bank-transactions/bank-transactions.module' +import { CampaignNewsModule } from '../campaign-news/campaign-news.module' @Module({ imports: [ @@ -103,6 +104,7 @@ import { BankTransactionsModule } from '../bank-transactions/bank-transactions.m JwtModule, NotificationModule, BankTransactionsModule, + CampaignNewsModule ], controllers: [AppController], providers: [ diff --git a/apps/api/src/campaign-news/campaign-news.controller.spec.ts b/apps/api/src/campaign-news/campaign-news.controller.spec.ts new file mode 100644 index 000000000..7cfd76e3e --- /dev/null +++ b/apps/api/src/campaign-news/campaign-news.controller.spec.ts @@ -0,0 +1,34 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { PersonService } from '../person/person.service' +import { MockPrismaService } from '../prisma/prisma-client.mock' +import { PrismaService } from '../prisma/prisma.service' +import { CampaignNewsController } from './campaign-news.controller' +import { CampaignNewsService } from './campaign-news.service' + +describe('CampaignNewsController', () => { + let controller: CampaignNewsController + let prismaService: PrismaService + + const personIdMock = 'testPersonId' + const personServiceMock = { + findOneByKeycloakId: jest.fn(() => { + return { id: personIdMock } + }), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CampaignNewsController], + providers: [CampaignNewsService, PersonService, MockPrismaService], + }) + .overrideProvider(PersonService) + .useValue(personServiceMock) + .compile() + + controller = module.get(CampaignNewsController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) +}) diff --git a/apps/api/src/campaign-news/campaign-news.controller.ts b/apps/api/src/campaign-news/campaign-news.controller.ts new file mode 100644 index 000000000..c4fc2a51c --- /dev/null +++ b/apps/api/src/campaign-news/campaign-news.controller.ts @@ -0,0 +1,115 @@ +import { ApiTags } from '@nestjs/swagger' +import { + Controller, + Get, + Post, + Body, + Param, + Delete, + Put, + Inject, + forwardRef, + ForbiddenException, + NotFoundException, +} from '@nestjs/common' +import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' +import { AuthenticatedUser, Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' +import { CreateCampaignNewsDto } from './dto/create-campaign-news.dto' +import { CampaignNewsService } from './campaign-news.service' +import { UpdateCampaignNewsDto } from './dto/update-campaign-news.dto' +import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak' +import { PersonService } from '../person/person.service' + +@ApiTags('campaign-news') +@Controller('campaign-news') +export class CampaignNewsController { + constructor( + private readonly campaignNewsService: CampaignNewsService, + @Inject(forwardRef(() => PersonService)) private personService: PersonService, + ) {} + + @Post() + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + async create( + @Body() CreateCampaignNewsDto: CreateCampaignNewsDto, + @AuthenticatedUser() user: KeycloakTokenParsed, + ) { + if (!isAdmin(user)) { + throw new ForbiddenException( + 'The user is not coordinator,organizer or beneficiery to the requested campaign', + ) + } + const person = await this.personService.findOneByKeycloakId(user.sub) + + if (!person) { + throw new NotFoundException('User has not been found') + } + + return await this.campaignNewsService.createDraft({ + ...CreateCampaignNewsDto, + publisherId: person.id, + }) + } + + @Get('list-all') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + async getAdminList() { + return await this.campaignNewsService.listAllArticles() + } + + @Get(':slug') + @Public() + async findOne(@Param('slug') slug: string) { + return await this.campaignNewsService.findArticleBySlug(slug) + } + + @Get('byId/:id') + @Public() + async findById(@Param('id') id: string) { + return await this.campaignNewsService.findArticleByID(id) + } + + @Put(':id') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + async editArticle( + @Param('id') articleId: string, + @Body() updateCampaignNewsDto: UpdateCampaignNewsDto, + @AuthenticatedUser() user: KeycloakTokenParsed, + ) { + if (!isAdmin(user)) { + throw new Error('The user has no access to edit this article') + } + const article = await this.campaignNewsService.findArticleByID(articleId) + + if (!article) { + throw new NotFoundException('Article not found') + } + + return await this.campaignNewsService.editArticle( + articleId, + article.state, + updateCampaignNewsDto, + ) + } + + @Delete(':id') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + async delete(@Param('id') articleId: string, @AuthenticatedUser() user: KeycloakTokenParsed) { + if (!isAdmin(user)) { + throw new Error('The user has no access to delete this article') + } + return await this.campaignNewsService.deleteArticle(articleId) + } +} diff --git a/apps/api/src/campaign-news/campaign-news.module.ts b/apps/api/src/campaign-news/campaign-news.module.ts new file mode 100644 index 000000000..e3cf4d169 --- /dev/null +++ b/apps/api/src/campaign-news/campaign-news.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { CampaignNewsService } from './campaign-news.service' +import { CampaignNewsController } from './campaign-news.controller' +import { PrismaService } from '../prisma/prisma.service' +import { PersonService } from '../person/person.service' + +@Module({ + controllers: [CampaignNewsController], + providers: [CampaignNewsService, PersonService, PrismaService], + exports: [CampaignNewsService], +}) +export class CampaignNewsModule {} diff --git a/apps/api/src/campaign-news/campaign-news.service.spec.ts b/apps/api/src/campaign-news/campaign-news.service.spec.ts new file mode 100644 index 000000000..3dcde7718 --- /dev/null +++ b/apps/api/src/campaign-news/campaign-news.service.spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { MockPrismaService } from '../prisma/prisma-client.mock' +import { CampaignNewsService } from './campaign-news.service' + +describe('CampaignNewsService', () => { + let service: CampaignNewsService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CampaignNewsService, MockPrismaService], + }).compile() + + service = module.get(CampaignNewsService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/api/src/campaign-news/campaign-news.service.ts b/apps/api/src/campaign-news/campaign-news.service.ts new file mode 100644 index 000000000..f95136b0c --- /dev/null +++ b/apps/api/src/campaign-news/campaign-news.service.ts @@ -0,0 +1,164 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common' +import { CreateCampaignNewsDto } from './dto/create-campaign-news.dto' +import { PrismaService } from '../prisma/prisma.service' +import { UpdateCampaignNewsDto } from './dto/update-campaign-news.dto' +import { CampaignNewsState } from '@prisma/client' + +@Injectable() +export class CampaignNewsService { + constructor(private prisma: PrismaService) {} + private RECORDS_PER_PAGE = 4 + + async createDraft(campaignNewsDto: CreateCampaignNewsDto) { + try { + return await this.prisma.campaignNews.create({ data: campaignNewsDto }) + } catch (error) { + const message = 'Creating article about campaign failed' + Logger.warn(error) + throw new BadRequestException(message) + } + } + + async listPublishedNewsWithPagination(currentPage: number) { + const [articles, totalRecords] = await this.prisma.$transaction([ + this.prisma.campaignNews.findMany({ + where: { state: CampaignNewsState.published }, + orderBy: { publishedAt: 'desc' }, + take: this.RECORDS_PER_PAGE, + skip: Number((currentPage - 1) * this.RECORDS_PER_PAGE), + select: { + id: true, + title: true, + slug: true, + author: true, + publishedAt: true, + description: true, + campaign: { + select: { + title: true, + state: true, + }, + }, + }, + }), + this.prisma.campaignNews.count({ + where: { state: CampaignNewsState.published }, + }), + ]) + + const totalPages = Math.ceil(totalRecords / this.RECORDS_PER_PAGE) + + return { + articles: articles, + pagination: { + currentPage: currentPage, + nextPage: currentPage === totalPages ? currentPage : currentPage + 1, + prevPage: currentPage > 1 ? currentPage - 1 : 1, + totalPages: totalPages, + }, + } + } + + async findArticleByID(articleId: string) { + const article = await this.prisma.campaignNews + .findFirst({ + where: { id: articleId }, + }) + .catch((error) => Logger.warn(error)) + return article + } + + async findArticlesByCampaignSlug(slug: string, currentPage: number) { + const [articles, totalRecords] = await this.prisma.$transaction([ + this.prisma.campaignNews.findMany({ + where: { campaign: { slug: slug }, state: CampaignNewsState.published }, + orderBy: { publishedAt: 'desc' }, + take: this.RECORDS_PER_PAGE, + skip: Number((currentPage - 1) * this.RECORDS_PER_PAGE), + select: { + id: true, + title: true, + slug: true, + publishedAt: true, + author: true, + description: true, + campaign: { + select: { + title: true, + state: true, + slug: true, + }, + }, + }, + }), + this.prisma.campaignNews.count({ + where: { campaign: { slug: slug }, state: CampaignNewsState.published }, + }), + ]) + + const totalPages = Math.ceil(totalRecords / this.RECORDS_PER_PAGE) + + return { + articles: articles, + pagination: { + currentPage: currentPage, + nextPage: currentPage === totalPages ? currentPage : currentPage + 1, + prevPage: currentPage > 1 ? currentPage - 1 : 1, + totalPages: totalPages ?? 1, + }, + } + } + + async findArticleBySlug(slug: string) { + return await this.prisma.campaignNews + .findFirst({ where: { slug: slug } }) + .catch((error) => Logger.warn(error)) + } + + async editArticle(id: string, state: CampaignNewsState, editArticleDto: UpdateCampaignNewsDto) { + try { + return await this.prisma.campaignNews.update({ + where: { id }, + data: { + ...editArticleDto, + editedAt: new Date(), + publishedAt: + editArticleDto.state === CampaignNewsState.published && state === CampaignNewsState.draft + ? new Date() + : editArticleDto.state === CampaignNewsState.draft + ? null + : undefined, + }, + }) + } catch (error) { + const message = 'Updating news article has failed!' + Logger.warn(error) + throw new BadRequestException(message) + } + } + + async listAllArticles() { + const fetch = await this.prisma.campaignNews.findMany({ + include: { + campaign: { + select: { + title: true, + }, + }, + }, + }) + return fetch + } + + async deleteArticle(articleId: string) { + try { + const test = await this.prisma.campaignNews.delete({ where: { id: articleId } }) + console.log(test) + return test + } catch (error) { + const message = 'Deleting news article has failed!' + Logger.warn(error) + throw new BadRequestException(message) + } + } +} diff --git a/apps/api/src/campaign-news/dto/create-campaign-news.dto.ts b/apps/api/src/campaign-news/dto/create-campaign-news.dto.ts new file mode 100644 index 000000000..a9955da78 --- /dev/null +++ b/apps/api/src/campaign-news/dto/create-campaign-news.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger' +import { CampaignNewsState } from '@prisma/client' +import { Expose } from 'class-transformer' +import { IsDate, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator' + +@Expose() +export class CreateCampaignNewsDto { + @ApiProperty() + @Expose() + @IsUUID() + campaignId: string + + @ApiProperty() + @Expose() + @IsString() + slug: string + + @ApiProperty() + @Expose() + @IsString() + title: string + + @ApiProperty() + @Expose() + @IsOptional() + @IsUUID() + publisherId: string + + @ApiProperty() + @Expose() + @IsString() + author: string + + @ApiProperty() + @Expose() + @IsOptional() + @IsString() + sourceLink: string | null + + @ApiProperty() + @Expose() + @IsOptional() + @IsString() + @IsEnum(CampaignNewsState) + state: CampaignNewsState + + @ApiProperty() + @Expose() + @IsOptional() + @IsDate() + publishedAt: Date | null + + @ApiProperty() + @Expose() + @IsOptional() + @IsDate() + editedAt: Date | null + + @ApiProperty() + @Expose() + @IsString() + description: string +} diff --git a/apps/api/src/campaign-news/dto/update-campaign-news.dto.ts b/apps/api/src/campaign-news/dto/update-campaign-news.dto.ts new file mode 100644 index 000000000..4cbefda66 --- /dev/null +++ b/apps/api/src/campaign-news/dto/update-campaign-news.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger' +import { CreateCampaignNewsDto } from './create-campaign-news.dto' + +export class UpdateCampaignNewsDto extends PartialType(CreateCampaignNewsDto) {} From 0fd829ce443ee05a66019b91bbf305abbf22de08 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 7 Jun 2023 16:04:25 +0300 Subject: [PATCH 03/27] src/campaign: Add new endpoints to show campaign related news --- apps/api/src/campaign/campaign.controller.ts | 19 +++++++++++++++++++ apps/api/src/campaign/campaign.module.ts | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/api/src/campaign/campaign.controller.ts b/apps/api/src/campaign/campaign.controller.ts index 4eca77640..2efd450e2 100644 --- a/apps/api/src/campaign/campaign.controller.ts +++ b/apps/api/src/campaign/campaign.controller.ts @@ -16,6 +16,8 @@ import { BadRequestException, Query, ForbiddenException, + ParseIntPipe, + DefaultValuePipe, } from '@nestjs/common' import { CampaignService } from './campaign.service' @@ -26,6 +28,7 @@ import { PersonService } from '../person/person.service' import { ApiQuery } from '@nestjs/swagger' import { DonationQueryDto } from '../common/dto/donation-query-dto' import { ApiTags } from '@nestjs/swagger' +import { CampaignNewsService } from '../campaign-news/campaign-news.service' @ApiTags('campaign') @Controller('campaign') @@ -33,6 +36,7 @@ export class CampaignController { constructor( private readonly campaignService: CampaignService, @Inject(forwardRef(() => PersonService)) private readonly personService: PersonService, + @Inject(forwardRef(() => CampaignNewsService)) private readonly campaignNewsService: CampaignNewsService ) {} @Get('list') @@ -60,12 +64,27 @@ export class CampaignController { return await this.campaignService.getUserDonatedCampaigns(user.sub) } + @Get('news') + @Public() + async listPublishedNews(@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number) { + return this.campaignNewsService.listPublishedNewsWithPagination(page) + } + @Get(':slug') @Public() async viewBySlug(@Param('slug') slug: string): Promise<{ campaign: Campaign | null }> { const campaign = await this.campaignService.getCampaignBySlug(slug) return { campaign } } + + @Get(':slug/news') + @Public() + async listNewsForSingleCampaign( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Param(`slug`) slug: string, + ) { + return this.campaignNewsService.findArticlesByCampaignSlug(slug, page) + } @Post('create-campaign') async create( diff --git a/apps/api/src/campaign/campaign.module.ts b/apps/api/src/campaign/campaign.module.ts index 32b886fbc..b9744b1c2 100644 --- a/apps/api/src/campaign/campaign.module.ts +++ b/apps/api/src/campaign/campaign.module.ts @@ -8,11 +8,12 @@ import { CampaignTypeController } from './campaign-type.controller' import { CampaignController } from './campaign.controller' import { CampaignService } from './campaign.service' import { NotificationModule } from '../sockets/notifications/notification.module' +import { CampaignNewsService } from '../campaign-news/campaign-news.service' @Module({ imports: [forwardRef(() => VaultModule), NotificationModule], controllers: [CampaignController, CampaignTypeController], - providers: [CampaignService, PrismaService, VaultService, PersonService, ConfigService], + providers: [CampaignService, PrismaService, VaultService, PersonService, ConfigService, CampaignNewsService], exports: [CampaignService], }) export class CampaignModule {} From 949319416c930e024b6bc8d415bced269872c95a Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 7 Jun 2023 16:22:35 +0300 Subject: [PATCH 04/27] prisma: Add schema for news files --- .../dto/connect-campaignNewsFile.dto.ts | 5 +++ .../dto/create-campaignNewsFile.dto.ts | 13 ++++++ .../generated/campaignNewsFile/dto/index.ts | 4 ++ .../dto/update-campaignNewsFile.dto.ts | 13 ++++++ .../entities/campaignNewsFile.entity.ts | 16 +++++++ .../campaignNewsFile/entities/index.ts | 2 + .../migration.sql | 20 +++++++++ schema.prisma | 44 +++++++++++++------ 8 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/domain/generated/campaignNewsFile/dto/connect-campaignNewsFile.dto.ts create mode 100644 apps/api/src/domain/generated/campaignNewsFile/dto/create-campaignNewsFile.dto.ts create mode 100644 apps/api/src/domain/generated/campaignNewsFile/dto/index.ts create mode 100644 apps/api/src/domain/generated/campaignNewsFile/dto/update-campaignNewsFile.dto.ts create mode 100644 apps/api/src/domain/generated/campaignNewsFile/entities/campaignNewsFile.entity.ts create mode 100644 apps/api/src/domain/generated/campaignNewsFile/entities/index.ts create mode 100644 migrations/20230607132040_add_file_upload_for_news/migration.sql diff --git a/apps/api/src/domain/generated/campaignNewsFile/dto/connect-campaignNewsFile.dto.ts b/apps/api/src/domain/generated/campaignNewsFile/dto/connect-campaignNewsFile.dto.ts new file mode 100644 index 000000000..ab48dffbb --- /dev/null +++ b/apps/api/src/domain/generated/campaignNewsFile/dto/connect-campaignNewsFile.dto.ts @@ -0,0 +1,5 @@ + + export class ConnectCampaignNewsFileDto { + id: string; + } + \ No newline at end of file diff --git a/apps/api/src/domain/generated/campaignNewsFile/dto/create-campaignNewsFile.dto.ts b/apps/api/src/domain/generated/campaignNewsFile/dto/create-campaignNewsFile.dto.ts new file mode 100644 index 000000000..8615826f7 --- /dev/null +++ b/apps/api/src/domain/generated/campaignNewsFile/dto/create-campaignNewsFile.dto.ts @@ -0,0 +1,13 @@ + +import {CampaignFileRole} from '@prisma/client' +import {ApiProperty} from '@nestjs/swagger' + + + + +export class CreateCampaignNewsFileDto { + filename: string; +mimetype: string; +@ApiProperty({ enum: CampaignFileRole}) +role: CampaignFileRole; +} diff --git a/apps/api/src/domain/generated/campaignNewsFile/dto/index.ts b/apps/api/src/domain/generated/campaignNewsFile/dto/index.ts new file mode 100644 index 000000000..f8d203eaa --- /dev/null +++ b/apps/api/src/domain/generated/campaignNewsFile/dto/index.ts @@ -0,0 +1,4 @@ + +export * from './connect-campaignNewsFile.dto'; +export * from './create-campaignNewsFile.dto'; +export * from './update-campaignNewsFile.dto'; \ No newline at end of file diff --git a/apps/api/src/domain/generated/campaignNewsFile/dto/update-campaignNewsFile.dto.ts b/apps/api/src/domain/generated/campaignNewsFile/dto/update-campaignNewsFile.dto.ts new file mode 100644 index 000000000..cd6ef53f4 --- /dev/null +++ b/apps/api/src/domain/generated/campaignNewsFile/dto/update-campaignNewsFile.dto.ts @@ -0,0 +1,13 @@ + +import {CampaignFileRole} from '@prisma/client' +import {ApiProperty} from '@nestjs/swagger' + + + + +export class UpdateCampaignNewsFileDto { + filename?: string; +mimetype?: string; +@ApiProperty({ enum: CampaignFileRole}) +role?: CampaignFileRole; +} diff --git a/apps/api/src/domain/generated/campaignNewsFile/entities/campaignNewsFile.entity.ts b/apps/api/src/domain/generated/campaignNewsFile/entities/campaignNewsFile.entity.ts new file mode 100644 index 000000000..80143fbe2 --- /dev/null +++ b/apps/api/src/domain/generated/campaignNewsFile/entities/campaignNewsFile.entity.ts @@ -0,0 +1,16 @@ + +import {CampaignFileRole} from '@prisma/client' +import {CampaignNews} from '../../campaignNews/entities/campaignNews.entity' +import {Person} from '../../person/entities/person.entity' + + +export class CampaignNewsFile { + id: string ; +filename: string ; +articleId: string ; +personId: string ; +mimetype: string ; +role: CampaignFileRole ; +news?: CampaignNews ; +person?: Person ; +} diff --git a/apps/api/src/domain/generated/campaignNewsFile/entities/index.ts b/apps/api/src/domain/generated/campaignNewsFile/entities/index.ts new file mode 100644 index 000000000..d112cd6d7 --- /dev/null +++ b/apps/api/src/domain/generated/campaignNewsFile/entities/index.ts @@ -0,0 +1,2 @@ + +export * from './campaignNewsFile.entity'; \ No newline at end of file diff --git a/migrations/20230607132040_add_file_upload_for_news/migration.sql b/migrations/20230607132040_add_file_upload_for_news/migration.sql new file mode 100644 index 000000000..9fef81b36 --- /dev/null +++ b/migrations/20230607132040_add_file_upload_for_news/migration.sql @@ -0,0 +1,20 @@ +-- AlterEnum +ALTER TYPE "campaign_file_role" ADD VALUE 'gallery'; + +-- CreateTable +CREATE TABLE "campaign_news_files" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "filename" VARCHAR(200) NOT NULL, + "article_id" UUID NOT NULL, + "person_id" UUID NOT NULL, + "mimetype" VARCHAR(100) NOT NULL, + "role" "campaign_file_role" NOT NULL, + + CONSTRAINT "campaign_news_files_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "campaign_news_files" ADD CONSTRAINT "campaign_news_files_article_id_fkey" FOREIGN KEY ("article_id") REFERENCES "campaign_news"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "campaign_news_files" ADD CONSTRAINT "campaign_news_files_person_id_fkey" FOREIGN KEY ("person_id") REFERENCES "people"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/schema.prisma b/schema.prisma index 3dc09ad3b..135a5951e 100644 --- a/schema.prisma +++ b/schema.prisma @@ -69,6 +69,7 @@ model Person { transfers Transfer[] withdrawals Withdrawal[] publishedNews CampaignNews[] + articleFiles CampaignNewsFile[] @@index([keycloakId], map: "keycloak_id_idx") @@index([stripeCustomerId], map: "stripe_customer_id_idx") @@ -218,20 +219,21 @@ model Campaign { } model CampaignNews { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - campaignId String @map("campaign_id") @db.Uuid - publisherId String @map("publisher_id") @db.Uuid - slug String - title String - author String - sourceLink String? @map("source_link") - state CampaignNewsState @default(draft) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - publishedAt DateTime? @map("published_at") @db.Timestamptz(6) - editedAt DateTime? @map("edited_at") @db.Timestamptz(6) - description String - campaign Campaign @relation(fields: [campaignId], references: [id]) - publisher Person @relation(fields: [publisherId], references: [id]) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + campaignId String @map("campaign_id") @db.Uuid + publisherId String @map("publisher_id") @db.Uuid + slug String + title String + author String + sourceLink String? @map("source_link") + state CampaignNewsState @default(draft) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + publishedAt DateTime? @map("published_at") @db.Timestamptz(6) + editedAt DateTime? @map("edited_at") @db.Timestamptz(6) + description String + campaign Campaign @relation(fields: [campaignId], references: [id]) + publisher Person @relation(fields: [publisherId], references: [id]) + articleFiles CampaignNewsFile[] @@map("campaign_news") } @@ -276,6 +278,19 @@ model CampaignFile { @@map("campaign_files") } +model CampaignNewsFile { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + filename String @db.VarChar(200) + articleId String @map("article_id") @db.Uuid + personId String @map("person_id") @db.Uuid + mimetype String @db.VarChar(100) + role CampaignFileRole + news CampaignNews @relation(fields: [articleId], references: [id], onDelete: Cascade) + person Person @relation(fields: [personId], references: [id]) + + @@map("campaign_news_files") +} + model IrregularityFile { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid filename String @db.VarChar(200) @@ -833,6 +848,7 @@ enum CampaignFileRole { campaignListPhoto beneficiaryPhoto organizerPhoto + gallery @@map("campaign_file_role") } From 4805d0db9549598fc0bcc9a6defce2a921e6b2d0 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 7 Jun 2023 16:33:06 +0300 Subject: [PATCH 05/27] api/src: Add CampaignNewsFile module Duplication of CampaignFile module and accomodated to work for the news --- apps/api/src/app/app.module.ts | 4 +- .../campaign-news-file.controller.spec.ts | 92 ++++++++++++++++++ .../campaign-news-file.controller.ts | 94 +++++++++++++++++++ .../campaign-news-file.module.ts | 24 +++++ .../campaign-news-file.service.spec.ts | 28 ++++++ .../campaign-news-file.service.ts | 68 ++++++++++++++ .../dto/create-campaign-news-file.dto.ts | 9 ++ .../campaign-news-file/dto/files-role.dto.ts | 11 +++ 8 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/campaign-news-file/campaign-news-file.controller.spec.ts create mode 100644 apps/api/src/campaign-news-file/campaign-news-file.controller.ts create mode 100644 apps/api/src/campaign-news-file/campaign-news-file.module.ts create mode 100644 apps/api/src/campaign-news-file/campaign-news-file.service.spec.ts create mode 100644 apps/api/src/campaign-news-file/campaign-news-file.service.ts create mode 100644 apps/api/src/campaign-news-file/dto/create-campaign-news-file.dto.ts create mode 100644 apps/api/src/campaign-news-file/dto/files-role.dto.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 7d4c059c4..54c7eede3 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -53,6 +53,7 @@ import { ScheduleModule } from '@nestjs/schedule' import { TasksModule } from '../tasks//bank-import/tasks.module' import { BankTransactionsModule } from '../bank-transactions/bank-transactions.module' import { CampaignNewsModule } from '../campaign-news/campaign-news.module' +import { CampaignNewsFileModule } from '../campaign-news-file/campaign-news-file.module' @Module({ imports: [ @@ -104,7 +105,8 @@ import { CampaignNewsModule } from '../campaign-news/campaign-news.module' JwtModule, NotificationModule, BankTransactionsModule, - CampaignNewsModule + CampaignNewsModule, + CampaignNewsFileModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/campaign-news-file/campaign-news-file.controller.spec.ts b/apps/api/src/campaign-news-file/campaign-news-file.controller.spec.ts new file mode 100644 index 000000000..8fa081c35 --- /dev/null +++ b/apps/api/src/campaign-news-file/campaign-news-file.controller.spec.ts @@ -0,0 +1,92 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { ConfigService } from '@nestjs/config' +import { CampaignNewsFileController } from './campaign-news-file.controller' +import { CampaignNewsFileService } from './campaign-news-file.service' +import { S3Service } from '../s3/s3.service' +import { PersonService } from '../person/person.service' +import { MockPrismaService } from '../prisma/prisma-client.mock' +import { CampaignService } from '../campaign/campaign.service' +import { VaultService } from '../vault/vault.service' +import { KeycloakTokenParsed } from '../auth/keycloak' + +describe('CampaignFileController', () => { + let controller: CampaignNewsFileController + let campaignNewsFileService: CampaignNewsFileService + let personService: PersonService + + const personIdMock = 'testPersonId' + const fileId = 'fileId' + const articleId = 'testArticleId' + const userMock = { + sub: 'testKeycloackId', + resource_access: { account: { roles: [] } }, + 'allowed-origins': [], + } as KeycloakTokenParsed + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CampaignNewsFileController], + providers: [ + { + provide: CampaignNewsFileService, + useValue: { create: jest.fn(() => fileId) }, + }, + MockPrismaService, + S3Service, + { + provide: PersonService, + useValue: { findOneByKeycloakId: jest.fn(() => ({ id: personIdMock })) }, + }, + ConfigService, + { + provide: CampaignService, + useValue: { getCampaignByIdAndCoordinatorId: jest.fn(() => null) }, + }, + VaultService, + ], + }).compile() + + controller = module.get(CampaignNewsFileController) + campaignNewsFileService = module.get(CampaignNewsFileService) + personService = module.get(PersonService) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + it('should call service for create campaign file for admin user', async () => { + const files = [ + { mimetype: 'jpg', originalname: 'testName1', buffer: Buffer.from('') }, + { mimetype: 'jpg', originalname: 'testName2', buffer: Buffer.from('') }, + ] as Express.Multer.File[] + + expect( + await controller.create(articleId, { roles: ['background'] }, files, { + ...userMock, + ...{ resource_access: { account: { roles: ['account-view-supporters'] } } }, + }), + ).toEqual([fileId, fileId]) + + expect(personService.findOneByKeycloakId).toHaveBeenCalledWith(userMock.sub) + expect(campaignNewsFileService.create).toHaveBeenCalledTimes(2) + }) + + it('should throw an error for missing person', async () => { + jest.spyOn(personService, 'findOneByKeycloakId').mockResolvedValue(null) + + await expect(controller.create(articleId, { roles: [] }, [], userMock)).rejects.toThrowError() + + expect(personService.findOneByKeycloakId).toHaveBeenCalledWith(userMock.sub) + }) + + it('should throw an error for user not having access', async () => { + await expect(controller.create(articleId, { roles: [] }, [], userMock)).rejects.toThrowError() + + expect(personService.findOneByKeycloakId).toHaveBeenCalledWith(userMock.sub) + }) +}) diff --git a/apps/api/src/campaign-news-file/campaign-news-file.controller.ts b/apps/api/src/campaign-news-file/campaign-news-file.controller.ts new file mode 100644 index 000000000..d5acbda8b --- /dev/null +++ b/apps/api/src/campaign-news-file/campaign-news-file.controller.ts @@ -0,0 +1,94 @@ +import 'multer' +import { + Controller, + Get, + Post, + Response, + Param, + Delete, + StreamableFile, + NotFoundException, + Logger, + Body, + Inject, + forwardRef, + ForbiddenException, +} from '@nestjs/common' +import { FilesInterceptor } from '@nestjs/platform-express' +import { UseInterceptors, UploadedFiles } from '@nestjs/common' +import { RoleMatchingMode, Roles } from 'nest-keycloak-connect' +import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' +import { Public, AuthenticatedUser } from 'nest-keycloak-connect' +import { PersonService } from '../person/person.service' +import { FilesRoleDto } from './dto/files-role.dto' +import { CampaignNewsFileService } from './campaign-news-file.service' +import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak' +import { ApiTags } from '@nestjs/swagger' + +@ApiTags('campaign-news-file') +@Controller('campaign-news-file') +export class CampaignNewsFileController { + constructor( + private readonly campaignFileService: CampaignNewsFileService, + @Inject(forwardRef(() => PersonService)) private readonly personService: PersonService, + ) {} + + @Post(':article_id') + @UseInterceptors(FilesInterceptor('file', 10, { limits: { fileSize: 20485760 } })) //limit uploaded files to 5 at once and 10MB each + async create( + @Param('article_id') articleId: string, + @Body() body: FilesRoleDto, + @UploadedFiles() files: Express.Multer.File[], + @AuthenticatedUser() user: KeycloakTokenParsed, + ) { + const keycloakId = user.sub + const person = await this.personService.findOneByKeycloakId(keycloakId) + if (!person) { + Logger.warn('No person record with keycloak ID: ' + keycloakId) + throw new NotFoundException('No person record with keycloak ID: ' + keycloakId) + } + + if (!isAdmin(user)) { + throw new ForbiddenException('User has no access to this operation.') + } + + const filesRole = body.roles + return await Promise.all( + files.map((file, key) => { + return this.campaignFileService.create( + Array.isArray(filesRole) ? filesRole[key] : filesRole, + articleId, + file.mimetype, + file.originalname, + person, + file.buffer, + ) + }), + ) + } + + @Get(':id') + @Public() + async findOne( + @Param('id') id: string, + @Response({ passthrough: true }) res, + ): Promise { + const file = await this.campaignFileService.findOne(id) + res.set({ + 'Content-Type': file.mimetype, + 'Content-Disposition': 'inline; filename="' + file.filename + '"', + }) + + return new StreamableFile(file.stream) + } + + @Delete(':id') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + remove(@Param('id') id: string) { + console.log(` called`) + return this.campaignFileService.remove(id) + } +} diff --git a/apps/api/src/campaign-news-file/campaign-news-file.module.ts b/apps/api/src/campaign-news-file/campaign-news-file.module.ts new file mode 100644 index 000000000..e9233c4e4 --- /dev/null +++ b/apps/api/src/campaign-news-file/campaign-news-file.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' +import { CampaignNewsFileService } from './campaign-news-file.service' +import { CampaignNewsFileController } from './campaign-news-file.controller' +import { PrismaService } from '../prisma/prisma.service' +import { S3Service } from '../s3/s3.service' +import { PersonService } from '../person/person.service' +import { CampaignService } from '../campaign/campaign.service' +import { VaultService } from '../vault/vault.service' +import { NotificationModule } from '../sockets/notifications/notification.module' + +@Module({ + imports: [NotificationModule], + + controllers: [CampaignNewsFileController], + providers: [ + CampaignNewsFileService, + PrismaService, + S3Service, + PersonService, + CampaignService, + VaultService, + ], +}) +export class CampaignNewsFileModule {} diff --git a/apps/api/src/campaign-news-file/campaign-news-file.service.spec.ts b/apps/api/src/campaign-news-file/campaign-news-file.service.spec.ts new file mode 100644 index 000000000..5b85711e9 --- /dev/null +++ b/apps/api/src/campaign-news-file/campaign-news-file.service.spec.ts @@ -0,0 +1,28 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { ConfigService } from '@nestjs/config' +import { CampaignNewsFileService } from './campaign-news-file.service' +import { S3Service } from '../s3/s3.service' +import { PersonService } from '../person/person.service' +import { MockPrismaService } from '../prisma/prisma-client.mock' + +describe('CampaignFileService', () => { + let service: CampaignNewsFileService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CampaignNewsFileService, + MockPrismaService, + S3Service, + PersonService, + ConfigService, + ], + }).compile() + + service = module.get(CampaignNewsFileService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/api/src/campaign-news-file/campaign-news-file.service.ts b/apps/api/src/campaign-news-file/campaign-news-file.service.ts new file mode 100644 index 000000000..250861968 --- /dev/null +++ b/apps/api/src/campaign-news-file/campaign-news-file.service.ts @@ -0,0 +1,68 @@ +import { Readable } from 'stream' +import { Injectable, Logger, NotFoundException } from '@nestjs/common' +import { CampaignFile, CampaignFileRole, Person } from '@prisma/client' + +import { S3Service } from '../s3/s3.service' +import { PrismaService } from '../prisma/prisma.service' +import { CreateCampaignNewsFileDto } from './dto/create-campaign-news-file.dto' + +@Injectable() +export class CampaignNewsFileService { + private readonly bucketName: string = 'campaign-news-files' + constructor(private prisma: PrismaService, private s3: S3Service) {} + + async create( + role: CampaignFileRole, + articleId: string, + mimetype: string, + filename: string, + person: Person, + buffer: Buffer, + ): Promise { + const file: CreateCampaignNewsFileDto = { + filename, + mimetype, + role, + articleId, + personId: person.id, + } + const dbFile = await this.prisma.campaignNewsFile.create({ data: file }) + + // Use the DB primary key as the S3 key. This will make sure it is always unique. + await this.s3.uploadObject( + this.bucketName, + dbFile.id, + encodeURIComponent(filename), + mimetype, + buffer, + 'NewsArticle', + articleId, + person.id, + ) + + return dbFile.id + } + + async findOne(id: string): Promise<{ + filename: CampaignFile['filename'] + mimetype: CampaignFile['mimetype'] + stream: Readable + }> { + const file = await this.prisma.campaignNewsFile.findFirst({ where: { id: id } }) + if (!file) { + Logger.warn('No campaign file record with ID: ' + id) + throw new NotFoundException('No campaign file record with ID: ' + id) + } + return { + filename: encodeURIComponent(file.filename), + mimetype: file.mimetype, + stream: await this.s3.streamFile(this.bucketName, id), + } + } + + async remove(id: string) { + await this.s3.deleteObject(this.bucketName, id) + return await this.prisma.campaignNewsFile.delete({ where: { id } }) + + } +} diff --git a/apps/api/src/campaign-news-file/dto/create-campaign-news-file.dto.ts b/apps/api/src/campaign-news-file/dto/create-campaign-news-file.dto.ts new file mode 100644 index 000000000..a5c36c70c --- /dev/null +++ b/apps/api/src/campaign-news-file/dto/create-campaign-news-file.dto.ts @@ -0,0 +1,9 @@ +import { CampaignFileRole } from '@prisma/client' + +export class CreateCampaignNewsFileDto { + filename: string + mimetype: string + articleId: string + personId: string + role: CampaignFileRole +} diff --git a/apps/api/src/campaign-news-file/dto/files-role.dto.ts b/apps/api/src/campaign-news-file/dto/files-role.dto.ts new file mode 100644 index 000000000..0c065ddf7 --- /dev/null +++ b/apps/api/src/campaign-news-file/dto/files-role.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger' +import { CampaignFileRole } from '@prisma/client' +import { Expose } from 'class-transformer' +import { IsEnum } from 'class-validator' + +export class FilesRoleDto { + @ApiProperty() + @Expose() + @IsEnum(CampaignFileRole, { each: true }) + roles: CampaignFileRole[] +} From 89e6df0ff5cf89d72af808f0f4408e776d00beb8 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 7 Jun 2023 17:15:28 +0300 Subject: [PATCH 06/27] campaign-news: Include article files in response --- apps/api/src/campaign-news/campaign-news.service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/api/src/campaign-news/campaign-news.service.ts b/apps/api/src/campaign-news/campaign-news.service.ts index f95136b0c..a796bef5a 100644 --- a/apps/api/src/campaign-news/campaign-news.service.ts +++ b/apps/api/src/campaign-news/campaign-news.service.ts @@ -33,6 +33,7 @@ export class CampaignNewsService { author: true, publishedAt: true, description: true, + articleFiles: true, campaign: { select: { title: true, @@ -63,6 +64,9 @@ export class CampaignNewsService { const article = await this.prisma.campaignNews .findFirst({ where: { id: articleId }, + include: { + articleFiles: true + } }) .catch((error) => Logger.warn(error)) return article @@ -82,6 +86,7 @@ export class CampaignNewsService { publishedAt: true, author: true, description: true, + articleFiles: true, campaign: { select: { title: true, From c6eb27b0d2b6ce0c44107c02c56af07a3d7fa606 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 7 Jun 2023 17:31:03 +0300 Subject: [PATCH 07/27] src/campaign: Include the latest 2 news as part of the slug endpoint response --- apps/api/src/campaign/campaign.service.ts | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 2619423d4..1a5fadf55 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -7,6 +7,7 @@ import { DonationStatus, DonationType, Vault, + CampaignFileRole, } from '@prisma/client' import { forwardRef, @@ -318,6 +319,7 @@ export class CampaignService { const campaignSums = await this.getCampaignSums([campaign.id]) campaign['summary'] = this.getVaultAndDonationSummaries(campaign.id, campaignSums) + campaign['news'] = await this.getCampaignNews(campaign.id) const vault = await this.getCampaignVault(campaign.id) if (vault) { @@ -762,4 +764,31 @@ export class CampaignService { donors: csum?.donors || 0, } } + + async getCampaignNews(campaignId: string) { + const articles = await this.prisma.campaignNews.findMany({ + where: { campaignId: campaignId }, + take: 2, + orderBy: { publishedAt: 'asc' }, + include: { + articleFiles: { + where: { + OR: [ + { role: CampaignFileRole.invoice }, + { role: CampaignFileRole.document }, + { role: CampaignFileRole.campaignPhoto }, + {role: CampaignFileRole.gallery} + ], + }, + select: { + id: true, + filename: true, + role: true, + }, + }, + }, + }) + + return articles + } } From b335e3b1c69569d4cb39b727592dfcf90e6d4165 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 7 Jun 2023 17:39:09 +0300 Subject: [PATCH 08/27] prisma: Seed campaign news -Seed 5 drafts -Seed 5 published news --- db/seed/campaignNews/factory.ts | 19 +++++++++ db/seed/campaignNews/seed.ts | 76 +++++++++++++++++++++++++++++++++ db/seed/index.ts | 2 + 3 files changed, 97 insertions(+) create mode 100644 db/seed/campaignNews/factory.ts create mode 100644 db/seed/campaignNews/seed.ts diff --git a/db/seed/campaignNews/factory.ts b/db/seed/campaignNews/factory.ts new file mode 100644 index 000000000..8c5e5d550 --- /dev/null +++ b/db/seed/campaignNews/factory.ts @@ -0,0 +1,19 @@ +import { Factory } from 'fishery' +import { faker } from '@faker-js/faker' + +import { CampaignNews, CampaignNewsState } from '@prisma/client' + +export const campaignNewsFactory = Factory.define(({ associations }) => ({ + id: faker.datatype.uuid(), + campaignId: associations.campaignId || faker.datatype.uuid(), + slug: faker.lorem.slug(), + title: faker.lorem.sentence(3), + publisherId: associations.publisherId || faker.datatype.uuid(), + author: faker.name.fullName(), + state: faker.helpers.arrayElement(Object.values(CampaignNewsState)), + createdAt: faker.date.soon(3), + publishedAt: faker.date.soon(3), + editedAt: faker.date.recent(), + sourceLink: faker.internet.domainName(), + description: faker.lorem.paragraph(30), +})) diff --git a/db/seed/campaignNews/seed.ts b/db/seed/campaignNews/seed.ts new file mode 100644 index 000000000..3c5083c32 --- /dev/null +++ b/db/seed/campaignNews/seed.ts @@ -0,0 +1,76 @@ +import { Campaign, CampaignNewsState, Person, PrismaClient } from '@prisma/client' +import { campaignNewsFactory } from './factory' + +const prisma = new PrismaClient() + +const SEED_PUBLISHED_NEWS = 5 +const SEED_DRAFT_NEWS = 5 + +interface SeedData { + campaign: Campaign + person: Person +} + +export async function campaignNewsSeed() { + console.log(`Campaign news seed`) + + const campaign = await prisma.campaign.findFirst() + const person = await prisma.person.findFirst({ where: { email: 'admin@podkrepi.bg' } }) + + if (!campaign) { + throw new Error('Campaign not found') + } + + if (!person?.keycloakId) { + throw new Error('Person not found') + } + + await seedDraftNews({ campaign: campaign, person: person }) + await seedPublishedNews({ campaign: campaign, person: person }) +} + +async function seedPublishedNews({ campaign, person }: SeedData) { + const campaignNewsData = campaignNewsFactory.buildList( + SEED_PUBLISHED_NEWS, + { + state: CampaignNewsState.published, + editedAt: undefined, + }, + { + associations: { + publisherId: person.id, + campaignId: campaign.id, + }, + }, + ) + + const insertPublishedNews = await prisma.campaignNews.createMany({ + data: campaignNewsData, + skipDuplicates: true, + }) + + console.log({ insertPublishedNews }) +} + +async function seedDraftNews({ campaign, person }: SeedData) { + const campaignNewsData = campaignNewsFactory.buildList( + SEED_DRAFT_NEWS, + { + state: CampaignNewsState.draft, + publishedAt: undefined, + }, + { + associations: { + publisherId: person.id, + campaignId: campaign.id, + }, + }, + ) + + const insertDraftNews = await prisma.campaignNews.createMany({ + data: campaignNewsData, + skipDuplicates: true, + }) + + console.log({ insertDraftNews }) +} diff --git a/db/seed/index.ts b/db/seed/index.ts index 94a83465f..7d8dab554 100644 --- a/db/seed/index.ts +++ b/db/seed/index.ts @@ -16,6 +16,7 @@ import { expenseSeed } from './expense/seed' import { donationsSeed } from './donation/seed' import { companySeed } from './company/seed' import { donationsWishesSeed } from './donationWish/seed' +import { campaignNewsSeed } from './campaignNews/seed' const prisma = new PrismaClient() @@ -54,6 +55,7 @@ async function seedDevData() { await expenseSeed() await donationsSeed() await donationsWishesSeed() + await campaignNewsSeed() } } From 8c0ff5b1a714ce7570090185b6b14da9ef2c31c2 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 7 Jun 2023 18:35:23 +0300 Subject: [PATCH 09/27] src/campaign-news-file: Encode UTF-8 filename --- .../api/src/campaign-news-file/campaign-news-file.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/campaign-news-file/campaign-news-file.controller.ts b/apps/api/src/campaign-news-file/campaign-news-file.controller.ts index d5acbda8b..cfd62a453 100644 --- a/apps/api/src/campaign-news-file/campaign-news-file.controller.ts +++ b/apps/api/src/campaign-news-file/campaign-news-file.controller.ts @@ -59,7 +59,7 @@ export class CampaignNewsFileController { Array.isArray(filesRole) ? filesRole[key] : filesRole, articleId, file.mimetype, - file.originalname, + Buffer.from(file.originalname, 'latin1').toString('utf-8'), person, file.buffer, ) From 2035547a45989b5e4c988fc7774efa89d756d0c5 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 10 Jun 2023 15:18:54 +0300 Subject: [PATCH 10/27] Get news for specific campaign via the campaignNews relation On the frontend side the campaign title is needed when visualizing the news for specific campaign. This change prevents unexpected behavior such as the news page crashing in case no news were found for the specific campaign, or user has visited the news page via invalid campaign slug --- .../campaign-news/campaign-news.service.ts | 63 ++++++++++--------- apps/api/src/campaign/campaign.controller.ts | 2 +- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/apps/api/src/campaign-news/campaign-news.service.ts b/apps/api/src/campaign-news/campaign-news.service.ts index a796bef5a..32344ea9e 100644 --- a/apps/api/src/campaign-news/campaign-news.service.ts +++ b/apps/api/src/campaign-news/campaign-news.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, BadRequestException } from '@nestjs/common' +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common' import { CreateCampaignNewsDto } from './dto/create-campaign-news.dto' import { PrismaService } from '../prisma/prisma.service' import { UpdateCampaignNewsDto } from './dto/update-campaign-news.dto' @@ -50,7 +50,9 @@ export class CampaignNewsService { const totalPages = Math.ceil(totalRecords / this.RECORDS_PER_PAGE) return { - articles: articles, + campaign: { + campaignNews: articles + }, pagination: { currentPage: currentPage, nextPage: currentPage === totalPages ? currentPage : currentPage + 1, @@ -72,39 +74,41 @@ export class CampaignNewsService { return article } - async findArticlesByCampaignSlug(slug: string, currentPage: number) { - const [articles, totalRecords] = await this.prisma.$transaction([ - this.prisma.campaignNews.findMany({ - where: { campaign: { slug: slug }, state: CampaignNewsState.published }, - orderBy: { publishedAt: 'desc' }, - take: this.RECORDS_PER_PAGE, - skip: Number((currentPage - 1) * this.RECORDS_PER_PAGE), - select: { - id: true, - title: true, - slug: true, - publishedAt: true, - author: true, - description: true, - articleFiles: true, - campaign: { - select: { + async findArticlesByCampaignSlugWithPagination(slug: string, currentPage: number) { + const [campaign, totalRecords] = await this.prisma.$transaction([ + this.prisma.campaign.findFirst({ + where: {slug}, + select: { + title: true, + slug: true, + campaignNews: { + where: {state: CampaignNewsState.published}, + orderBy: {publishedAt: 'desc'}, + take: this.RECORDS_PER_PAGE, + skip: Number((currentPage - 1) * this.RECORDS_PER_PAGE), + select: { + id: true, title: true, - state: true, slug: true, - }, - }, - }, - }), - this.prisma.campaignNews.count({ - where: { campaign: { slug: slug }, state: CampaignNewsState.published }, - }), - ]) + publishedAt: true, + author: true, + description: true, + articleFiles: true, + } + } + } + }), + this.prisma.campaignNews.count({ + where: { campaign: { slug: slug }, state: CampaignNewsState.published }, + }), + ]) + + if(!campaign ) throw new NotFoundException("No news were found for the selected campaign") const totalPages = Math.ceil(totalRecords / this.RECORDS_PER_PAGE) return { - articles: articles, + campaign, pagination: { currentPage: currentPage, nextPage: currentPage === totalPages ? currentPage : currentPage + 1, @@ -158,7 +162,6 @@ export class CampaignNewsService { async deleteArticle(articleId: string) { try { const test = await this.prisma.campaignNews.delete({ where: { id: articleId } }) - console.log(test) return test } catch (error) { const message = 'Deleting news article has failed!' diff --git a/apps/api/src/campaign/campaign.controller.ts b/apps/api/src/campaign/campaign.controller.ts index 2efd450e2..9e766a11b 100644 --- a/apps/api/src/campaign/campaign.controller.ts +++ b/apps/api/src/campaign/campaign.controller.ts @@ -83,7 +83,7 @@ export class CampaignController { @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Param(`slug`) slug: string, ) { - return this.campaignNewsService.findArticlesByCampaignSlug(slug, page) + return this.campaignNewsService.findArticlesByCampaignSlugWithPagination(slug, page) } @Post('create-campaign') From 67e7da8d855b9031d845d107d127d58e7cce89a6 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 10 Jun 2023 19:38:21 +0300 Subject: [PATCH 11/27] campaign-news: Allow for campaign's organizer to create/edit/delete a news article --- .../campaign-news/campaign-news.controller.ts | 56 ++++++++++++++----- .../campaign-news/campaign-news.service.ts | 6 ++ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/apps/api/src/campaign-news/campaign-news.controller.ts b/apps/api/src/campaign-news/campaign-news.controller.ts index c4fc2a51c..d631a9432 100644 --- a/apps/api/src/campaign-news/campaign-news.controller.ts +++ b/apps/api/src/campaign-news/campaign-news.controller.ts @@ -19,6 +19,7 @@ import { CampaignNewsService } from './campaign-news.service' import { UpdateCampaignNewsDto } from './dto/update-campaign-news.dto' import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak' import { PersonService } from '../person/person.service' +import { CampaignNewsState } from '@prisma/client' @ApiTags('campaign-news') @Controller('campaign-news') @@ -30,18 +31,20 @@ export class CampaignNewsController { @Post() @Roles({ - roles: [RealmViewSupporters.role, ViewSupporters.role], + roles: [], mode: RoleMatchingMode.ANY, }) async create( - @Body() CreateCampaignNewsDto: CreateCampaignNewsDto, + @Body() createCampaignNewsDto: CreateCampaignNewsDto, @AuthenticatedUser() user: KeycloakTokenParsed, ) { - if (!isAdmin(user)) { - throw new ForbiddenException( - 'The user is not coordinator,organizer or beneficiery to the requested campaign', - ) + + const isCampaignOrganizer = await this.campaignNewsService.canCreateArticle(createCampaignNewsDto.campaignId, user.sub); + + if(!isCampaignOrganizer && !isAdmin(user)) { + throw new ForbiddenException('The user is not coordinator,organizer or beneficiery to the requested campaign') } + const person = await this.personService.findOneByKeycloakId(user.sub) if (!person) { @@ -49,7 +52,7 @@ export class CampaignNewsController { } return await this.campaignNewsService.createDraft({ - ...CreateCampaignNewsDto, + ...createCampaignNewsDto, publisherId: person.id, }) } @@ -77,7 +80,7 @@ export class CampaignNewsController { @Put(':id') @Roles({ - roles: [RealmViewSupporters.role, ViewSupporters.role], + roles: [], mode: RoleMatchingMode.ANY, }) async editArticle( @@ -85,15 +88,24 @@ export class CampaignNewsController { @Body() updateCampaignNewsDto: UpdateCampaignNewsDto, @AuthenticatedUser() user: KeycloakTokenParsed, ) { - if (!isAdmin(user)) { - throw new Error('The user has no access to edit this article') - } const article = await this.campaignNewsService.findArticleByID(articleId) - if (!article) { throw new NotFoundException('Article not found') } + const publisher = await this.personService.findOneByKeycloakId(user.sub); + if(!publisher) { + throw new NotFoundException('Author was not found') + }; + + if((article.state === CampaignNewsState.draft && publisher.id !== article.publisherId) && !isAdmin(user)){ + throw new ForbiddenException('The user has no access to delete this article') + } + + if(article.state === CampaignNewsState.published && !isAdmin(user)){ + throw new ForbiddenException("User has no access to edit this article") + } + return await this.campaignNewsService.editArticle( articleId, article.state, @@ -103,13 +115,27 @@ export class CampaignNewsController { @Delete(':id') @Roles({ - roles: [RealmViewSupporters.role, ViewSupporters.role], + roles: [], mode: RoleMatchingMode.ANY, }) async delete(@Param('id') articleId: string, @AuthenticatedUser() user: KeycloakTokenParsed) { - if (!isAdmin(user)) { - throw new Error('The user has no access to delete this article') + const article = await this.campaignNewsService.findArticleByID(articleId); + if(!article) throw new NotFoundException('Article not found') + + const publisher = await this.personService.findOneByKeycloakId(user.sub); + + if(!publisher) { + throw new NotFoundException('Author was not found') + }; + + if((article.state === CampaignNewsState.draft && publisher.id !== article.publisherId) && !isAdmin(user)){ + throw new ForbiddenException('The user has no access to delete this article') + } + + if (article.state === CampaignNewsState.published && !isAdmin(user)) { + throw new ForbiddenException('The user has no access to delete this article') } + return await this.campaignNewsService.deleteArticle(articleId) } } diff --git a/apps/api/src/campaign-news/campaign-news.service.ts b/apps/api/src/campaign-news/campaign-news.service.ts index 32344ea9e..8668bec5d 100644 --- a/apps/api/src/campaign-news/campaign-news.service.ts +++ b/apps/api/src/campaign-news/campaign-news.service.ts @@ -19,6 +19,12 @@ export class CampaignNewsService { } } + + async canCreateArticle(campaignId: string, keycloakId:string) { + const canEdit = await this.prisma.campaign.findFirst({where: {id: campaignId, organizer: {person: {keycloakId}}}}) + return !!canEdit + } + async listPublishedNewsWithPagination(currentPage: number) { const [articles, totalRecords] = await this.prisma.$transaction([ this.prisma.campaignNews.findMany({ From 55b565843c7843cd0ef1b1eb9a38b8c94f0f7c23 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 10 Jun 2023 19:44:20 +0300 Subject: [PATCH 12/27] campaign-news: List all news for specific campaign --- .../campaign-news/campaign-news.controller.ts | 9 ++++++++ .../campaign-news/campaign-news.service.ts | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/api/src/campaign-news/campaign-news.controller.ts b/apps/api/src/campaign-news/campaign-news.controller.ts index d631a9432..55caf97ff 100644 --- a/apps/api/src/campaign-news/campaign-news.controller.ts +++ b/apps/api/src/campaign-news/campaign-news.controller.ts @@ -57,6 +57,15 @@ export class CampaignNewsController { }) } + @Get(':campaignSlug/list') + @Public() + async listNewsByCampaignSlug( + @Param('campaignSlug') campaignSlug: string, + ) { + const news = await this.campaignNewsService.listAdminArticles(campaignSlug) + return news + } + @Get('list-all') @Roles({ roles: [RealmViewSupporters.role, ViewSupporters.role], diff --git a/apps/api/src/campaign-news/campaign-news.service.ts b/apps/api/src/campaign-news/campaign-news.service.ts index 8668bec5d..9d3b6c9d7 100644 --- a/apps/api/src/campaign-news/campaign-news.service.ts +++ b/apps/api/src/campaign-news/campaign-news.service.ts @@ -130,6 +130,27 @@ export class CampaignNewsService { .catch((error) => Logger.warn(error)) } + async listAdminArticles(campaignSlug: string) { + return await this.prisma.campaign.findFirst({ + where: {slug: campaignSlug}, + select: { + id: true, + title: true, + campaignNews: { + select: { + id: true, + title: true, + author: true, + createdAt: true, + publishedAt: true, + editedAt: true, + state: true, + } + } + } + }) + } + async editArticle(id: string, state: CampaignNewsState, editArticleDto: UpdateCampaignNewsDto) { try { return await this.prisma.campaignNews.update({ From 26e0a660b7a4acbcf301d231f5a3332fd3df840b Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 10 Jun 2023 19:59:59 +0300 Subject: [PATCH 13/27] src/campaign: Add new endpoint to check whether logged user is campaign's organizer --- apps/api/src/campaign/campaign.controller.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/api/src/campaign/campaign.controller.ts b/apps/api/src/campaign/campaign.controller.ts index 9e766a11b..4945138c6 100644 --- a/apps/api/src/campaign/campaign.controller.ts +++ b/apps/api/src/campaign/campaign.controller.ts @@ -77,6 +77,16 @@ export class CampaignController { return { campaign } } + @Get(':slug/:keycloakId/can-edit') + @Public() + async canEditCampaign( + @Param('slug') slug: string, + @Param('keycloakId') keycloakId: string, + ): Promise { + const campaign = await this.campaignService.isUserCampaign(keycloakId, slug) + return campaign + } + @Get(':slug/news') @Public() async listNewsForSingleCampaign( From b951cf8837bbe94add3eafbd8bce2ef5de4623f6 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 10 Jun 2023 20:37:37 +0300 Subject: [PATCH 14/27] src/campaign: Fix news timeline on campaign page -Change the key to `campaignNews` to correctly represent the name of the relation -Take the latest two published articles for the campaign --- apps/api/src/campaign/campaign.service.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 1a5fadf55..b37325d42 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -8,6 +8,7 @@ import { DonationType, Vault, CampaignFileRole, + CampaignNewsState, } from '@prisma/client' import { forwardRef, @@ -319,7 +320,7 @@ export class CampaignService { const campaignSums = await this.getCampaignSums([campaign.id]) campaign['summary'] = this.getVaultAndDonationSummaries(campaign.id, campaignSums) - campaign['news'] = await this.getCampaignNews(campaign.id) + campaign['campaignNews'] = await this.getCampaignNews(campaign.id) const vault = await this.getCampaignVault(campaign.id) if (vault) { @@ -767,9 +768,9 @@ export class CampaignService { async getCampaignNews(campaignId: string) { const articles = await this.prisma.campaignNews.findMany({ - where: { campaignId: campaignId }, + where: { campaignId: campaignId, state: CampaignNewsState.published }, take: 2, - orderBy: { publishedAt: 'asc' }, + orderBy: { publishedAt: 'desc' }, include: { articleFiles: { where: { From d8938a51ca184c24cd52d9dedb5f13972090474d Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 14 Jun 2023 02:59:04 +0300 Subject: [PATCH 15/27] campaign-news: Include articleFile relation when slug endpoint is reached -Needed to accomodate the latest frontend changes for SingleArticlePage -Include article slug in listAdmin `:campaignSlug/list` endpoint --- apps/api/src/campaign-news/campaign-news.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/campaign-news/campaign-news.service.ts b/apps/api/src/campaign-news/campaign-news.service.ts index 9d3b6c9d7..d89f714bd 100644 --- a/apps/api/src/campaign-news/campaign-news.service.ts +++ b/apps/api/src/campaign-news/campaign-news.service.ts @@ -126,7 +126,7 @@ export class CampaignNewsService { async findArticleBySlug(slug: string) { return await this.prisma.campaignNews - .findFirst({ where: { slug: slug } }) + .findFirst({ where: { slug: slug }, include: {articleFiles: true} }) .catch((error) => Logger.warn(error)) } @@ -141,6 +141,7 @@ export class CampaignNewsService { id: true, title: true, author: true, + slug: true, createdAt: true, publishedAt: true, editedAt: true, From 2a987d0f9799463e4371b9349337b6e9e7a7625a Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 18 Jun 2023 14:20:56 +0300 Subject: [PATCH 16/27] prisma: Rename articleFiles to newsFiles --- .../campaign-news-file.service.ts | 7 +- .../dto/create-campaign-news-file.dto.ts | 2 +- .../campaign-news/campaign-news.controller.ts | 48 ++++++---- .../campaign-news/campaign-news.service.ts | 92 ++++++++++--------- apps/api/src/campaign/campaign.controller.ts | 11 ++- apps/api/src/campaign/campaign.module.ts | 2 + apps/api/src/campaign/campaign.service.ts | 9 +- .../dto/connect-campaignNews.dto.ts | 8 +- .../dto/create-campaignNews.dto.ts | 20 ++-- .../generated/campaignNews/dto/index.ts | 7 +- .../dto/update-campaignNews.dto.ts | 20 ++-- .../entities/campaignNews.entity.ts | 38 ++++---- .../generated/campaignNews/entities/index.ts | 3 +- .../dto/connect-campaignNewsFile.dto.ts | 8 +- .../dto/create-campaignNewsFile.dto.ts | 16 ++-- .../generated/campaignNewsFile/dto/index.ts | 7 +- .../dto/update-campaignNewsFile.dto.ts | 16 ++-- .../entities/campaignNewsFile.entity.ts | 24 +++-- .../campaignNewsFile/entities/index.ts | 3 +- .../migration.sql | 16 ++++ schema.prisma | 48 +++++----- 21 files changed, 202 insertions(+), 203 deletions(-) create mode 100644 migrations/20230618101516_rename_campaign_news_fields/migration.sql diff --git a/apps/api/src/campaign-news-file/campaign-news-file.service.ts b/apps/api/src/campaign-news-file/campaign-news-file.service.ts index 250861968..9911c3d65 100644 --- a/apps/api/src/campaign-news-file/campaign-news-file.service.ts +++ b/apps/api/src/campaign-news-file/campaign-news-file.service.ts @@ -13,7 +13,7 @@ export class CampaignNewsFileService { async create( role: CampaignFileRole, - articleId: string, + newsId: string, mimetype: string, filename: string, person: Person, @@ -23,7 +23,7 @@ export class CampaignNewsFileService { filename, mimetype, role, - articleId, + newsId, personId: person.id, } const dbFile = await this.prisma.campaignNewsFile.create({ data: file }) @@ -36,7 +36,7 @@ export class CampaignNewsFileService { mimetype, buffer, 'NewsArticle', - articleId, + newsId, person.id, ) @@ -63,6 +63,5 @@ export class CampaignNewsFileService { async remove(id: string) { await this.s3.deleteObject(this.bucketName, id) return await this.prisma.campaignNewsFile.delete({ where: { id } }) - } } diff --git a/apps/api/src/campaign-news-file/dto/create-campaign-news-file.dto.ts b/apps/api/src/campaign-news-file/dto/create-campaign-news-file.dto.ts index a5c36c70c..dc1c4f9fb 100644 --- a/apps/api/src/campaign-news-file/dto/create-campaign-news-file.dto.ts +++ b/apps/api/src/campaign-news-file/dto/create-campaign-news-file.dto.ts @@ -3,7 +3,7 @@ import { CampaignFileRole } from '@prisma/client' export class CreateCampaignNewsFileDto { filename: string mimetype: string - articleId: string + newsId: string personId: string role: CampaignFileRole } diff --git a/apps/api/src/campaign-news/campaign-news.controller.ts b/apps/api/src/campaign-news/campaign-news.controller.ts index 55caf97ff..19ce6e930 100644 --- a/apps/api/src/campaign-news/campaign-news.controller.ts +++ b/apps/api/src/campaign-news/campaign-news.controller.ts @@ -38,11 +38,15 @@ export class CampaignNewsController { @Body() createCampaignNewsDto: CreateCampaignNewsDto, @AuthenticatedUser() user: KeycloakTokenParsed, ) { + const isCampaignOrganizer = await this.campaignNewsService.canCreateArticle( + createCampaignNewsDto.campaignId, + user.sub, + ) - const isCampaignOrganizer = await this.campaignNewsService.canCreateArticle(createCampaignNewsDto.campaignId, user.sub); - - if(!isCampaignOrganizer && !isAdmin(user)) { - throw new ForbiddenException('The user is not coordinator,organizer or beneficiery to the requested campaign') + if (!isCampaignOrganizer && !isAdmin(user)) { + throw new ForbiddenException( + 'The user is not coordinator,organizer or beneficiery to the requested campaign', + ) } const person = await this.personService.findOneByKeycloakId(user.sub) @@ -59,9 +63,7 @@ export class CampaignNewsController { @Get(':campaignSlug/list') @Public() - async listNewsByCampaignSlug( - @Param('campaignSlug') campaignSlug: string, - ) { + async listNewsByCampaignSlug(@Param('campaignSlug') campaignSlug: string) { const news = await this.campaignNewsService.listAdminArticles(campaignSlug) return news } @@ -102,17 +104,21 @@ export class CampaignNewsController { throw new NotFoundException('Article not found') } - const publisher = await this.personService.findOneByKeycloakId(user.sub); - if(!publisher) { + const publisher = await this.personService.findOneByKeycloakId(user.sub) + if (!publisher) { throw new NotFoundException('Author was not found') - }; + } - if((article.state === CampaignNewsState.draft && publisher.id !== article.publisherId) && !isAdmin(user)){ + if ( + article.state === CampaignNewsState.draft && + publisher.id !== article.publisherId && + !isAdmin(user) + ) { throw new ForbiddenException('The user has no access to delete this article') } - if(article.state === CampaignNewsState.published && !isAdmin(user)){ - throw new ForbiddenException("User has no access to edit this article") + if (article.state === CampaignNewsState.published && !isAdmin(user)) { + throw new ForbiddenException('User has no access to edit this article') } return await this.campaignNewsService.editArticle( @@ -128,16 +134,20 @@ export class CampaignNewsController { mode: RoleMatchingMode.ANY, }) async delete(@Param('id') articleId: string, @AuthenticatedUser() user: KeycloakTokenParsed) { - const article = await this.campaignNewsService.findArticleByID(articleId); - if(!article) throw new NotFoundException('Article not found') + const article = await this.campaignNewsService.findArticleByID(articleId) + if (!article) throw new NotFoundException('Article not found') - const publisher = await this.personService.findOneByKeycloakId(user.sub); + const publisher = await this.personService.findOneByKeycloakId(user.sub) - if(!publisher) { + if (!publisher) { throw new NotFoundException('Author was not found') - }; + } - if((article.state === CampaignNewsState.draft && publisher.id !== article.publisherId) && !isAdmin(user)){ + if ( + article.state === CampaignNewsState.draft && + publisher.id !== article.publisherId && + !isAdmin(user) + ) { throw new ForbiddenException('The user has no access to delete this article') } diff --git a/apps/api/src/campaign-news/campaign-news.service.ts b/apps/api/src/campaign-news/campaign-news.service.ts index d89f714bd..ef9a6016e 100644 --- a/apps/api/src/campaign-news/campaign-news.service.ts +++ b/apps/api/src/campaign-news/campaign-news.service.ts @@ -19,9 +19,10 @@ export class CampaignNewsService { } } - - async canCreateArticle(campaignId: string, keycloakId:string) { - const canEdit = await this.prisma.campaign.findFirst({where: {id: campaignId, organizer: {person: {keycloakId}}}}) + async canCreateArticle(campaignId: string, keycloakId: string) { + const canEdit = await this.prisma.campaign.findFirst({ + where: { id: campaignId, organizer: { person: { keycloakId } } }, + }) return !!canEdit } @@ -39,7 +40,7 @@ export class CampaignNewsService { author: true, publishedAt: true, description: true, - articleFiles: true, + newsFiles: true, campaign: { select: { title: true, @@ -57,7 +58,7 @@ export class CampaignNewsService { return { campaign: { - campaignNews: articles + campaignNews: articles, }, pagination: { currentPage: currentPage, @@ -73,43 +74,43 @@ export class CampaignNewsService { .findFirst({ where: { id: articleId }, include: { - articleFiles: true - } + newsFiles: true, + }, }) .catch((error) => Logger.warn(error)) return article } async findArticlesByCampaignSlugWithPagination(slug: string, currentPage: number) { - const [campaign, totalRecords] = await this.prisma.$transaction([ - this.prisma.campaign.findFirst({ - where: {slug}, - select: { - title: true, - slug: true, - campaignNews: { - where: {state: CampaignNewsState.published}, - orderBy: {publishedAt: 'desc'}, - take: this.RECORDS_PER_PAGE, - skip: Number((currentPage - 1) * this.RECORDS_PER_PAGE), - select: { + const [campaign, totalRecords] = await this.prisma.$transaction([ + this.prisma.campaign.findFirst({ + where: { slug }, + select: { + title: true, + slug: true, + campaignNews: { + where: { state: CampaignNewsState.published }, + orderBy: { publishedAt: 'desc' }, + take: this.RECORDS_PER_PAGE, + skip: Number((currentPage - 1) * this.RECORDS_PER_PAGE), + select: { id: true, title: true, slug: true, publishedAt: true, author: true, description: true, - articleFiles: true, - } - } - } - }), - this.prisma.campaignNews.count({ - where: { campaign: { slug: slug }, state: CampaignNewsState.published }, - }), - ]) - - if(!campaign ) throw new NotFoundException("No news were found for the selected campaign") + newsFiles: true, + }, + }, + }, + }), + this.prisma.campaignNews.count({ + where: { campaign: { slug: slug }, state: CampaignNewsState.published }, + }), + ]) + + if (!campaign) throw new NotFoundException('No news were found for the selected campaign') const totalPages = Math.ceil(totalRecords / this.RECORDS_PER_PAGE) @@ -126,29 +127,29 @@ export class CampaignNewsService { async findArticleBySlug(slug: string) { return await this.prisma.campaignNews - .findFirst({ where: { slug: slug }, include: {articleFiles: true} }) + .findFirst({ where: { slug: slug }, include: { newsFiles: true } }) .catch((error) => Logger.warn(error)) } async listAdminArticles(campaignSlug: string) { return await this.prisma.campaign.findFirst({ - where: {slug: campaignSlug}, + where: { slug: campaignSlug }, select: { id: true, title: true, campaignNews: { - select: { - id: true, - title: true, - author: true, - slug: true, - createdAt: true, - publishedAt: true, - editedAt: true, - state: true, - } - } - } + select: { + id: true, + title: true, + author: true, + slug: true, + createdAt: true, + publishedAt: true, + editedAt: true, + state: true, + }, + }, + }, }) } @@ -160,7 +161,8 @@ export class CampaignNewsService { ...editArticleDto, editedAt: new Date(), publishedAt: - editArticleDto.state === CampaignNewsState.published && state === CampaignNewsState.draft + editArticleDto.state === CampaignNewsState.published && + state === CampaignNewsState.draft ? new Date() : editArticleDto.state === CampaignNewsState.draft ? null diff --git a/apps/api/src/campaign/campaign.controller.ts b/apps/api/src/campaign/campaign.controller.ts index 4945138c6..9612f3a55 100644 --- a/apps/api/src/campaign/campaign.controller.ts +++ b/apps/api/src/campaign/campaign.controller.ts @@ -36,7 +36,8 @@ export class CampaignController { constructor( private readonly campaignService: CampaignService, @Inject(forwardRef(() => PersonService)) private readonly personService: PersonService, - @Inject(forwardRef(() => CampaignNewsService)) private readonly campaignNewsService: CampaignNewsService + @Inject(forwardRef(() => CampaignNewsService)) + private readonly campaignNewsService: CampaignNewsService, ) {} @Get('list') @@ -76,15 +77,15 @@ export class CampaignController { const campaign = await this.campaignService.getCampaignBySlug(slug) return { campaign } } - + @Get(':slug/:keycloakId/can-edit') @Public() async canEditCampaign( @Param('slug') slug: string, @Param('keycloakId') keycloakId: string, - ): Promise { - const campaign = await this.campaignService.isUserCampaign(keycloakId, slug) - return campaign + ): Promise { + const campaign = await this.campaignService.isUserCampaign(keycloakId, slug) + return campaign } @Get(':slug/news') diff --git a/apps/api/src/campaign/campaign.module.ts b/apps/api/src/campaign/campaign.module.ts index b9744b1c2..046c58746 100644 --- a/apps/api/src/campaign/campaign.module.ts +++ b/apps/api/src/campaign/campaign.module.ts @@ -14,6 +14,8 @@ import { CampaignNewsService } from '../campaign-news/campaign-news.service' controllers: [CampaignController, CampaignTypeController], providers: [CampaignService, PrismaService, VaultService, PersonService, ConfigService, CampaignNewsService], + + exports: [CampaignService], }) export class CampaignModule {} diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index b37325d42..7e32a9a2e 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -772,13 +772,12 @@ export class CampaignService { take: 2, orderBy: { publishedAt: 'desc' }, include: { - articleFiles: { + newsFiles: { where: { OR: [ - { role: CampaignFileRole.invoice }, - { role: CampaignFileRole.document }, - { role: CampaignFileRole.campaignPhoto }, - {role: CampaignFileRole.gallery} + { role: CampaignFileRole.invoice }, + { role: CampaignFileRole.document }, + { role: CampaignFileRole.gallery }, ], }, select: { diff --git a/apps/api/src/domain/generated/campaignNews/dto/connect-campaignNews.dto.ts b/apps/api/src/domain/generated/campaignNews/dto/connect-campaignNews.dto.ts index f12e24daa..b1b11c63f 100644 --- a/apps/api/src/domain/generated/campaignNews/dto/connect-campaignNews.dto.ts +++ b/apps/api/src/domain/generated/campaignNews/dto/connect-campaignNews.dto.ts @@ -1,5 +1,3 @@ - - export class ConnectCampaignNewsDto { - id: string; - } - \ No newline at end of file +export class ConnectCampaignNewsDto { + id: string +} diff --git a/apps/api/src/domain/generated/campaignNews/dto/create-campaignNews.dto.ts b/apps/api/src/domain/generated/campaignNews/dto/create-campaignNews.dto.ts index c66e7e93d..b7acf5db8 100644 --- a/apps/api/src/domain/generated/campaignNews/dto/create-campaignNews.dto.ts +++ b/apps/api/src/domain/generated/campaignNews/dto/create-campaignNews.dto.ts @@ -1,15 +1,9 @@ - - - - - - export class CreateCampaignNewsDto { - slug: string; -title: string; -author: string; -sourceLink?: string; -publishedAt?: Date; -editedAt?: Date; -description: string; + slug: string + title: string + author: string + sourceLink?: string + publishedAt?: Date + editedAt?: Date + description: string } diff --git a/apps/api/src/domain/generated/campaignNews/dto/index.ts b/apps/api/src/domain/generated/campaignNews/dto/index.ts index d8692302f..b887f9b80 100644 --- a/apps/api/src/domain/generated/campaignNews/dto/index.ts +++ b/apps/api/src/domain/generated/campaignNews/dto/index.ts @@ -1,4 +1,3 @@ - -export * from './connect-campaignNews.dto'; -export * from './create-campaignNews.dto'; -export * from './update-campaignNews.dto'; \ No newline at end of file +export * from './connect-campaignNews.dto' +export * from './create-campaignNews.dto' +export * from './update-campaignNews.dto' diff --git a/apps/api/src/domain/generated/campaignNews/dto/update-campaignNews.dto.ts b/apps/api/src/domain/generated/campaignNews/dto/update-campaignNews.dto.ts index b97165ede..82adf9710 100644 --- a/apps/api/src/domain/generated/campaignNews/dto/update-campaignNews.dto.ts +++ b/apps/api/src/domain/generated/campaignNews/dto/update-campaignNews.dto.ts @@ -1,15 +1,9 @@ - - - - - - export class UpdateCampaignNewsDto { - slug?: string; -title?: string; -author?: string; -sourceLink?: string; -publishedAt?: Date; -editedAt?: Date; -description?: string; + slug?: string + title?: string + author?: string + sourceLink?: string + publishedAt?: Date + editedAt?: Date + description?: string } diff --git a/apps/api/src/domain/generated/campaignNews/entities/campaignNews.entity.ts b/apps/api/src/domain/generated/campaignNews/entities/campaignNews.entity.ts index 637e6b6ad..f046989d1 100644 --- a/apps/api/src/domain/generated/campaignNews/entities/campaignNews.entity.ts +++ b/apps/api/src/domain/generated/campaignNews/entities/campaignNews.entity.ts @@ -1,22 +1,22 @@ - -import {CampaignNewsState} from '@prisma/client' -import {Campaign} from '../../campaign/entities/campaign.entity' -import {Person} from '../../person/entities/person.entity' - +import { CampaignNewsState } from '@prisma/client' +import { Campaign } from '../../campaign/entities/campaign.entity' +import { Person } from '../../person/entities/person.entity' +import { CampaignNewsFile } from '../../campaignNewsFile/entities/campaignNewsFile.entity' export class CampaignNews { - id: string ; -campaignId: string ; -publisherId: string ; -slug: string ; -title: string ; -author: string ; -sourceLink: string | null; -state: CampaignNewsState ; -createdAt: Date ; -publishedAt: Date | null; -editedAt: Date | null; -description: string ; -campaign?: Campaign ; -publisher?: Person ; + id: string + campaignId: string + publisherId: string + slug: string + title: string + author: string + sourceLink: string | null + state: CampaignNewsState + createdAt: Date + publishedAt: Date | null + editedAt: Date | null + description: string + campaign?: Campaign + publisher?: Person + newsFiles?: CampaignNewsFile[] } diff --git a/apps/api/src/domain/generated/campaignNews/entities/index.ts b/apps/api/src/domain/generated/campaignNews/entities/index.ts index a356a01d1..cb5d82606 100644 --- a/apps/api/src/domain/generated/campaignNews/entities/index.ts +++ b/apps/api/src/domain/generated/campaignNews/entities/index.ts @@ -1,2 +1 @@ - -export * from './campaignNews.entity'; \ No newline at end of file +export * from './campaignNews.entity' diff --git a/apps/api/src/domain/generated/campaignNewsFile/dto/connect-campaignNewsFile.dto.ts b/apps/api/src/domain/generated/campaignNewsFile/dto/connect-campaignNewsFile.dto.ts index ab48dffbb..3292302a0 100644 --- a/apps/api/src/domain/generated/campaignNewsFile/dto/connect-campaignNewsFile.dto.ts +++ b/apps/api/src/domain/generated/campaignNewsFile/dto/connect-campaignNewsFile.dto.ts @@ -1,5 +1,3 @@ - - export class ConnectCampaignNewsFileDto { - id: string; - } - \ No newline at end of file +export class ConnectCampaignNewsFileDto { + id: string +} diff --git a/apps/api/src/domain/generated/campaignNewsFile/dto/create-campaignNewsFile.dto.ts b/apps/api/src/domain/generated/campaignNewsFile/dto/create-campaignNewsFile.dto.ts index 8615826f7..b99ca94c8 100644 --- a/apps/api/src/domain/generated/campaignNewsFile/dto/create-campaignNewsFile.dto.ts +++ b/apps/api/src/domain/generated/campaignNewsFile/dto/create-campaignNewsFile.dto.ts @@ -1,13 +1,9 @@ - -import {CampaignFileRole} from '@prisma/client' -import {ApiProperty} from '@nestjs/swagger' - - - +import { CampaignFileRole } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' export class CreateCampaignNewsFileDto { - filename: string; -mimetype: string; -@ApiProperty({ enum: CampaignFileRole}) -role: CampaignFileRole; + filename: string + mimetype: string + @ApiProperty({ enum: CampaignFileRole }) + role: CampaignFileRole } diff --git a/apps/api/src/domain/generated/campaignNewsFile/dto/index.ts b/apps/api/src/domain/generated/campaignNewsFile/dto/index.ts index f8d203eaa..b2ce1b44c 100644 --- a/apps/api/src/domain/generated/campaignNewsFile/dto/index.ts +++ b/apps/api/src/domain/generated/campaignNewsFile/dto/index.ts @@ -1,4 +1,3 @@ - -export * from './connect-campaignNewsFile.dto'; -export * from './create-campaignNewsFile.dto'; -export * from './update-campaignNewsFile.dto'; \ No newline at end of file +export * from './connect-campaignNewsFile.dto' +export * from './create-campaignNewsFile.dto' +export * from './update-campaignNewsFile.dto' diff --git a/apps/api/src/domain/generated/campaignNewsFile/dto/update-campaignNewsFile.dto.ts b/apps/api/src/domain/generated/campaignNewsFile/dto/update-campaignNewsFile.dto.ts index cd6ef53f4..dd638edca 100644 --- a/apps/api/src/domain/generated/campaignNewsFile/dto/update-campaignNewsFile.dto.ts +++ b/apps/api/src/domain/generated/campaignNewsFile/dto/update-campaignNewsFile.dto.ts @@ -1,13 +1,9 @@ - -import {CampaignFileRole} from '@prisma/client' -import {ApiProperty} from '@nestjs/swagger' - - - +import { CampaignFileRole } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' export class UpdateCampaignNewsFileDto { - filename?: string; -mimetype?: string; -@ApiProperty({ enum: CampaignFileRole}) -role?: CampaignFileRole; + filename?: string + mimetype?: string + @ApiProperty({ enum: CampaignFileRole }) + role?: CampaignFileRole } diff --git a/apps/api/src/domain/generated/campaignNewsFile/entities/campaignNewsFile.entity.ts b/apps/api/src/domain/generated/campaignNewsFile/entities/campaignNewsFile.entity.ts index 80143fbe2..18d32c0c9 100644 --- a/apps/api/src/domain/generated/campaignNewsFile/entities/campaignNewsFile.entity.ts +++ b/apps/api/src/domain/generated/campaignNewsFile/entities/campaignNewsFile.entity.ts @@ -1,16 +1,14 @@ - -import {CampaignFileRole} from '@prisma/client' -import {CampaignNews} from '../../campaignNews/entities/campaignNews.entity' -import {Person} from '../../person/entities/person.entity' - +import { CampaignFileRole } from '@prisma/client' +import { CampaignNews } from '../../campaignNews/entities/campaignNews.entity' +import { Person } from '../../person/entities/person.entity' export class CampaignNewsFile { - id: string ; -filename: string ; -articleId: string ; -personId: string ; -mimetype: string ; -role: CampaignFileRole ; -news?: CampaignNews ; -person?: Person ; + id: string + filename: string + newsId: string + personId: string + mimetype: string + role: CampaignFileRole + news?: CampaignNews + person?: Person } diff --git a/apps/api/src/domain/generated/campaignNewsFile/entities/index.ts b/apps/api/src/domain/generated/campaignNewsFile/entities/index.ts index d112cd6d7..9c6fe2cab 100644 --- a/apps/api/src/domain/generated/campaignNewsFile/entities/index.ts +++ b/apps/api/src/domain/generated/campaignNewsFile/entities/index.ts @@ -1,2 +1 @@ - -export * from './campaignNewsFile.entity'; \ No newline at end of file +export * from './campaignNewsFile.entity' diff --git a/migrations/20230618101516_rename_campaign_news_fields/migration.sql b/migrations/20230618101516_rename_campaign_news_fields/migration.sql new file mode 100644 index 000000000..5230031c8 --- /dev/null +++ b/migrations/20230618101516_rename_campaign_news_fields/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `article_id` on the `campaign_news_files` table. All the data in the column will be lost. + - Added the required column `news_id` to the `campaign_news_files` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "campaign_news_files" DROP CONSTRAINT "campaign_news_files_article_id_fkey"; + +-- AlterTable +ALTER TABLE "campaign_news_files" DROP COLUMN "article_id", +ADD COLUMN "news_id" UUID NOT NULL; + +-- AddForeignKey +ALTER TABLE "campaign_news_files" ADD CONSTRAINT "campaign_news_files_news_id_fkey" FOREIGN KEY ("news_id") REFERENCES "campaign_news"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/schema.prisma b/schema.prisma index 135a5951e..5fc107e53 100644 --- a/schema.prisma +++ b/schema.prisma @@ -69,7 +69,7 @@ model Person { transfers Transfer[] withdrawals Withdrawal[] publishedNews CampaignNews[] - articleFiles CampaignNewsFile[] + newsFiles CampaignNewsFile[] @@index([keycloakId], map: "keycloak_id_idx") @@index([stripeCustomerId], map: "stripe_customer_id_idx") @@ -219,21 +219,21 @@ model Campaign { } model CampaignNews { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - campaignId String @map("campaign_id") @db.Uuid - publisherId String @map("publisher_id") @db.Uuid - slug String - title String - author String - sourceLink String? @map("source_link") - state CampaignNewsState @default(draft) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - publishedAt DateTime? @map("published_at") @db.Timestamptz(6) - editedAt DateTime? @map("edited_at") @db.Timestamptz(6) - description String - campaign Campaign @relation(fields: [campaignId], references: [id]) - publisher Person @relation(fields: [publisherId], references: [id]) - articleFiles CampaignNewsFile[] + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + campaignId String @map("campaign_id") @db.Uuid + publisherId String @map("publisher_id") @db.Uuid + slug String + title String + author String + sourceLink String? @map("source_link") + state CampaignNewsState @default(draft) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + publishedAt DateTime? @map("published_at") @db.Timestamptz(6) + editedAt DateTime? @map("edited_at") @db.Timestamptz(6) + description String + campaign Campaign @relation(fields: [campaignId], references: [id]) + publisher Person @relation(fields: [publisherId], references: [id]) + newsFiles CampaignNewsFile[] @@map("campaign_news") } @@ -279,14 +279,14 @@ model CampaignFile { } model CampaignNewsFile { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - filename String @db.VarChar(200) - articleId String @map("article_id") @db.Uuid - personId String @map("person_id") @db.Uuid - mimetype String @db.VarChar(100) - role CampaignFileRole - news CampaignNews @relation(fields: [articleId], references: [id], onDelete: Cascade) - person Person @relation(fields: [personId], references: [id]) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + filename String @db.VarChar(200) + newsId String @map("news_id") @db.Uuid + personId String @map("person_id") @db.Uuid + mimetype String @db.VarChar(100) + role CampaignFileRole + news CampaignNews @relation(fields: [newsId], references: [id], onDelete: Cascade) + person Person @relation(fields: [personId], references: [id]) @@map("campaign_news_files") } From 9cb1d65cf5092d1997a9858382e6f91fa98b57ea Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 18 Jun 2023 14:22:36 +0300 Subject: [PATCH 17/27] CampaignNewsFileModule: Remove unused imports --- .../src/campaign-news-file/campaign-news-file.module.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/api/src/campaign-news-file/campaign-news-file.module.ts b/apps/api/src/campaign-news-file/campaign-news-file.module.ts index e9233c4e4..017ac42a8 100644 --- a/apps/api/src/campaign-news-file/campaign-news-file.module.ts +++ b/apps/api/src/campaign-news-file/campaign-news-file.module.ts @@ -4,21 +4,14 @@ import { CampaignNewsFileController } from './campaign-news-file.controller' import { PrismaService } from '../prisma/prisma.service' import { S3Service } from '../s3/s3.service' import { PersonService } from '../person/person.service' -import { CampaignService } from '../campaign/campaign.service' -import { VaultService } from '../vault/vault.service' -import { NotificationModule } from '../sockets/notifications/notification.module' @Module({ - imports: [NotificationModule], - controllers: [CampaignNewsFileController], providers: [ CampaignNewsFileService, PrismaService, S3Service, PersonService, - CampaignService, - VaultService, ], }) export class CampaignNewsFileModule {} From c7a7e64880e48fcc848491d4fa6524045b43cbeb Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 18 Jun 2023 14:31:10 +0300 Subject: [PATCH 18/27] campaign: slug/can-edit Remove keycloakId param It will be received by the auth header now --- apps/api/src/campaign/campaign.controller.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/api/src/campaign/campaign.controller.ts b/apps/api/src/campaign/campaign.controller.ts index 9612f3a55..56d76b064 100644 --- a/apps/api/src/campaign/campaign.controller.ts +++ b/apps/api/src/campaign/campaign.controller.ts @@ -78,13 +78,12 @@ export class CampaignController { return { campaign } } - @Get(':slug/:keycloakId/can-edit') - @Public() + @Get(':slug/can-edit') async canEditCampaign( @Param('slug') slug: string, - @Param('keycloakId') keycloakId: string, + @AuthenticatedUser() user: KeycloakTokenParsed ): Promise { - const campaign = await this.campaignService.isUserCampaign(keycloakId, slug) + const campaign = await this.campaignService.isUserCampaign(user.sub, slug) return campaign } From 7990e11698847b915f0756d5a5a4b28831aee1a3 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 18 Jun 2023 14:53:35 +0300 Subject: [PATCH 19/27] CampaignModule: Import CampaignNewsModule --- apps/api/src/campaign/campaign.controller.ts | 3 +-- apps/api/src/campaign/campaign.module.ts | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/api/src/campaign/campaign.controller.ts b/apps/api/src/campaign/campaign.controller.ts index 56d76b064..3081fcd25 100644 --- a/apps/api/src/campaign/campaign.controller.ts +++ b/apps/api/src/campaign/campaign.controller.ts @@ -35,9 +35,8 @@ import { CampaignNewsService } from '../campaign-news/campaign-news.service' export class CampaignController { constructor( private readonly campaignService: CampaignService, - @Inject(forwardRef(() => PersonService)) private readonly personService: PersonService, - @Inject(forwardRef(() => CampaignNewsService)) private readonly campaignNewsService: CampaignNewsService, + @Inject(forwardRef(() => PersonService)) private readonly personService: PersonService, ) {} @Get('list') diff --git a/apps/api/src/campaign/campaign.module.ts b/apps/api/src/campaign/campaign.module.ts index 046c58746..c565b5651 100644 --- a/apps/api/src/campaign/campaign.module.ts +++ b/apps/api/src/campaign/campaign.module.ts @@ -8,12 +8,12 @@ import { CampaignTypeController } from './campaign-type.controller' import { CampaignController } from './campaign.controller' import { CampaignService } from './campaign.service' import { NotificationModule } from '../sockets/notifications/notification.module' -import { CampaignNewsService } from '../campaign-news/campaign-news.service' +import { CampaignNewsModule } from '../campaign-news/campaign-news.module' @Module({ - imports: [forwardRef(() => VaultModule), NotificationModule], + imports: [forwardRef(() => VaultModule), NotificationModule, CampaignNewsModule], controllers: [CampaignController, CampaignTypeController], - providers: [CampaignService, PrismaService, VaultService, PersonService, ConfigService, CampaignNewsService], + providers: [CampaignService, PrismaService, VaultService, PersonService, ConfigService], exports: [CampaignService], From 98dd7a23d008be42ffeb74421fbd2642b66debc8 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 18 Jun 2023 15:14:42 +0300 Subject: [PATCH 20/27] CampaignNewsController: Remove Public() annotation from campaignSlug/list --- apps/api/src/campaign-news/campaign-news.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/campaign-news/campaign-news.controller.ts b/apps/api/src/campaign-news/campaign-news.controller.ts index 19ce6e930..e85186c43 100644 --- a/apps/api/src/campaign-news/campaign-news.controller.ts +++ b/apps/api/src/campaign-news/campaign-news.controller.ts @@ -62,7 +62,6 @@ export class CampaignNewsController { } @Get(':campaignSlug/list') - @Public() async listNewsByCampaignSlug(@Param('campaignSlug') campaignSlug: string) { const news = await this.campaignNewsService.listAdminArticles(campaignSlug) return news From 65d34ebe74aaf20b959b88b1d17ca6c4b4f2946a Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 18 Jun 2023 15:59:51 +0300 Subject: [PATCH 21/27] CampaignNewsController: Remove unncessary @Roles() annotations --- .../src/campaign-news/campaign-news.controller.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/api/src/campaign-news/campaign-news.controller.ts b/apps/api/src/campaign-news/campaign-news.controller.ts index e85186c43..b62838531 100644 --- a/apps/api/src/campaign-news/campaign-news.controller.ts +++ b/apps/api/src/campaign-news/campaign-news.controller.ts @@ -30,10 +30,6 @@ export class CampaignNewsController { ) {} @Post() - @Roles({ - roles: [], - mode: RoleMatchingMode.ANY, - }) async create( @Body() createCampaignNewsDto: CreateCampaignNewsDto, @AuthenticatedUser() user: KeycloakTokenParsed, @@ -89,10 +85,6 @@ export class CampaignNewsController { } @Put(':id') - @Roles({ - roles: [], - mode: RoleMatchingMode.ANY, - }) async editArticle( @Param('id') articleId: string, @Body() updateCampaignNewsDto: UpdateCampaignNewsDto, @@ -128,10 +120,6 @@ export class CampaignNewsController { } @Delete(':id') - @Roles({ - roles: [], - mode: RoleMatchingMode.ANY, - }) async delete(@Param('id') articleId: string, @AuthenticatedUser() user: KeycloakTokenParsed) { const article = await this.campaignNewsService.findArticleByID(articleId) if (!article) throw new NotFoundException('Article not found') From 7a2113b304d36d19a94687cfdc6a1a024779cb79 Mon Sep 17 00:00:00 2001 From: igoychev Date: Tue, 27 Jun 2023 12:22:24 +0300 Subject: [PATCH 22/27] replaced PersonService with import of PersonModule to fix the tests when importing external services, they need to be imported through their module --- apps/api/src/campaign-news/campaign-news.module.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/src/campaign-news/campaign-news.module.ts b/apps/api/src/campaign-news/campaign-news.module.ts index e3cf4d169..1f5c790c6 100644 --- a/apps/api/src/campaign-news/campaign-news.module.ts +++ b/apps/api/src/campaign-news/campaign-news.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common' import { CampaignNewsService } from './campaign-news.service' import { CampaignNewsController } from './campaign-news.controller' import { PrismaService } from '../prisma/prisma.service' -import { PersonService } from '../person/person.service' +import { PersonModule } from '../person/person.module' @Module({ + imports: [PersonModule], controllers: [CampaignNewsController], - providers: [CampaignNewsService, PersonService, PrismaService], + providers: [CampaignNewsService, PrismaService], exports: [CampaignNewsService], }) export class CampaignNewsModule {} From e6d14635ea9e030c4ad8b2c9eba11a8ad603595e Mon Sep 17 00:00:00 2001 From: igoychev Date: Tue, 27 Jun 2023 12:27:32 +0300 Subject: [PATCH 23/27] added import for CampaignNewsModule to fix the tests --- apps/api/src/campaign/campaign.controller.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/campaign/campaign.controller.spec.ts b/apps/api/src/campaign/campaign.controller.spec.ts index 27f6cb877..93dca289a 100644 --- a/apps/api/src/campaign/campaign.controller.spec.ts +++ b/apps/api/src/campaign/campaign.controller.spec.ts @@ -14,6 +14,7 @@ import { PersonService } from '../person/person.service' import * as paymentReferenceGenerator from './helpers/payment-reference' import { CampaignSummaryDto } from './dto/campaign-summary.dto' import { NotificationModule } from '../sockets/notifications/notification.module' +import { CampaignNewsModule } from '../campaign-news/campaign-news.module' describe('CampaignController', () => { let controller: CampaignController @@ -110,7 +111,7 @@ describe('CampaignController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [NotificationModule], + imports: [NotificationModule, CampaignNewsModule], controllers: [CampaignController], providers: [CampaignService, MockPrismaService, VaultService, PersonService, ConfigService], }) From 3825bd7b056073d55b5fdd038bb61889551c8878 Mon Sep 17 00:00:00 2001 From: igoychev Date: Tue, 27 Jun 2023 12:29:59 +0300 Subject: [PATCH 24/27] added missing import of ConfigModule to fix the tests --- apps/api/src/person/person.module.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/person/person.module.ts b/apps/api/src/person/person.module.ts index 1e29519ec..cacb9f0a3 100644 --- a/apps/api/src/person/person.module.ts +++ b/apps/api/src/person/person.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common' import { PersonService } from './person.service' import { PersonController } from './person.controller' import { PrismaService } from '../prisma/prisma.service' +import { ConfigModule } from '@nestjs/config' @Module({ + imports: [ConfigModule], controllers: [PersonController], providers: [PersonService, PrismaService], exports: [PersonService], From 23430d793d6836a51e4a1c5d3e92333fbddead37 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Tue, 27 Jun 2023 13:20:50 +0300 Subject: [PATCH 25/27] campaign-news: Use logged in user's first and last name if author field is empty --- apps/api/src/campaign-news/campaign-news.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/campaign-news/campaign-news.controller.ts b/apps/api/src/campaign-news/campaign-news.controller.ts index b62838531..3b5e88065 100644 --- a/apps/api/src/campaign-news/campaign-news.controller.ts +++ b/apps/api/src/campaign-news/campaign-news.controller.ts @@ -53,6 +53,7 @@ export class CampaignNewsController { return await this.campaignNewsService.createDraft({ ...createCampaignNewsDto, + author: createCampaignNewsDto.author || `${person.firstName} ${person.lastName}`, publisherId: person.id, }) } From 1d44ed890d4cd6d07bf4a9f0261fc1909086c568 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Tue, 27 Jun 2023 13:44:02 +0300 Subject: [PATCH 26/27] campaign-news: Remove unnecessary inject As we import the PersonModule, there is no need for injecting PersonService anymore --- apps/api/src/campaign-news/campaign-news.controller.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/api/src/campaign-news/campaign-news.controller.ts b/apps/api/src/campaign-news/campaign-news.controller.ts index 3b5e88065..b377d3287 100644 --- a/apps/api/src/campaign-news/campaign-news.controller.ts +++ b/apps/api/src/campaign-news/campaign-news.controller.ts @@ -7,8 +7,6 @@ import { Param, Delete, Put, - Inject, - forwardRef, ForbiddenException, NotFoundException, } from '@nestjs/common' @@ -26,7 +24,7 @@ import { CampaignNewsState } from '@prisma/client' export class CampaignNewsController { constructor( private readonly campaignNewsService: CampaignNewsService, - @Inject(forwardRef(() => PersonService)) private personService: PersonService, + private personService: PersonService, ) {} @Post() From 0592843db7538c0a609508ebed04fed5f716450b Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Fri, 30 Jun 2023 19:29:20 +0300 Subject: [PATCH 27/27] prisma: Make CampaignNews' slug field unique --- .../migration.sql | 12 ++++++++++++ schema.prisma | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 migrations/20230630162339_campaign_news_make_slug_field_unique/migration.sql diff --git a/migrations/20230630162339_campaign_news_make_slug_field_unique/migration.sql b/migrations/20230630162339_campaign_news_make_slug_field_unique/migration.sql new file mode 100644 index 000000000..07ea20a26 --- /dev/null +++ b/migrations/20230630162339_campaign_news_make_slug_field_unique/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to alter the column `slug` on the `campaign_news` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(250)`. + - A unique constraint covering the columns `[slug]` on the table `campaign_news` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "campaign_news" ALTER COLUMN "slug" SET DATA TYPE VARCHAR(250); + +-- CreateIndex +CREATE UNIQUE INDEX "campaign_news_slug_key" ON "campaign_news"("slug"); diff --git a/schema.prisma b/schema.prisma index e6b1b1cab..6484df7ed 100644 --- a/schema.prisma +++ b/schema.prisma @@ -222,7 +222,7 @@ model CampaignNews { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid campaignId String @map("campaign_id") @db.Uuid publisherId String @map("publisher_id") @db.Uuid - slug String + slug String @unique @db.VarChar(250) title String author String sourceLink String? @map("source_link")