diff --git a/public/locales/en/affix.json b/public/locales/en/affix.json index 711800335..b2420a85a 100644 --- a/public/locales/en/affix.json +++ b/public/locales/en/affix.json @@ -1,10 +1,11 @@ { - "affixButtonTitle": "Click here to add a claim or a personality", - "affixButtonCreateClaim": "Click here to add a claim", - "affixButtonCreatePersonality": "Click here to add a personality", - "affixButtonCreateVerifiedSources": "Click here to add a source", - "affixButtonCreateVerificationRequest": "Click here to add a verification request", - "affixCallToActionButton": "Click here", - "AffixCloseTooltip": "Close", - "affixCopilotTitle": "Click here to open the Aletheia's Assistant" + "affixButtonTitle": "Click here to add a claim or a personality", + "affixButtonCreateClaim": "Click here to add a claim", + "affixButtonCreatePersonality": "Click here to add a personality", + "affixButtonCreateVerifiedSources": "Click here to add a source", + "affixButtonCreateVerificationRequest": "Click here to add a verification request", + "affixButtonCreateEvent": "Click here to add a event", + "affixCallToActionButton": "Click here", + "AffixCloseTooltip": "Close", + "affixCopilotTitle": "Click here to open the Aletheia's Assistant" } diff --git a/public/locales/en/events.json b/public/locales/en/events.json new file mode 100644 index 000000000..d0d6bdbdc --- /dev/null +++ b/public/locales/en/events.json @@ -0,0 +1,27 @@ +{ + "eventCreateSuccess": "Event created successfully", + "totalItems": "{{total}} in total", + "openEvent": "See event", + "fetchError": "Error fetching events. Please try again later.", + "loadMoreButton": "Load more", + "eventsList": "Events", + "filterAll": "All", + "filterOngoing": "Ongoing", + "filterUpcoming": "Upcoming", + "filterFinished": "Finished", + "badgeLabel": "Badge", + "badgePlaceholder": "Enter tag (e.g., COP30)", + "nameLabel": "Name", + "namePlaceholder": "Enter event name", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Describe the event", + "locationLabel": "Location", + "locationPlaceholder": "Enter event location", + "startDateLabel": "Start date", + "startDatePlaceholder": "Select start date", + "endDateLabel": "End date", + "endDatePlaceholder": "Select end date", + "mainTopicLabel": "Main topic", + "mainTopicPlaceholder": "Search and select the main topic", + "mainTopicLabelTooltip": "Reference topic used to link the fact-checks and verification requests that will appear on the event page." +} diff --git a/public/locales/en/seo.json b/public/locales/en/seo.json index c6d90cdf3..73942f37e 100644 --- a/public/locales/en/seo.json +++ b/public/locales/en/seo.json @@ -17,5 +17,7 @@ "verificationRequestTitle": "Verification Requests", "verificationRequestDescription": "See verification requests on AletheiaFact.org", "createVerificationRequestTitle": "Create verification request", - "createVerificationRequestDescription": "Create verification requests to AletheiaFact.org" + "createVerificationRequestDescription": "Create verification requests to AletheiaFact.org", + "createEventTitle": "Create event", + "createEventDescription": "Create event to AletheiaFact.org" } diff --git a/public/locales/pt/affix.json b/public/locales/pt/affix.json index 22983d3c5..76542dbef 100644 --- a/public/locales/pt/affix.json +++ b/public/locales/pt/affix.json @@ -1,10 +1,11 @@ { - "affixButtonTitle": "Clique aqui para adicionar uma afirmação ou personalidade", - "affixButtonCreateClaim": "Clique aqui para adicionar uma afirmação", - "affixButtonCreatePersonality": "Clique aqui para adicionar uma personalidade", - "affixButtonCreateVerifiedSources": "Clique aqui para adicionar uma informação checada", - "affixButtonCreateVerificationRequest": "Clique aqui para adicionar uma denúncia", - "affixCallToActionButton": "Clique aqui", - "AffixCloseTooltip": "Fechar", - "affixCopilotTitle": "Clique aqui para abrir o Assistente Aletheia" + "affixButtonTitle": "Clique aqui para adicionar uma afirmação ou personalidade", + "affixButtonCreateClaim": "Clique aqui para adicionar uma afirmação", + "affixButtonCreatePersonality": "Clique aqui para adicionar uma personalidade", + "affixButtonCreateVerifiedSources": "Clique aqui para adicionar uma informação checada", + "affixButtonCreateVerificationRequest": "Clique aqui para adicionar uma denúncia", + "affixButtonCreateEvent": "Clique aqui para adicionar um evento", + "affixCallToActionButton": "Clique aqui", + "AffixCloseTooltip": "Fechar", + "affixCopilotTitle": "Clique aqui para abrir o Assistente Aletheia" } diff --git a/public/locales/pt/events.json b/public/locales/pt/events.json new file mode 100644 index 000000000..0ad2c14f5 --- /dev/null +++ b/public/locales/pt/events.json @@ -0,0 +1,27 @@ +{ + "eventCreateSuccess": "Evento criado com sucesso", + "totalItems": "{{total}} no total", + "openEvent": "Veja evento", + "fetchError": "Erro ao carregar eventos. Por favor, tente novamente mais tarde.", + "loadMoreButton": "Carregar mais", + "eventsList": "Eventos", + "filterAll": "Todos", + "filterHappening": "Agora", + "filterUpcoming": "Em breve", + "filterFinished": "Encerrados", + "badgeLabel": "Etiqueta", + "badgePlaceholder": "Digite a etiqueta (ex: COP30)", + "nameLabel": "Nome", + "namePlaceholder": "Digite o nome do evento", + "descriptionLabel": "Descrição", + "descriptionPlaceholder": "Descreva o evento", + "locationLabel": "Local", + "locationPlaceholder": "Digite o local do evento", + "startDateLabel": "Data de início", + "startDatePlaceholder": "Selecione a data de início", + "endDateLabel": "Data de término", + "endDatePlaceholder": "Selecione a data de término", + "mainTopicLabel": "Tópico principal", + "mainTopicPlaceholder": "Busque e selecione o tópico principal", + "mainTopicLabelTooltip": "Tópico de referência usado para vincular as checagens e as denúncias que serão exibidos na página do evento." +} diff --git a/public/locales/pt/seo.json b/public/locales/pt/seo.json index 8c5726e36..7d1a90a85 100644 --- a/public/locales/pt/seo.json +++ b/public/locales/pt/seo.json @@ -17,5 +17,7 @@ "verificationRequestTitle": "Denúncias", "verificationRequestDescription": "Veja as denúncias na AletheiaFact.org", "createVerificationRequestTitle": "Adicione uma denuncia", - "createVerificationRequestDescription": "Adiciona uma denúncia na AletheiaFact.org" + "createVerificationRequestDescription": "Adiciona uma denúncia na AletheiaFact.org", + "createEventTitle": "Adicione um evento", + "createEventDescription": "Adicione um na AletheiaFact.org" } diff --git a/server/app.module.ts b/server/app.module.ts index f45273eed..fe83b7423 100644 --- a/server/app.module.ts +++ b/server/app.module.ts @@ -65,6 +65,7 @@ import { M2MGuard } from "./auth/m2m.guard"; import { CallbackDispatcherModule } from "./callback-dispatcher/callback-dispatcher.module"; import { AiTaskModule } from "./ai-task/ai-task.module"; import { TrackingModule } from "./tracking/tracking.module"; +import { EventsModule } from "./events/event.module"; import { ManagementModule } from "./management/management.module"; @Module({}) @@ -123,6 +124,7 @@ export class AppModule implements NestModule { ClaimRevisionModule, HistoryModule, TrackingModule, + EventsModule, ManagementModule, StateEventModule, SourceModule, diff --git a/server/claim/types/sentence/sentence.service.ts b/server/claim/types/sentence/sentence.service.ts index 607a40575..fb4e34223 100644 --- a/server/claim/types/sentence/sentence.service.ts +++ b/server/claim/types/sentence/sentence.service.ts @@ -1,6 +1,8 @@ import { BadRequestException, Injectable, + InternalServerErrorException, + Logger, NotFoundException, } from "@nestjs/common"; import { Model } from "mongoose"; @@ -8,8 +10,9 @@ import { SentenceDocument, Sentence } from "./schemas/sentence.schema"; import { InjectModel } from "@nestjs/mongoose"; import { ReportService } from "../../../report/report.service"; import { UtilService } from "../../../util"; -import { allCop30WikiDataIds } from "../../../../src/constants/cop30Filters"; import type { Cop30Sentence } from "../../../../src/types/Cop30Sentence"; +import { TopicRelatedSentencesResponse } from "./types/sentence.interfaces"; +import { allCop30WikiDataIds } from "../../../../src/constants/cop30Filters"; import type { Cop30Stats } from "../../../../src/types/Cop30Stats"; import { buildStats } from "../../../../src/components/Home/COP30/utils/classification"; @@ -24,6 +27,8 @@ interface FindAllOptionsFilters { @Injectable() export class SentenceService { + private readonly logger = new Logger(SentenceService.name); + constructor( @InjectModel(Sentence.name) private SentenceModel: Model, @@ -31,6 +36,64 @@ export class SentenceService { private util: UtilService ) {} + /** + * Fetches sentences based on topic values and enriches them with review classification. + * Uses an aggregation pipeline for cross-collection lookup. + * @param query - Topic wikidataId array to filter sentences. + * @returns A list of sentences with their respective classification. + */ + async getSentencesByTopics(query: string[]): Promise { + try { + this.logger.debug(`Fetching sentences for topics: ${query.join(', ')}`); + + const aggregation: any[] = [ + { $match: { "topics.value": { $in: query } } }, + { + $lookup: { + from: "reviewtasks", + let: { sentenceDataHash: "$data_hash" }, + pipeline: [ + { + $match: { + $expr: { + $eq: [ + "$machine.context.reviewData.data_hash", + "$$sentenceDataHash", + ], + }, + }, + }, + { + $project: { + _id: 0, + classification: "$machine.context.reviewData.classification", + }, + }, + ], + as: "reviewInfo", + }, + }, + { + $addFields: { + classification: { + $arrayElemAt: ["$reviewInfo.classification", 0], + }, + }, + }, + { $project: { reviewInfo: 0 } }, + ]; + + const result = await this.SentenceModel.aggregate(aggregation).exec(); + + this.logger.log(`Found ${result.length} sentences for the requested topics.`); + + return result; + } catch (error) { + this.logger.error(`Error in getSentencesByTopics: ${error.message}`, error.stack); + throw new InternalServerErrorException("Failed to aggregate sentences with review data."); + } + } + async getSentencesWithCop30Topics(): Promise { const aggregation = [ { $match: { "topics.value": { $in: allCop30WikiDataIds } } }, diff --git a/server/claim/types/sentence/types/sentence.interfaces.ts b/server/claim/types/sentence/types/sentence.interfaces.ts new file mode 100644 index 000000000..dc74c5fe8 --- /dev/null +++ b/server/claim/types/sentence/types/sentence.interfaces.ts @@ -0,0 +1,12 @@ +import { Sentence } from "../schemas/sentence.schema"; + +export interface Review { + personality: string; + usersId: string; + isPartialReview: boolean; +} + +export type TopicRelatedSentencesResponse = Sentence & { + classification?: string; + review?: Review; +} diff --git a/server/events/dto/event.dto.ts b/server/events/dto/event.dto.ts new file mode 100644 index 000000000..e1b609fa4 --- /dev/null +++ b/server/events/dto/event.dto.ts @@ -0,0 +1,67 @@ +import { + IsArray, + IsDate, + IsNotEmpty, + IsObject, + IsOptional, + IsString +} from "class-validator"; +import { ApiProperty, PartialType } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import type { TopicData } from "../../topic/types/topic.interfaces"; + +export class CreateEventDTO { + @IsString() + @IsOptional() + @ApiProperty() + nameSpace?: string; + + @IsString() + @IsNotEmpty() + @ApiProperty() + badge: string; + + @IsString() + @IsNotEmpty() + @ApiProperty() + name: string; + + @IsString() + @IsNotEmpty() + @ApiProperty() + description: string; + + @IsString() + @IsNotEmpty() + @ApiProperty() + location: string; + + @IsDate() + @IsNotEmpty() + @Type(() => Date) + @ApiProperty() + startDate: Date; + + @IsDate() + @IsNotEmpty() + @Type(() => Date) + @ApiProperty() + endDate: Date; + + @IsObject() + @IsNotEmpty() + @ApiProperty() + mainTopic: TopicData; + + @IsString() + @IsOptional() + @ApiProperty() + recaptcha?: string; +} + +export class UpdateEventDTO extends PartialType(CreateEventDTO) { + @IsArray() + @IsOptional() + @ApiProperty() + filterTopics: TopicData[]; +} diff --git a/server/events/dto/filter.dto.ts b/server/events/dto/filter.dto.ts new file mode 100644 index 000000000..9e991def2 --- /dev/null +++ b/server/events/dto/filter.dto.ts @@ -0,0 +1,26 @@ +import { + IsOptional, + IsInt, + Min, + IsString +} from "class-validator"; +import { Type } from "class-transformer"; + +export class FilterEventsDTO { + @Type(() => Number) + @IsInt() + @Min(0) + page?: number = 0; + + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number = 10; + + @IsString() + order?: "asc" | "desc"; + + @IsOptional() + @IsString() + status?: string; +} diff --git a/server/events/event.controller.spec.ts b/server/events/event.controller.spec.ts new file mode 100644 index 000000000..8b0689173 --- /dev/null +++ b/server/events/event.controller.spec.ts @@ -0,0 +1,185 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { EventsController } from "./event.controller"; +import { EventsService } from "./event.service"; +import { ViewService } from "../view/view.service"; +import { AbilitiesGuard } from "../auth/ability/abilities.guard"; +import { + mockConfigService, + mockEventsService, + mockViewService, +} from "../mocks/EventMock"; + +describe("EventsController (Unit)", () => { + let controller: EventsController; + + beforeEach(async () => { + const testingModule: TestingModule = await Test.createTestingModule({ + controllers: [EventsController], + providers: [ + { provide: ConfigService, useValue: mockConfigService }, + { provide: EventsService, useValue: mockEventsService }, + { provide: ViewService, useValue: mockViewService }, + ], + }) + .overrideGuard(AbilitiesGuard) + .useValue({}) + .compile(); + + controller = testingModule.get(EventsController); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("create", () => { + it("should delegate creation to eventsService", async () => { + const dto = { name: "Event" }; + const created = { _id: "e1", ...dto }; + mockEventsService.create.mockResolvedValue(created); + + const result = await controller.create(dto as any); + + expect(mockEventsService.create).toHaveBeenCalledWith(dto); + expect(result).toEqual(created); + }); + }); + + describe("update", () => { + it("should delegate update to eventsService", async () => { + const id = "507f1f77bcf86cd799439011"; + const dto = { name: "Updated" }; + const updated = { _id: id, ...dto }; + mockEventsService.update.mockResolvedValue(updated); + + const result = await controller.update(id, dto as any); + + expect(mockEventsService.update).toHaveBeenCalledWith(id, dto); + expect(result).toEqual(updated); + }); + }); + + describe("findAll", () => { + it("should delegate filtering to eventsService", async () => { + const query = { page: 0, pageSize: 10, order: "asc", status: "upcoming" }; + const events = [{ _id: "e1" }]; + mockEventsService.findAll.mockResolvedValue(events); + + const result = await controller.findAll(query as any); + + expect(mockEventsService.findAll).toHaveBeenCalledWith(query); + expect(result).toEqual(events); + }); + }); + + describe("eventPage", () => { + it("should render /event-page with parsed query, namespace and site key", async () => { + const req = { + url: "/event?foo=bar", + params: { namespace: "main" }, + }; + const res = {}; + + await controller.eventPage(req as any, res as any); + + expect(mockConfigService.get).toHaveBeenCalledWith("recaptcha_sitekey"); + expect(mockViewService.render).toHaveBeenCalledWith( + req, + res, + "/event-page", + expect.objectContaining({ + foo: "bar", + nameSpace: "main", + sitekey: "test-site-key", + }) + ); + }); + }); + + describe("createEventPage", () => { + it("should render /event-create with parsed query, namespace and site key", async () => { + const req = { + url: "/event/create?foo=bar", + params: { namespace: "main" }, + }; + const res = {}; + + await controller.createEventPage(req as any, res as any); + + expect(mockConfigService.get).toHaveBeenCalledWith("recaptcha_sitekey"); + expect(mockViewService.render).toHaveBeenCalledWith( + req, + res, + "/event-create", + expect.objectContaining({ + foo: "bar", + nameSpace: "main", + sitekey: "test-site-key", + }) + ); + }); + }); + + describe("eventViewPage", () => { + it("should render /event-view-page when event exists", async () => { + const req = { + url: "/event/hash-1?lang=en", + params: { namespace: "main", data_hash: "hash-1" }, + }; + const res = {}; + const fullEvent = { _id: "e1", data_hash: "hash-1" }; + + mockEventsService.getFullEventByHash.mockResolvedValue(fullEvent); + + await controller.eventViewPage(req as any, res as any); + + expect(mockEventsService.getFullEventByHash).toHaveBeenCalledWith("hash-1"); + expect(mockConfigService.get).toHaveBeenCalledWith("recaptcha_sitekey"); + expect(mockViewService.render).toHaveBeenCalledWith( + req, + res, + "/event-view-page", + expect.objectContaining({ + lang: "en", + fullEvent, + namespace: "main", + sitekey: "test-site-key", + }) + ); + }); + + it("should throw NotFoundException when service returns empty event", async () => { + const req = { + url: "/event/hash-1", + params: { namespace: "main", data_hash: "hash-1" }, + }; + const res = {}; + + mockEventsService.getFullEventByHash.mockResolvedValue(null); + + await expect(controller.eventViewPage(req as any, res as any)).rejects.toThrow( + NotFoundException + ); + expect(mockViewService.render).not.toHaveBeenCalled(); + }); + + it("should rethrow service errors and log only non-NotFound errors", async () => { + const req = { + url: "/event/hash-1", + params: { namespace: "main", data_hash: "hash-1" }, + }; + const res = {}; + const error = new Error("unexpected"); + const loggerErrorSpy = jest.spyOn((controller as any).logger, "error"); + + mockEventsService.getFullEventByHash.mockRejectedValue(error); + + await expect(controller.eventViewPage(req as any, res as any)).rejects.toThrow( + "unexpected" + ); + expect(loggerErrorSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/events/event.controller.ts b/server/events/event.controller.ts new file mode 100644 index 000000000..037934438 --- /dev/null +++ b/server/events/event.controller.ts @@ -0,0 +1,143 @@ +import { + Body, + Controller, + Get, + Header, + Logger, + NotFoundException, + Param, + Patch, + Post, + Query, + Req, + Res +} from "@nestjs/common"; +import { CreateEventDTO, UpdateEventDTO } from "./dto/event.dto"; +import { ApiTags } from "@nestjs/swagger"; +import { FilterEventsDTO } from "./dto/filter.dto"; +import type { BaseRequest } from "../types"; +import type { Response } from "express"; +import { FactCheckerOnly, Public } from "../auth/decorators/auth.decorator"; +import { parse } from "url"; +import { ObjectIdValidationPipe } from "../ai-task/pipes/objectid-validation.pipe"; +import { ConfigService } from "@nestjs/config"; +import { ViewService } from "../view/view.service"; +import { EventsService } from "./event.service"; + + +@Controller(":namespace?") +export class EventsController { + private readonly logger = new Logger(EventsController.name); + + constructor( + private configService: ConfigService, + private readonly eventsService: EventsService, + private viewService: ViewService, + ) { } + + @FactCheckerOnly() + @ApiTags("event") + @Post("api/event") + async create(@Body() newEvent: CreateEventDTO) { + return this.eventsService.create(newEvent); + } + + @FactCheckerOnly() + @ApiTags("event") + @Patch("api/event/:id") + async update( + @Param("id", ObjectIdValidationPipe) eventId: string, + @Body() updatedEvent: UpdateEventDTO + ) { + return this.eventsService.update(eventId, updatedEvent); + } + + @Public() + @ApiTags("event") + @Get("api/event") + public async findAll(@Query() query: FilterEventsDTO) { + return this.eventsService.findAll(query); + } + + @Public() + @ApiTags("pages") + @Get("event") + @Header("Cache-Control", "max-age=60") + public async eventPage( + @Req() req: BaseRequest, + @Res() res: Response + ) { + const parsedUrl = parse(req.url, true); + + const queryObject = Object.assign(parsedUrl.query, { + nameSpace: req.params.namespace, + sitekey: this.configService.get("recaptcha_sitekey"), + }); + + await this.viewService.render( + req, + res, + "/event-page", + queryObject + ); + } + + @FactCheckerOnly() + @ApiTags("pages") + @Get("event/create") + public async createEventPage( + @Req() req: BaseRequest, + @Res() res: Response + ) { + const parsedUrl = parse(req.url, true); + const queryObject = Object.assign(parsedUrl.query, { + sitekey: this.configService.get("recaptcha_sitekey"), + nameSpace: req.params.namespace, + }); + + await this.viewService.render( + req, + res, + "/event-create", + queryObject + ); + } + + @Public() + @ApiTags("pages") + @Get("event/:data_hash") + @Header("Cache-Control", "max-age=60, must-revalidate") + public async eventViewPage( + @Req() req: BaseRequest, + @Res() res: Response, + ) { + const parsedUrl = parse(req.url, true); + const { data_hash } = req.params; + try { + const fullEvent = await this.eventsService.getFullEventByHash(data_hash); + + if (!fullEvent) { + this.logger.warn(`Event not found for hash: ${data_hash}`); + throw new NotFoundException("Event not found"); + } + + const queryObject = Object.assign(parsedUrl.query, { + fullEvent, + namespace: req.params.namespace, + sitekey: this.configService.get("recaptcha_sitekey"), + }); + + await this.viewService.render( + req, + res, + "/event-view-page", + queryObject + ); + } catch (error) { + if (!(error instanceof NotFoundException)) { + this.logger.error(`Error rendering event page: ${error.message}`, error.stack); + } + throw error; + } + } +} diff --git a/server/events/event.module.ts b/server/events/event.module.ts new file mode 100644 index 000000000..880b017e4 --- /dev/null +++ b/server/events/event.module.ts @@ -0,0 +1,35 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { EventSchema } from "./schema/event.schema"; +import { EventsController } from "./event.controller"; +import { ConfigModule } from "@nestjs/config"; +import { ViewModule } from "../view/view.module"; +import { EventsService } from "./event.service"; +import { VerificationRequestModule } from "../verification-request/verification-request.module"; +import { SentenceModule } from "../claim/types/sentence/sentence.module"; +import { TopicModule } from "../topic/topic.module"; +import { AbilityModule } from "../auth/ability/ability.module"; + +const EventModel = MongooseModule.forFeature([ + { + name: Event.name, + schema: EventSchema, + }, +]); + + +@Module({ + imports: [ + EventModel, + ConfigModule, + ViewModule, + AbilityModule, + VerificationRequestModule, + SentenceModule, + TopicModule + ], + controllers: [EventsController], + providers: [EventsService], + exports: [EventsService], +}) +export class EventsModule {} diff --git a/server/events/event.service.spec.ts b/server/events/event.service.spec.ts new file mode 100644 index 000000000..ac62004dc --- /dev/null +++ b/server/events/event.service.spec.ts @@ -0,0 +1,365 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getModelToken } from "@nestjs/mongoose"; +import { + BadRequestException, + ConflictException, + InternalServerErrorException, + NotFoundException, +} from "@nestjs/common"; +import * as crypto from "crypto"; +import { EventsService } from "./event.service"; +import { Event } from "./schema/event.schema"; +import { VerificationRequestService } from "../verification-request/verification-request.service"; +import { SentenceService } from "../claim/types/sentence/sentence.service"; +import { TopicService } from "../topic/topic.service"; +import { + mockCreateEventDto, + mockEventModel, + mockSentenceService, + mockTopicService, + mockVerificationRequestService, +} from "../mocks/EventMock"; + +describe("EventsService (Unit)", () => { + let service: EventsService; + + beforeAll(async () => { + const testingModule: TestingModule = await Test.createTestingModule({ + providers: [ + EventsService, + { + provide: getModelToken(Event.name), + useValue: mockEventModel, + }, + { + provide: VerificationRequestService, + useValue: mockVerificationRequestService, + }, + { + provide: SentenceService, + useValue: mockSentenceService, + }, + { + provide: TopicService, + useValue: mockTopicService, + }, + ], + }).compile(); + + service = testingModule.get(EventsService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("create", () => { + it("should create an event with generated hash and resolved topics", async () => { + const mainTopic = { _id: "t1", name: "Climate", wikidataId: "Q1" }; + const expectedHash = crypto + .createHash("md5") + .update(`${mockCreateEventDto.name}-${mockCreateEventDto.startDate}`) + .digest("hex"); + const createdEvent = { _id: "e1", data_hash: expectedHash }; + + mockTopicService.findOrCreateTopic.mockResolvedValueOnce(mainTopic); + mockEventModel.create.mockResolvedValue(createdEvent); + + const result = await service.create(mockCreateEventDto as any); + + expect(mockTopicService.findOrCreateTopic).toHaveBeenCalledTimes(1); + expect(mockTopicService.findOrCreateTopic).toHaveBeenCalledWith({ + ...mockCreateEventDto.mainTopic, + name: mockCreateEventDto.mainTopic.label, + }); + expect(mockEventModel.create).toHaveBeenCalledWith( + expect.objectContaining({ + ...mockCreateEventDto, + data_hash: expectedHash, + mainTopic: mainTopic._id, + }) + ); + expect(result).toEqual(createdEvent); + }); + + it("should throw BadRequestException for schema validation errors", async () => { + const validationError = { + name: "ValidationError", + errors: { name: {}, location: {} }, + }; + + mockTopicService.findOrCreateTopic.mockResolvedValue({ _id: "topic-id" }); + mockEventModel.create.mockRejectedValue(validationError); + + await expect(service.create(mockCreateEventDto as any)).rejects.toThrow( + BadRequestException + ); + }); + + it("should throw ConflictException for duplicate key errors", async () => { + const duplicateKeyError = { + code: 11000, + keyPattern: { name: 1 }, + }; + + mockTopicService.findOrCreateTopic.mockResolvedValue({ _id: "topic-id" }); + mockEventModel.create.mockRejectedValue(duplicateKeyError); + + await expect(service.create(mockCreateEventDto as any)).rejects.toThrow( + ConflictException + ); + }); + + it("should throw InternalServerErrorException for unknown errors", async () => { + mockTopicService.findOrCreateTopic.mockResolvedValue({ _id: "topic-id" }); + mockEventModel.create.mockRejectedValue(new Error("unexpected")); + + await expect(service.create(mockCreateEventDto as any)).rejects.toThrow( + InternalServerErrorException + ); + }); + }); + + describe("update", () => { + it("should throw BadRequestException when id is invalid", async () => { + await expect(service.update("invalid-id", { name: "Updated" } as any)).rejects.toThrow( + BadRequestException + ); + + expect(mockEventModel.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it("should update event and deduplicate filter topics by name", async () => { + const id = "507f1f77bcf86cd799439011"; + const updateDto = { + name: "Updated Event", + mainTopic: { name: "Main", wikidataId: "Q10" }, + filterTopics: [ + { name: "A", wikidataId: "Q11" }, + { name: "A", wikidataId: "Q11" }, + { name: "B", wikidataId: "Q12" }, + ], + } as any; + + const resolvedMain = { _id: "tm", name: "Main" }; + const resolvedFilterA = { _id: "ta", name: "A" }; + const resolvedFilterB = { _id: "tb", name: "B" }; + const updatedEvent = { _id: id, ...updateDto }; + + mockTopicService.findOrCreateTopic + .mockResolvedValueOnce(resolvedMain) + .mockResolvedValueOnce(resolvedFilterA) + .mockResolvedValueOnce(resolvedFilterB); + mockEventModel.findByIdAndUpdate.mockResolvedValue(updatedEvent); + + const result = await service.update(id, updateDto); + + expect(mockTopicService.findOrCreateTopic).toHaveBeenCalledTimes(3); + expect(mockEventModel.findByIdAndUpdate).toHaveBeenCalledWith( + id, + { + $set: expect.objectContaining({ + name: "Updated Event", + mainTopic: resolvedMain, + filterTopics: [resolvedFilterA, resolvedFilterB], + }), + }, + { new: true, runValidators: true } + ); + expect(result).toEqual(updatedEvent); + }); + + it("should throw NotFoundException if event is not found", async () => { + const id = "507f1f77bcf86cd799439011"; + mockEventModel.findByIdAndUpdate.mockResolvedValue(null); + + await expect(service.update(id, { name: "Updated" } as any)).rejects.toThrow( + NotFoundException + ); + }); + + it("should throw BadRequestException on cast errors", async () => { + const id = "507f1f77bcf86cd799439011"; + mockEventModel.findByIdAndUpdate.mockRejectedValue({ + name: "CastError", + path: "startDate", + }); + + await expect(service.update(id, { name: "Updated" } as any)).rejects.toThrow( + BadRequestException + ); + }); + + it("should throw InternalServerErrorException on unknown failures", async () => { + const id = "507f1f77bcf86cd799439011"; + mockEventModel.findByIdAndUpdate.mockRejectedValue(new Error("unexpected")); + + await expect(service.update(id, { name: "Updated" } as any)).rejects.toThrow( + InternalServerErrorException + ); + }); + }); + + describe("findAll", () => { + it("should return events list with pagination and order", async () => { + const eventsResult = [{ _id: "e1" }, { _id: "e2" }]; + const exec = jest.fn().mockResolvedValue(eventsResult); + const countExec = jest.fn().mockResolvedValue(eventsResult.length); + const lean = jest.fn().mockReturnValue({ exec: exec }); + const sort = jest.fn().mockReturnValue({ lean }); + const limit = jest.fn().mockReturnValue({ sort }); + const skip = jest.fn().mockReturnValue({ limit }); + + mockEventModel.find.mockReturnValue({ skip }); + mockEventModel.countDocuments = jest.fn().mockReturnValue({ exec: countExec }); + + const result = await service.findAll({ + page: 1, + pageSize: 5, + order: "desc", + status: "happening", + }); + + expect(mockEventModel.find).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: expect.any(Object), + endDate: expect.any(Object), + }) + ); + expect(skip).toHaveBeenCalledWith(5); + expect(limit).toHaveBeenCalledWith(5); + expect(sort).toHaveBeenCalledWith({ _id: "desc" }); + expect(result).toEqual({ + events: eventsResult, + total: eventsResult.length, + }); + }); + + it("should throw InternalServerErrorException when querying fails", async () => { + const exec = jest.fn().mockRejectedValue(new Error("db fail")); + const countExec = jest.fn().mockResolvedValue(0); + const lean = jest.fn().mockReturnValue({ exec: exec }); + const sort = jest.fn().mockReturnValue({ lean }); + const limit = jest.fn().mockReturnValue({ sort }); + const skip = jest.fn().mockReturnValue({ limit }); + + mockEventModel.find.mockReturnValue({ skip }); + mockEventModel.countDocuments = jest.fn().mockReturnValue({ exec: countExec }); + + await expect( + service.findAll({ page: 0, pageSize: 10, order: "asc", status: "upcoming" }) + ).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe("findByHash", () => { + it("should return event when hash exists", async () => { + const event = { _id: "e1", data_hash: "hash123" }; + const exec = jest.fn().mockResolvedValue(event); + const populateFilterTopics = jest.fn().mockReturnValue({ exec }); + const populateMainTopic = jest + .fn() + .mockReturnValue({ populate: populateFilterTopics }); + + mockEventModel.findOne.mockReturnValue({ populate: populateMainTopic }); + + const result = await service.findByHash("hash123"); + + expect(mockEventModel.findOne).toHaveBeenCalledWith({ data_hash: "hash123" }); + expect(result).toEqual(event); + }); + + it("should throw NotFoundException when hash does not exist", async () => { + const exec = jest.fn().mockResolvedValue(null); + const populateFilterTopics = jest.fn().mockReturnValue({ exec }); + const populateMainTopic = jest + .fn() + .mockReturnValue({ populate: populateFilterTopics }); + + mockEventModel.findOne.mockReturnValue({ populate: populateMainTopic }); + + await expect(service.findByHash("missing-hash")).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("getFullEventByHash", () => { + it("should return event with empty arrays when mainTopic is missing", async () => { + const eventWithoutMainTopic = { _id: "e1", data_hash: "h1", mainTopic: null }; + jest + .spyOn(service, "findByHash") + .mockResolvedValue(eventWithoutMainTopic as any); + + const result = await service.getFullEventByHash("h1"); + + expect(result.sentences).toEqual([]); + expect(result.verificationRequests).toEqual([]); + expect(mockSentenceService.getSentencesByTopics).not.toHaveBeenCalled(); + expect(mockVerificationRequestService.listAll).not.toHaveBeenCalled(); + }); + + it("should return full event data with sentences and verification requests", async () => { + const eventDocument = { + _id: "e1", + data_hash: "h1", + mainTopic: { name: "Climate", wikidataId: "Q1" }, + toObject: jest.fn().mockReturnValue({ + _id: "e1", + data_hash: "h1", + mainTopic: { name: "Climate", wikidataId: "Q1" }, + }), + }; + const mockSentences = [{ _id: "s1" }]; + const mockVerificationRequests = [{ _id: "vr1" }]; + + jest.spyOn(service, "findByHash").mockResolvedValue(eventDocument as any); + mockSentenceService.getSentencesByTopics.mockResolvedValue(mockSentences); + mockVerificationRequestService.listAll.mockResolvedValue( + mockVerificationRequests + ); + + const result = await service.getFullEventByHash("h1"); + + expect(mockSentenceService.getSentencesByTopics).toHaveBeenCalledWith(["Q1"]); + expect(mockVerificationRequestService.listAll).toHaveBeenCalledWith( + expect.objectContaining({ + topics: ["Climate"], + }) + ); + expect(result).toEqual( + expect.objectContaining({ + _id: "e1", + sentences: mockSentences, + verificationRequests: mockVerificationRequests, + }) + ); + }); + + it("should fallback to empty arrays when related queries fail", async () => { + const eventDocument = { + _id: "e1", + data_hash: "h1", + mainTopic: { name: "Climate", wikidataId: "Q1" }, + toObject: jest.fn().mockReturnValue({ + _id: "e1", + data_hash: "h1", + mainTopic: { name: "Climate", wikidataId: "Q1" }, + }), + }; + + jest.spyOn(service, "findByHash").mockResolvedValue(eventDocument as any); + mockSentenceService.getSentencesByTopics.mockRejectedValue( + new Error("sentence fail") + ); + mockVerificationRequestService.listAll.mockRejectedValue( + new Error("vr fail") + ); + + const result = await service.getFullEventByHash("h1"); + + expect(result.sentences).toEqual([]); + expect(result.verificationRequests).toEqual([]); + }); + }); +}); diff --git a/server/events/event.service.ts b/server/events/event.service.ts new file mode 100644 index 000000000..87f6bc5e3 --- /dev/null +++ b/server/events/event.service.ts @@ -0,0 +1,255 @@ +import { + BadRequestException, + ConflictException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException +} from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { FilterQuery, isValidObjectId, Model } from "mongoose"; +import { EventDocument, Event } from "./schema/event.schema"; +import { VerificationRequestService } from "../verification-request/verification-request.service"; +import { SentenceService } from "../claim/types/sentence/sentence.service"; +import { CreateEventDTO, UpdateEventDTO } from "./dto/event.dto"; +import { FilterEventsDTO } from "./dto/filter.dto"; +import { TopicService } from "../topic/topic.service"; +import { FindAllResponse, FullEventResponse } from "./types/event.interfaces"; +import * as crypto from "crypto"; + +@Injectable() +export class EventsService { + private readonly logger = new Logger(EventsService.name); + + constructor( + @InjectModel(Event.name) private eventModel: Model, + private readonly verificationRequestService: VerificationRequestService, + private readonly sentenceService: SentenceService, + private readonly topicService: TopicService, + ) { } + + /** + * Creates a new event, processes associated topics, and generates a data hash. + * * @param createEventDto - Data transfer object containing event details. + * @returns A promise that resolves to the created event document. + */ + async create(createEventDto: CreateEventDTO): Promise { + try { + this.logger.debug("Creating event", { createEventDto }); + + const createdTopic = await this.topicService.findOrCreateTopic({ + ...createEventDto.mainTopic, + name: createEventDto.mainTopic.label + }) + + const data_hash = crypto + .createHash("md5") + .update(`${createEventDto.name}-${createEventDto.startDate}`) + .digest("hex"); + + const newEvent = await this.eventModel.create({ + ...createEventDto, + data_hash, + mainTopic: createdTopic._id + }) + + this.logger.log(`Event created successfully: ${newEvent._id}`); + return newEvent; + } catch (error) { + this.logger.error(`Failed to create event: "${error.name}"`, error.stack); + + if (error.name === "ValidationError") { + const fields = Object.keys(error.errors).join(", "); + throw new BadRequestException(`Schema validation failed: missing or invalid fields [${fields}]`); + } + + if (error.code === 11000) { + const duplicateField = Object.keys(error.keyPattern)[0]; + throw new ConflictException(`Duplicate entry: an event with this ${duplicateField} already exists`); + } + + throw new InternalServerErrorException("Unexpected error during event creation"); + } + } + + /** + * Partially updates an existing event and synchronizes related topics if provided. + * @param id - The unique identifier of the event. + * @param updateEventDto - Data transfer object with partial event updates. + * @returns The updated event document. + */ + async update(id: string, updateEventDto: UpdateEventDTO): Promise { + try { + this.logger.debug(`Updating event ${id}`, { updateEventDto }); + + if (!isValidObjectId(id)) { + throw new BadRequestException(`Invalid event ID format: ${id}`); + } + + const { mainTopic, filterTopics, ...otherFields } = updateEventDto; + + const updateData: Partial = { ...otherFields }; + + if (mainTopic) { + updateData.mainTopic = await this.topicService.findOrCreateTopic(mainTopic); + } + + if (filterTopics !== undefined) { + const uniqueTopics = Array.from( + new Map(filterTopics.map(topic => [topic.name, topic])).values() + ); + + updateData.filterTopics = await Promise.all( + uniqueTopics.map(topic => this.topicService.findOrCreateTopic(topic)) + ); + } + + const updatedEvent = await this.eventModel.findByIdAndUpdate( + id, + { $set: updateData }, + { new: true, runValidators: true } + ); + + if (!updatedEvent) { + throw new NotFoundException(`Event not found with ID: ${id}`); + } + + return updatedEvent; + } catch (error) { + this.logger.error(`Failed to update event [${id}]`, error.stack); + + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + + if (error.name === "CastError") { + throw new BadRequestException(`Invalid format for field: ${error.path}`); + } + + throw new InternalServerErrorException("Unexpected error during event update"); + } + } + + /** + * Lists events with pagination and dynamic filters for status. + * Useful for public listings and SSR components. + * @param queryDto - DTO containing pagination and filter parameters. + * @returns A list of event documents. + */ + async findAll(queryDto: FilterEventsDTO): Promise { + const { page, pageSize, order, status } = queryDto; + try { + const query = this.buildStatusQuery(status); + + this.logger.debug(`Fetching events list. Page: ${page}, PageSize: ${pageSize}, Status: ${status}`); + this.logger.verbose(`Built MongoDB Query: ${JSON.stringify(query)}`); + + const [events, total] = await Promise.all([ + this.eventModel.find(query) + .skip(page * parseInt(`${pageSize}`, 10)) + .limit(parseInt(`${pageSize}`, 10)) + .sort({ _id: order }) + .lean() + .exec(), + + this.eventModel.countDocuments(query).exec() + ]); + + return { events, total }; + } catch (error) { + this.logger.error(`Failed to fetch events list: ${error.message}`, error.stack); + throw new InternalServerErrorException("An error occurred while fetching the events list."); + } + } + + /** + * Fetches the "Full Content" of an event for the SSR view page. + * Aggregates the core Event data, related Sentences, and Verification Requests. + * @param data_hash - The unique hash identifier of the event. + * @returns A composite object containing the event and its related data. + * @throws NotFoundException if the event does not exist. + */ + async getFullEventByHash(data_hash: string): Promise { + const currentEvent = await this.findByHash(data_hash); + + if (!currentEvent.mainTopic) { + this.logger.error(`Event ${data_hash} exists but is missing mainTopic relation`); + return { + ...currentEvent, + sentences: [], + verificationRequests: [] + }; + } + + const [eventSentences, eventVerificationRequest] = await Promise.all([ + this.sentenceService.getSentencesByTopics([currentEvent.mainTopic.wikidataId]) + .catch(err => { + this.logger.error(`Failed to fetch sentences for topic ${currentEvent.mainTopic.wikidataId}`, err.stack); + return []; + }), + this.verificationRequestService.listAll({ + page: 0, + pageSize: 10, + order: "asc", + topics: [currentEvent.mainTopic.name] + }).catch(err => { + this.logger.error(`Failed to fetch verification requests for topic ${currentEvent.mainTopic.name}`, err.stack); + return []; + }) + ]); + + return { + ...currentEvent.toObject(), + sentences: eventSentences, + verificationRequests: eventVerificationRequest + }; + } + + /** + * Searches for an event by its hash. + * * @param data_hash - URL identifier hash. + * @returns Event document. + */ + async findByHash(data_hash: string): Promise { + this.logger.debug(`Fetching public event with hash: ${data_hash}`); + + const event = (await this.eventModel.findOne({ data_hash }).populate("mainTopic").populate("filterTopics").exec()); + + if (!event) { + this.logger.warn(`Public access attempt failed - Hash not found: ${data_hash}`); + throw new NotFoundException(`The requested event was not found.`); + } + + return event; + } + + /** + * Internal helper to build MongoDB date queries based on event status. + * @param status - The status string (finalized, upcoming, happening). + * @returns A MongoDB query object. + */ + private buildStatusQuery(status?: string): FilterQuery { + const query: FilterQuery = {}; + const now = new Date(); + now.setHours(0, 0, 0, 0); + + if (!status || status === "all") return query; + + switch (status) { + case "finalized": + query.endDate = { $lt: now }; + break; + case "upcoming": + query.startDate = { $gt: now }; + break; + case "happening": + query.startDate = { $lte: now }; + query.endDate = { $gte: now }; + break; + default: + this.logger.warn(`Unknown status provided: ${status}`); + } + + return query; + } +} diff --git a/server/events/schema/event.schema.ts b/server/events/schema/event.schema.ts new file mode 100644 index 000000000..5e7cb19fe --- /dev/null +++ b/server/events/schema/event.schema.ts @@ -0,0 +1,44 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Document, Schema as MongooseSchema } from "mongoose"; +import { Topic } from "../../topic/schemas/topic.schema"; + +export type EventDocument = Event & Document; + +@Schema({ toObject: { virtuals: true }, toJSON: { virtuals: true }, timestamps: true }) +export class Event { + @Prop({ required: true, unique: true }) + data_hash: string; + + @Prop({ required: true, trim: true }) + badge: string; + + @Prop({ required: true, trim: true }) + name: string; + + @Prop({ required: true }) + description: string; + + @Prop({ required: true }) + location: string; + + @Prop({ required: true, default: Date.now }) + startDate: Date; + + @Prop({ required: true, default: Date.now, index: true }) + endDate: Date; + + @Prop({ + type: MongooseSchema.Types.ObjectId, + required: true, + ref: "Topic", + }) + mainTopic: Topic; + + @Prop({ + type: [{ type: MongooseSchema.Types.ObjectId, ref: "Topic" }], + required: false, + }) + filterTopics: Topic[]; +} + +export const EventSchema = SchemaFactory.createForClass(Event); diff --git a/server/events/types/event.interfaces.ts b/server/events/types/event.interfaces.ts new file mode 100644 index 000000000..5dc5f5b5d --- /dev/null +++ b/server/events/types/event.interfaces.ts @@ -0,0 +1,13 @@ +import { TopicRelatedSentencesResponse } from "../../claim/types/sentence/types/sentence.interfaces"; +import { VerificationRequest } from "../../verification-request/schemas/verification-request.schema"; +import { Event } from "../schema/event.schema"; + +export type FullEventResponse = Event & { + sentences: TopicRelatedSentencesResponse[]; + verificationRequests: VerificationRequest[]; +}; + +export interface FindAllResponse { + events: Event[]; + total: number; +} diff --git a/server/mocks/EventMock.ts b/server/mocks/EventMock.ts new file mode 100644 index 000000000..6384e042b --- /dev/null +++ b/server/mocks/EventMock.ts @@ -0,0 +1,44 @@ +export const mockEventModel = { + create: jest.fn(), + findByIdAndUpdate: jest.fn(), + find: jest.fn(), + countDocuments: jest.fn(), + findOne: jest.fn(), +}; + +export const mockVerificationRequestService = { + listAll: jest.fn(), +}; + +export const mockSentenceService = { + getSentencesByTopics: jest.fn(), +}; + +export const mockTopicService = { + findOrCreateTopic: jest.fn(), +}; + +export const mockCreateEventDto = { + badge: "event-badge", + name: "My Event", + description: "My Event Description", + location: "NYC", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-10T00:00:00.000Z"), + mainTopic: { name: "Climate", label: "Climate", wikidataId: "Q1" }, +}; + +export const mockEventsService = { + create: jest.fn(), + update: jest.fn(), + findAll: jest.fn(), + getFullEventByHash: jest.fn(), +}; + +export const mockConfigService = { + get: jest.fn().mockReturnValue("test-site-key"), +}; + +export const mockViewService = { + render: jest.fn(), +}; diff --git a/server/tests/event.e2e.spec.ts b/server/tests/event.e2e.spec.ts new file mode 100644 index 000000000..8cc7ded9f --- /dev/null +++ b/server/tests/event.e2e.spec.ts @@ -0,0 +1,249 @@ +import * as request from "supertest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { ValidationPipe } from "@nestjs/common"; +import { AppModule } from "../app.module"; +import { SessionGuard } from "../auth/session.guard"; +import { SessionGuardMock } from "./mocks/SessionGuardMock"; +import { SessionOrM2MGuard } from "../auth/m2m-or-session.guard"; +import { SessionOrM2MGuardMock } from "./mocks/SessionOrM2MGuardMock"; +import { M2MGuard } from "../auth/m2m.guard"; +import { M2MGuardMock } from "./mocks/M2MGuardMock"; +import { AbilitiesGuard } from "../auth/ability/abilities.guard"; +import { AbilitiesGuardMock } from "./mocks/AbilitiesGuardMock"; +import { TestConfigOptions } from "./utils/TestConfigOptions"; +import { SeedTestUser } from "./utils/SeedTestUser"; +import { CleanupDatabase } from "./utils/CleanupDatabase"; + +jest.setTimeout(10000); + +describe("EventController (e2e)", () => { + let app: any; + let createdEventId: string; + + const createEventPayload = ( + name: string, + startDate: Date, + endDate: Date, + mainTopicName = "Climate" + ) => ({ + badge: "event-badge", + name, + description: `${name} description`, + location: "New York", + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + mainTopic: { + label: mainTopicName, + name: mainTopicName, + wikidataId: "Q125928", + }, + }); + + beforeAll(async () => { + const mongoUri = process.env.MONGO_URI!; + + await SeedTestUser(mongoUri); + + const testConfig = { + ...TestConfigOptions.config, + db: { + ...TestConfigOptions.config.db, + connection_uri: mongoUri, + }, + }; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(testConfig)], + }) + .overrideGuard(SessionGuard) + .useValue(SessionGuardMock) + .overrideGuard(SessionOrM2MGuard) + .useValue(SessionOrM2MGuardMock) + .overrideGuard(M2MGuard) + .useValue(M2MGuardMock) + .overrideGuard(AbilitiesGuard) + .useValue(AbilitiesGuardMock) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + transformOptions: { enableImplicitConversion: true }, + whitelist: true, + forbidNonWhitelisted: true, + }) + ); + + await app.init(); + }); + + it("/api/event (GET) - should return empty list initially", () => { + return request(app.getHttpServer()) + .get("/api/event") + .query({ + page: 0, + pageSize: 10, + order: "asc", + }) + .expect(200) + .expect(({ body }) => { + expect(body).toHaveProperty("events"); + expect(body).toHaveProperty("total"); + expect(Array.isArray(body.events)).toBe(true); + expect(body.events).toHaveLength(0); + expect(body.total).toBe(0); + }); + }); + + it("/api/event (POST) - should create a new event", () => { + const now = new Date(); + const start = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000); + const end = new Date(now.getTime() + 6 * 24 * 60 * 60 * 1000); + + return request(app.getHttpServer()) + .post("/api/event") + .send(createEventPayload("Future Event Alpha", start, end)) + .expect(201) + .expect(({ body }) => { + createdEventId = body._id; + + expect(body).toHaveProperty("_id"); + expect(body).toHaveProperty("data_hash"); + expect(body.name).toEqual("Future Event Alpha"); + expect(body).toHaveProperty("mainTopic"); + }); + }); + + it("/api/event (POST) - should reject duplicate event by unique data_hash", async () => { + const now = new Date(); + const start = new Date(now.getTime() + 8 * 24 * 60 * 60 * 1000); + const end = new Date(now.getTime() + 9 * 24 * 60 * 60 * 1000); + const payload = createEventPayload("Duplicate Event", start, end); + + await request(app.getHttpServer()) + .post("/api/event") + .send(payload) + .expect(201); + + return request(app.getHttpServer()) + .post("/api/event") + .send(payload) + .expect(409); + }); + + it("/api/event (GET) - should list created events", () => { + return request(app.getHttpServer()) + .get("/api/event") + .query({ + page: 0, + pageSize: 20, + order: "asc", + }) + .expect(200) + .expect(({ body }) => { + expect(body).toHaveProperty("events"); + expect(body).toHaveProperty("total"); + expect(Array.isArray(body.events)).toBe(true); + expect(body.events.length).toBeGreaterThanOrEqual(2); + expect(body.total).toBeGreaterThanOrEqual(2); + + const created = body.events.find((event) => event._id === createdEventId); + expect(created).toBeDefined(); + expect(created.name).toEqual("Future Event Alpha"); + }); + }); + + it("/api/event/:id (PATCH) - should update event name", () => { + return request(app.getHttpServer()) + .patch(`/api/event/${createdEventId}`) + .send({ + name: "Future Event Alpha Updated", + }) + .expect(200) + .expect(({ body }) => { + expect(body._id).toEqual(createdEventId); + expect(body.name).toEqual("Future Event Alpha Updated"); + }); + }); + + it("/api/event/:id (PATCH) - should return 400 for invalid ObjectId", () => { + return request(app.getHttpServer()) + .patch("/api/event/invalid-id") + .send({ name: "No Update" }) + .expect(400); + }); + + it("/api/event (GET) - should filter by status=upcoming", async () => { + const now = new Date(); + const start = new Date(now.getTime() + 12 * 24 * 60 * 60 * 1000); + const end = new Date(now.getTime() + 13 * 24 * 60 * 60 * 1000); + + await request(app.getHttpServer()) + .post("/api/event") + .send(createEventPayload("Future Event Beta", start, end, "Politics")) + .expect(201); + + return request(app.getHttpServer()) + .get("/api/event") + .query({ + page: 0, + pageSize: 20, + order: "asc", + status: "upcoming", + }) + .expect(200) + .expect(({ body }) => { + expect(body).toHaveProperty("events"); + expect(body).toHaveProperty("total"); + expect(Array.isArray(body.events)).toBe(true); + expect(body.events.length).toBeGreaterThanOrEqual(1); + expect(body.total).toBeGreaterThanOrEqual(1); + + for (const event of body.events) { + expect(new Date(event.startDate).getTime()).toBeGreaterThan(Date.now() - 24 * 60 * 60 * 1000); + } + }); + }); + + it("/api/event (GET) - should accept unknown status and still return list", () => { + return request(app.getHttpServer()) + .get("/api/event") + .query({ + page: 0, + pageSize: 20, + order: "asc", + status: "unknown-status", + }) + .expect(200) + .expect(({ body }) => { + expect(body).toHaveProperty("events"); + expect(body).toHaveProperty("total"); + expect(Array.isArray(body.events)).toBe(true); + expect(body.events.length).toBeGreaterThanOrEqual(1); + expect(body.total).toBeGreaterThanOrEqual(1); + }); + }); + + it("/api/event (POST) - should validate required fields", () => { + return request(app.getHttpServer()) + .post("/api/event") + .send({ + name: "Invalid Event Payload", + }) + .expect(400); + }); + + it("/api/event/:id (PATCH) - should return 404 for non-existent event id", () => { + return request(app.getHttpServer()) + .patch("/api/event/507f1f77bcf86cd799439011") + .send({ name: "Not found update" }) + .expect(404); + }); + + afterAll(async () => { + jest.restoreAllMocks(); + await app.close(); + await CleanupDatabase(process.env.MONGO_URI!); + }); +}); diff --git a/server/topic/topic.service.ts b/server/topic/topic.service.ts index c00b62194..96732ab49 100644 --- a/server/topic/topic.service.ts +++ b/server/topic/topic.service.ts @@ -5,6 +5,7 @@ import { Topic, TopicDocument } from "./schemas/topic.schema"; import slugify from "slugify"; import { SentenceService } from "../claim/types/sentence/sentence.service"; import { ContentModelEnum } from "../types/enums"; +import { TopicData } from "../topic/types/topic.interfaces"; import { ImageService } from "../claim/types/image/image.service"; import { WikidataService } from "../wikidata/wikidata.service"; @@ -217,12 +218,7 @@ export class TopicService { * @param topicData object with { slug?, name, wikidataId?, language?, description? } * @returns the existing or newly created topic document */ - async findOrCreateTopic(topicData: { - name: string; - wikidataId?: string; - language?: string; - description?: string; // Future use - }): Promise { + async findOrCreateTopic(topicData: TopicData): Promise { try { const slug = slugify(topicData.name, { lower: true, strict: true }); diff --git a/server/topic/types/topic.interfaces.ts b/server/topic/types/topic.interfaces.ts new file mode 100644 index 000000000..002577e8a --- /dev/null +++ b/server/topic/types/topic.interfaces.ts @@ -0,0 +1,7 @@ +export interface TopicData { + label?: string; + name: string; + wikidataId?: string; + language?: string; + description?: string; // Future use +} diff --git a/server/verification-request/verification-request.service.ts b/server/verification-request/verification-request.service.ts index 65a350786..6486b9b21 100644 --- a/server/verification-request/verification-request.service.ts +++ b/server/verification-request/verification-request.service.ts @@ -370,7 +370,7 @@ export class VerificationRequestService { await this.topicService.findOrCreateTopic( topicData ); - return (topic as any)._id; + return topic._id; }) ); @@ -387,7 +387,7 @@ export class VerificationRequestService { const topic = await this.topicService.findOrCreateTopic( result ); - valueToUpdate = (topic as any)._id; + valueToUpdate = topic._id; this.logger.log( `Impact area topic created/found with ID: ${valueToUpdate}` ); diff --git a/src/api/eventApi.ts b/src/api/eventApi.ts new file mode 100644 index 000000000..3c28b37e5 --- /dev/null +++ b/src/api/eventApi.ts @@ -0,0 +1,98 @@ +import axios from "axios"; +import { TFunction } from "i18next"; +import { MessageManager } from "../components/Messages"; +import { HEX24 } from "../types/History"; +import { EventPayload, ListEventsOptions } from "../types/event"; +import { NextRouter } from "next/router"; +import { NameSpaceEnum } from "../types/Namespace"; + +const request = axios.create({ + withCredentials: true, + baseURL: `/api/event`, +}); + +const createEvent = ( + newEvent: EventPayload, + router: NextRouter, + t?: TFunction +) => { + const { nameSpace = NameSpaceEnum.Main } = newEvent; + + return request + .post("/", newEvent) + .then((response) => { + MessageManager.showMessage( + "success", + t("events:eventCreateSuccess") + ); + + router.push( + nameSpace === NameSpaceEnum.Main + ? "/event" + : `/${nameSpace}/event` + ); + + return response.data + }) + .catch((err) => { + MessageManager.showMessage( + "error", + t("events:createError") + ); + throw err; + }); +}; + +const updateEvent = (eventId: string, updatedEvent: Partial, t?: TFunction) => { + if (!HEX24.test(eventId)) { + MessageManager.showMessage( + "error", + t("events:errorInvalidId") + ); + return Promise.reject(new Error("Invalid ID")); + } + + return request + .patch(`/${eventId}`, updatedEvent) + .then((response) => response.data) + .catch((err) => { + MessageManager.showMessage( + "error", + t("events:updateError") + ); + throw err; + }); +}; + +const getEvents = (options: ListEventsOptions = {}, t?: TFunction) => { + const params = { + page: options.page ? options.page - 1 : 0, + pageSize: options.pageSize ?? 10, + order: options.order ?? "asc", + status: options.status, + }; + + return request + .get("/", { params }) + .then((response) => { + return { + events: response.data.events, + total: response.data.total + }; + }) + .catch((err) => { + MessageManager.showMessage( + "error", + t("events:fetchError") + ); + throw err; + }); +}; + +const EventApi = { + createEvent, + updateEvent, + getEvents, +}; + +export default EventApi; diff --git a/src/components/AffixButton/AffixButton.tsx b/src/components/AffixButton/AffixButton.tsx index 514dbb07a..9b2366edc 100644 --- a/src/components/AffixButton/AffixButton.tsx +++ b/src/components/AffixButton/AffixButton.tsx @@ -4,7 +4,8 @@ import { AddOutlined, PersonAddAlt1Outlined, Source, - Report + Report, + Event } from "@mui/icons-material"; import { useAtom } from "jotai"; import Cookies from "js-cookie"; @@ -86,6 +87,15 @@ const AffixButton = ({ personalitySlug }: AffixButtonProps) => { ? `/${nameSpace}/verification-request/create` : `/verification-request/create`, dataCy: "testFloatButtonAddVerificationRequest", + }, + { + icon: , + tooltip: t("affix:affixButtonCreateVerificationRequest"), + href: + nameSpace !== NameSpaceEnum.Main + ? `/${nameSpace}/event/create` + : `/event/create`, + dataCy: "testFloatButtonAddEvent", } ); @@ -203,10 +213,10 @@ const AffixButton = ({ personalitySlug }: AffixButtonProps) => { > ]} + components={[]} />

- +
void; - disableFutureDates?: boolean; isLoading: boolean; disclaimer?: string; dateExtraText: string; @@ -38,7 +37,6 @@ interface BaseClaimFormProps { const BaseClaimForm = ({ content, handleSubmit, - disableFutureDates, isLoading, disclaimer, dateExtraText, @@ -57,10 +55,6 @@ const BaseClaimForm = ({ const router = useRouter(); const [disableSubmit, setDisableSubmit] = useState(true); - const disabledDate = (current) => { - return disableFutureDates && current && current > moment().endOf("day"); - }; - const onChangeCaptcha = (captchaString) => { setRecaptcha(captchaString); const hasRecaptcha = !!captchaString; @@ -127,8 +121,7 @@ const BaseClaimForm = ({ setDate(value); clearError("date"); }} - data-cy={"testSelectDate"} - disabledDate={disabledDate} + dataCy="testSelectDate" /> {errors?.date && ( diff --git a/src/components/Claim/CreateClaim/ClaimCreate.tsx b/src/components/Claim/CreateClaim/ClaimCreate.tsx index 05e180b8d..58d350bd5 100644 --- a/src/components/Claim/CreateClaim/ClaimCreate.tsx +++ b/src/components/Claim/CreateClaim/ClaimCreate.tsx @@ -11,7 +11,6 @@ const ClaimCreate = () => { return ( { ); }; -export default ClaimCreate; \ No newline at end of file +export default ClaimCreate; diff --git a/src/components/Claim/CreateClaim/ClaimUploadImage.tsx b/src/components/Claim/CreateClaim/ClaimUploadImage.tsx index 39503866e..b4d28d56e 100644 --- a/src/components/Claim/CreateClaim/ClaimUploadImage.tsx +++ b/src/components/Claim/CreateClaim/ClaimUploadImage.tsx @@ -104,7 +104,6 @@ const ClaimUploadImage = () => {
{ + return ( + + + + {message} + + + + ); +}; + +export default ErrorState; diff --git a/src/components/Event/EventForm/CreateEventForm.ts b/src/components/Event/EventForm/CreateEventForm.ts new file mode 100644 index 000000000..34f78b548 --- /dev/null +++ b/src/components/Event/EventForm/CreateEventForm.ts @@ -0,0 +1,49 @@ +import { createFormField, FormField } from "../../Form/FormField"; + +const lifecycleEventForm: FormField[] = [ + createFormField({ + fieldName: "badge", + type: "text", + defaultValue: "", + i18nNamespace: "events" + }), + createFormField({ + fieldName: "name", + type: "text", + defaultValue: "", + i18nNamespace: "events" + }), + createFormField({ + fieldName: "description", + type: "textArea", + defaultValue: "", + i18nNamespace: "events" + }), + createFormField({ + fieldName: "location", + type: "text", + defaultValue: "", + i18nNamespace: "events" + }), + createFormField({ + fieldName: "startDate", + type: "date", + defaultValue: "", + i18nNamespace: "events" + }), + createFormField({ + fieldName: "endDate", + type: "date", + defaultValue: "", + i18nNamespace: "events" + }), + createFormField({ + fieldName: "mainTopic", + type: "selectImpactArea", //standardize the type and component to a more generic name + defaultValue: "", + hasTooltip: true, + i18nNamespace: "events", + }), +]; + +export default lifecycleEventForm; diff --git a/src/components/Event/EventForm/CreateEventView.tsx b/src/components/Event/EventForm/CreateEventView.tsx new file mode 100644 index 000000000..337135d2a --- /dev/null +++ b/src/components/Event/EventForm/CreateEventView.tsx @@ -0,0 +1,54 @@ +import React, { useState } from "react"; +import { Grid } from "@mui/material"; +import colors from "../../../styles/colors"; +import { useTranslation } from "next-i18next"; +import { useRouter } from "next/router"; +import { useAtom } from "jotai"; +import { currentNameSpace } from "../../../atoms/namespace"; +import EventApi from "../../../api/eventApi"; +import DynamicEventForm from "./DynamicEventForm"; + +const CreateEventView = () => { + const router = useRouter(); + const { t } = useTranslation(); + const [nameSpace] = useAtom(currentNameSpace); + const [isLoading, setIsLoading] = useState(false); + const [recaptchaString, setRecaptchaString] = useState(""); + const hasCaptcha = !!recaptchaString; + + const onSubmit = (data) => { + const newEvent = { + nameSpace, + badge: data.badge, + name: data.name, + description: data.description, + location: data.location, + startDate: data.startDate, + endDate: data.endDate, + mainTopic: data.mainTopic, + recaptcha: recaptchaString, + }; + + EventApi + .createEvent(newEvent, router, t) + .then((s) => { + router.push(`/event/${s.data_hash}`); + setIsLoading(false); + }); + }; + + return ( + + + + + + ); +}; + +export default CreateEventView; diff --git a/src/components/Event/EventForm/DynamicEventForm.tsx b/src/components/Event/EventForm/DynamicEventForm.tsx new file mode 100644 index 000000000..a5d19864a --- /dev/null +++ b/src/components/Event/EventForm/DynamicEventForm.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { useForm } from "react-hook-form"; +import DynamicForm from "../../Form/DynamicForm"; +import SharedFormFooter from "../../SharedFormFooter"; +import { IDynamicEventForm } from "../../../types/event"; +import lifecycleEventForm from "./CreateEventForm"; + +const DynamicEventForm = ({ + data, + onSubmit, + isLoading, + setRecaptchaString, + hasCaptcha, + isDrawerOpen = false, + onClose = () => { }, +}: IDynamicEventForm) => { + const { + handleSubmit, + control, + formState: { errors }, + } = useForm(); + + return ( +
+ + + + + ); +}; + +export default DynamicEventForm; diff --git a/src/components/Event/EventForm/EventDrawer.tsx b/src/components/Event/EventForm/EventDrawer.tsx new file mode 100644 index 000000000..4080e0739 --- /dev/null +++ b/src/components/Event/EventForm/EventDrawer.tsx @@ -0,0 +1,10 @@ + + +const EventDrawer = () => { + + return ( + "EventDrawer" + ) +} + +export default EventDrawer diff --git a/src/components/Event/EventList/EventCard.tsx b/src/components/Event/EventList/EventCard.tsx new file mode 100644 index 000000000..753fb4bc2 --- /dev/null +++ b/src/components/Event/EventList/EventCard.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Grid } from "@mui/material"; +import CardBase from "../../CardBase"; +import { i18n } from "next-i18next"; +import { EventPayload } from "../../../types/event"; +import EventCardHeader from "./EventCardHeader"; +import EventCardTitle from "./EventCardTitle"; +import EventCardDateRange from "./EventCardDateRange"; +import EventCardAction from "./EventCardAction"; + +interface EventCardProps { + event: EventPayload; + openEventLabel: string; +} + +const EventCard = ({ event, openEventLabel }: EventCardProps) => { + const currentLocale = i18n.language || "pt"; + return ( + + + + + + + } + /> + ); +}; + +export default EventCard; diff --git a/src/components/Event/EventList/EventCardAction.tsx b/src/components/Event/EventList/EventCardAction.tsx new file mode 100644 index 000000000..52c9c2ddb --- /dev/null +++ b/src/components/Event/EventList/EventCardAction.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import AletheiaButton, { ButtonType } from "../../Button"; + +interface EventCardActionProps { + label: string; + href: string; +} + +const EventCardAction = ({ label, href }: EventCardActionProps) => { + return ( + + {label} + + ); +}; + +export default EventCardAction; diff --git a/src/components/Event/EventList/EventCardDateRange.tsx b/src/components/Event/EventList/EventCardDateRange.tsx new file mode 100644 index 000000000..bb5189c1f --- /dev/null +++ b/src/components/Event/EventList/EventCardDateRange.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Grid, Typography } from "@mui/material"; +import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; +import colors from "../../../styles/colors"; + +interface EventCardDateRangeProps { + startDate: Date; + endDate: Date; + locale: string; +} + +const EventCardDateRange = ({ startDate, endDate, locale }: EventCardDateRangeProps) => { + return ( + + + + {`${new Date(startDate).toLocaleDateString(locale)} - ${new Date(endDate).toLocaleDateString(locale)}`} + + + ); +}; + +export default EventCardDateRange; diff --git a/src/components/Event/EventList/EventCardHeader.tsx b/src/components/Event/EventList/EventCardHeader.tsx new file mode 100644 index 000000000..447715f0c --- /dev/null +++ b/src/components/Event/EventList/EventCardHeader.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Grid, Typography } from "@mui/material"; +import colors from "../../../styles/colors"; + +interface EventCardHeaderProps { + badge: string; + location: string; +} + +const EventCardHeader = ({ badge, location }: EventCardHeaderProps) => { + return ( + + + {badge} + + + {location} + + + ); +}; + +export default EventCardHeader; diff --git a/src/components/Event/EventList/EventCardTitle.tsx b/src/components/Event/EventList/EventCardTitle.tsx new file mode 100644 index 000000000..52c319264 --- /dev/null +++ b/src/components/Event/EventList/EventCardTitle.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Grid, Typography } from "@mui/material"; +import colors from "../../../styles/colors"; + +interface EventCardTitleProps { + title: string; +} + +const EventCardTitle = ({ title }: EventCardTitleProps) => { + return ( + + + {title} + + + ); +}; + +export default EventCardTitle; diff --git a/src/components/Event/EventList/EventFilters.tsx b/src/components/Event/EventList/EventFilters.tsx new file mode 100644 index 000000000..88e5168ab --- /dev/null +++ b/src/components/Event/EventList/EventFilters.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Grid } from "@mui/material"; +import AletheiaButton, { ButtonType } from "../../Button"; +import { EventStatus } from "../../../types/event"; + +interface EventFiltersProps { + selectedStatus: EventStatus; + onStatusChange: (status: EventStatus) => void; + t: (key: string) => string; +} + +const EventFilters = ({ selectedStatus, onStatusChange, t }: EventFiltersProps) => { + + const filterOptions = [ + { status: "all" as const, label: t("events:filterAll") }, + { status: "happening" as const, label: t("events:filterHappening") }, + { status: "upcoming" as const, label: t("events:filterUpcoming") }, + { status: "finalized" as const, label: t("events:filterFinished") }, + ]; + + return ( + + {filterOptions.map(({ status, label }) => ( + onStatusChange(status)} + rounded={"true"} + style={{ fontWeight: 600 }} + > + {label} + + ))} + + ); +}; + +export default EventFilters; diff --git a/src/components/Event/EventList/EventLoadMore.tsx b/src/components/Event/EventList/EventLoadMore.tsx new file mode 100644 index 000000000..5a4af15a2 --- /dev/null +++ b/src/components/Event/EventList/EventLoadMore.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Grid } from "@mui/material"; +import AletheiaButton, { ButtonType } from "../../Button"; + +interface EventLoadMoreProps { + visible: boolean; + onLoadMore: () => void; + label: string; +} + +const EventLoadMore = ({ visible, onLoadMore, label }: EventLoadMoreProps) => { + if (!visible) { + return null; + } + + return ( + + + {label} + + + ); +}; + +export default EventLoadMore; diff --git a/src/components/Event/EventList/EventTitle.tsx b/src/components/Event/EventList/EventTitle.tsx new file mode 100644 index 000000000..896ce3a52 --- /dev/null +++ b/src/components/Event/EventList/EventTitle.tsx @@ -0,0 +1,24 @@ +import { Grid, Typography } from "@mui/material"; +import colors from "../../../styles/colors"; + +interface EventTitleProps { + total: number; + t: (key: string, options?: { total: number }) => string; +} + +const EventTitle = ({ total, t }: EventTitleProps) => ( + + {t("events:eventsList")} + + {t("events:totalItems", { + total: total, + })} + + +) + +export default EventTitle diff --git a/src/components/Event/EventList/EventsList.tsx b/src/components/Event/EventList/EventsList.tsx new file mode 100644 index 000000000..2a3ec43c7 --- /dev/null +++ b/src/components/Event/EventList/EventsList.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Grid } from "@mui/material"; +import GridList from "../../GridList"; +import EventApi from "../../../api/eventApi"; +import Loading from "../../Loading"; +import { EventPayload, ListEventsOptions } from "../../../types/event"; +import EventCard from "./EventCard"; +import ErrorState from "../../ErrorState"; +import EventFilters from "./EventFilters"; +import EventLoadMore from "./EventLoadMore"; +import EventTitle from "./EventTitle"; + +interface IData { + events: EventPayload[], + total: number +} + +const EventsList = () => { + const { t } = useTranslation(); + const [data, setData] = useState({ events: [], total: 0 }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [query, setQuery] = useState({ + page: 1, + pageSize: 10, + order: "asc", + status: "all", + }) + + const handleFetch = async () => { + setLoading(true); + setError(null); + try { + const events = await EventApi.getEvents(query); + + if (query.page === 1) { + setData(events) + } else { + setData(prev => ({ + events: [...prev.events, ...events.events], + total: events.total + })) + } + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + handleFetch(); + }, [query]); + + if (loading) { + return ; + } + + if (error) { + return + } + + return ( +
+ + setQuery(prev => ({ ...prev, status, page: 1 }))} + t={t} + /> + + + } + dataSource={data.events} + loggedInMaxColumns={6} + disableSeeMoreButton={true} + hasDivider={true} + renderItem={(event) => + + } + /> + + data.events.length} + onLoadMore={() => setQuery(prev => ({ ...prev, page: prev.page + 1 }))} + label={t("events:loadMoreButton")} + /> + + +
+ ) +} + +export default EventsList diff --git a/src/components/Form/DatePickerInput.tsx b/src/components/Form/DatePickerInput.tsx index 78f37224d..100e0b43a 100644 --- a/src/components/Form/DatePickerInput.tsx +++ b/src/components/Form/DatePickerInput.tsx @@ -5,7 +5,7 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import colors from "../../styles/colors"; import styled from "styled-components"; import { useTranslation } from "next-i18next"; -import { useState } from "react"; +import { CSSProperties, useState } from "react"; import dayjs from "dayjs"; const StyledTextField = styled(TextField)` @@ -39,36 +39,55 @@ const StyledTextField = styled(TextField)` } `; -const DatePickerInput = (props) => { - const { t } = useTranslation(); - const [value, setValue] = useState(props.defaultValue || null); - const [open, setOpen] = useState(false); +interface IDatePickerInput { + defaultValue?: any, + placeholder: string, + onChange: (value: any) => void, + disabledFuture?: boolean, + dataCy?: string, + disabled?: boolean, + style?: CSSProperties, +} - return ( - - setOpen(false)} - onChange={(newValue) => { - setValue(newValue); - props.onChange?.(newValue); - }} - maxDate={dayjs()} - renderInput={(params) => - setOpen(true)} - data-cy={props.dataCy} - {...props} - />} - PopperProps={{ placement: 'bottom-start', }} - desktopModeMediaQuery="@media (min-width: 0)" - /> - - ); +const DatePickerInput = ({ + defaultValue, + placeholder, + onChange, + disabledFuture = true, + dataCy, + disabled, + style +}: IDatePickerInput) => { + const { t } = useTranslation(); + const [value, setValue] = useState(defaultValue || null); + const [open, setOpen] = useState(false); + + return ( + + setOpen(false)} + onChange={(newValue) => { + setValue(newValue); + onChange?.(newValue); + }} + disableFuture={disabledFuture} + disabled={disabled} + renderInput={(params) => + setOpen(true)} + data-cy={dataCy} + style={{ ...style }} + />} + PopperProps={{ placement: 'bottom-start', }} + desktopModeMediaQuery="@media (min-width: 0)" + /> + + ); }; export default DatePickerInput; diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx index 9a84795b3..97cab4e0e 100644 --- a/src/components/Form/DynamicForm.tsx +++ b/src/components/Form/DynamicForm.tsx @@ -4,13 +4,15 @@ import DynamicInput from "./DynamicInput"; import React from "react"; import colors from "../../styles/colors"; import { useTranslation } from "next-i18next"; +import InfoTooltip from "../Claim/InfoTooltip"; +import { InfoOutlined } from "@mui/icons-material"; const DynamicForm = ({ currentForm, control, errors, machineValues = {}, - disabledDate = {}, + disabledFuture = true, }) => { const { t } = useTranslation(); return ( @@ -43,6 +45,15 @@ const DynamicForm = ({ * } {t(label)} + {fieldItem.hasTooltip ? + + } + useCustomStyle={false} + /> : null + } )} diff --git a/src/components/Form/DynamicInput.tsx b/src/components/Form/DynamicInput.tsx index 9596e57b4..d6b049c94 100644 --- a/src/components/Form/DynamicInput.tsx +++ b/src/components/Form/DynamicInput.tsx @@ -32,7 +32,7 @@ interface DynamicInputProps { defaultValue: UnifiedDefaultValue; "data-cy": string; extraProps: any; - disabledDate?: any; + disabledFuture: boolean; disabled?: boolean; } @@ -171,8 +171,8 @@ const DynamicInput = (props: DynamicInputProps) => { defaultValue={props.defaultValue} placeholder={t(props.placeholder)} onChange={(value) => props.onChange(value)} - data-cy="testSelectDate" - disabledDate={props.disabledDate} + dataCy="testSelectDate" + disabledFuture={props.disabledFuture} disabled={props.disabled} style={{ backgroundColor: props.disabled ? colors.lightNeutral : colors.white }} /> diff --git a/src/components/Form/FormField.ts b/src/components/Form/FormField.ts index ac53c8d44..65f63b24d 100644 --- a/src/components/Form/FormField.ts +++ b/src/components/Form/FormField.ts @@ -17,6 +17,7 @@ export type FormField = { defaultValue: string | []; extraProps?: FormFieldExtraProps; disabled?: boolean; + hasTooltip?: boolean; }; // Use to add properties specific to one type of field @@ -40,6 +41,7 @@ interface CreateFormFieldProps extends Partial { required?: boolean; isURLField?: boolean; disabled?: boolean; + hasTooltip?: boolean; } const createFormField = (props: CreateFormFieldProps): FormField => { @@ -53,6 +55,7 @@ const createFormField = (props: CreateFormFieldProps): FormField => { required = true, isURLField = false, disabled = false, + hasTooltip = false, } = props; return { @@ -62,6 +65,7 @@ const createFormField = (props: CreateFormFieldProps): FormField => { placeholder: `${i18nNamespace}:${i18nKey}Placeholder`, defaultValue, disabled, + hasTooltip, ...props, rules: { required: !disabled && required && "common:requiredFieldError", @@ -111,7 +115,7 @@ const fieldValidation = (value, validationFunction) => { } if (dayjs.isDayjs(value)) { - return dayjs(value).isValid() && dayjs(value).isBefore(dayjs()); + return dayjs(value).isValid() } if (Array.isArray(value) && value.length > 0 && (value[0].uid || value[0].originFileObj)) { @@ -126,9 +130,14 @@ const fieldValidation = (value, validationFunction) => { if (typeof value === "string") { return validationFunction(value); } - if (typeof value === "object") { + + if (typeof value === "object" && value !== null) { + if ("value" in value) { + return validationFunction(value.value); + } return value.every((v) => validationFunction(v)); } + return false; }; diff --git a/src/components/GridList.tsx b/src/components/GridList.tsx index 9ff5e1c16..f20a78a55 100644 --- a/src/components/GridList.tsx +++ b/src/components/GridList.tsx @@ -13,10 +13,14 @@ const GridList = ({ dataCy = "", seeMoreButtonLabel = "", disableSeeMoreButton = false, + hasDivider = false, }) => { return ( <> - {title} + {dataSource.map((item) => ( @@ -41,7 +45,7 @@ const GridList = ({ }} > )} diff --git a/src/components/Personality/MorePersonalities.tsx b/src/components/Personality/MorePersonalities.tsx index a0185efcb..fe6f94484 100644 --- a/src/components/Personality/MorePersonalities.tsx +++ b/src/components/Personality/MorePersonalities.tsx @@ -35,9 +35,9 @@ const MorePersonalities = ({ personalities, href, title }) => { > {!vw?.md && ( - - {t("home:sectionTitle2")} - + )} diff --git a/src/components/SectionTitle.tsx b/src/components/SectionTitle.tsx index aff8e1cb0..0dee33695 100644 --- a/src/components/SectionTitle.tsx +++ b/src/components/SectionTitle.tsx @@ -1,22 +1,35 @@ import React from "react"; import colors from "../styles/colors"; import Typography from "@mui/material/Typography" +import { Divider, Grid } from "@mui/material"; -const SectionTitle = (props) => { +interface SectionTitleProps { + children: string | React.ReactNode, + hasDivider?: boolean +} + +const SectionTitle = ({ children, hasDivider }: SectionTitleProps) => { return ( - - {props.children} - + + + {children} + + { + hasDivider ? + + : null + } + ); }; diff --git a/src/components/VerificationRequest/verificationRequestForms/DynamicVerificationRequestForm.tsx b/src/components/VerificationRequest/verificationRequestForms/DynamicVerificationRequestForm.tsx index 0d53c759e..402ae34d6 100644 --- a/src/components/VerificationRequest/verificationRequestForms/DynamicVerificationRequestForm.tsx +++ b/src/components/VerificationRequest/verificationRequestForms/DynamicVerificationRequestForm.tsx @@ -1,7 +1,6 @@ import React from "react"; import { useForm } from "react-hook-form"; import createVerificationRequestForm from "./fieldLists/CreateVerificationRequestForm"; -import moment from "moment"; import DynamicForm from "../../Form/DynamicForm"; import SharedFormFooter from "../../SharedFormFooter"; import editVerificationRequestForm from "./fieldLists/EditVerificationRequestForm"; @@ -22,8 +21,6 @@ const DynamicVerificationRequestForm = ({ control, formState: { errors }, } = useForm(); - const disabledDate = (current) => - current && current > moment().endOf("day"); return (
diff --git a/src/components/badges/DynamicBadgesForm.tsx b/src/components/badges/DynamicBadgesForm.tsx index 1a22d08c5..ed97f2825 100644 --- a/src/components/badges/DynamicBadgesForm.tsx +++ b/src/components/badges/DynamicBadgesForm.tsx @@ -1,4 +1,3 @@ -import moment from "moment"; import { useForm } from "react-hook-form"; import DynamicForm from "../Form/DynamicForm"; import SharedFormFooter from "../SharedFormFooter"; @@ -19,8 +18,6 @@ const DynamicBadgesForm = ({ control, formState: { errors }, } = useForm(); - const disabledDate = (current) => - current && current > moment().endOf("day"); const [recaptchaString, setRecaptchaString] = useState(""); const hasCaptcha = !!recaptchaString; @@ -31,7 +28,6 @@ const DynamicBadgesForm = ({ currentForm={lifecycleBadgesForm} control={control} errors={errors} - disabledDate={disabledDate} machineValues={badges} /> diff --git a/src/components/namespace/DynamicNameSpaceForm.tsx b/src/components/namespace/DynamicNameSpaceForm.tsx index c576cf3da..eac2d7c5f 100644 --- a/src/components/namespace/DynamicNameSpaceForm.tsx +++ b/src/components/namespace/DynamicNameSpaceForm.tsx @@ -1,4 +1,3 @@ -import moment from "moment"; import { useForm } from "react-hook-form"; import { IDynamicNameSpaceForm } from "../../types/Namespace"; import lifecycleNameSpaceForm from "./NameSpaceForm"; @@ -23,8 +22,6 @@ const DynamicNameSpaceForm = ({ control, formState: { errors }, } = useForm(); - const disabledDate = (current) => - current && current > moment().endOf("day"); const [recaptchaString, setRecaptchaString] = useState(""); const hasCaptcha = !!recaptchaString; @@ -50,7 +47,6 @@ const DynamicNameSpaceForm = ({ currentForm={lifecycleNameSpaceForm} control={control} errors={errors} - disabledDate={disabledDate} machineValues={nameSpace} /> diff --git a/src/pages/event-create.tsx b/src/pages/event-create.tsx new file mode 100644 index 000000000..aaee12224 --- /dev/null +++ b/src/pages/event-create.tsx @@ -0,0 +1,47 @@ +import { useSetAtom } from "jotai"; +import { NextPage } from "next"; +import { useTranslation } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useDispatch } from "react-redux"; +import Seo from "../components/Seo"; +import actions from "../store/actions"; +import { GetLocale } from "../utils/GetLocale"; +import { NameSpaceEnum } from "../types/Namespace"; +import { currentNameSpace } from "../atoms/namespace"; +import CreateEventView from "../components/Event/EventForm/CreateEventView"; + +const CreateEventPage: NextPage<{ + nameSpace: NameSpaceEnum; + sitekey: string; +}> = ({ nameSpace, sitekey }) => { + const { t } = useTranslation(); + const setCurrentNameSpace = useSetAtom(currentNameSpace); + setCurrentNameSpace(nameSpace); + + const dispatch = useDispatch(); + dispatch(actions.setSitekey(sitekey)); + + return ( + <> + + + + ); +}; + +export async function getServerSideProps({ query, locale, locales, req }) { + locale = GetLocale(req, locale, locales); + query = JSON.parse(query.props); + + return { + props: { + ...(await serverSideTranslations(locale)), + sitekey: query.sitekey, + nameSpace: query.nameSpace ? query.nameSpace : NameSpaceEnum.Main, + }, + }; +} +export default CreateEventPage; diff --git a/src/pages/event-page.tsx b/src/pages/event-page.tsx new file mode 100644 index 000000000..69c22b6fc --- /dev/null +++ b/src/pages/event-page.tsx @@ -0,0 +1,44 @@ +import { NextPage } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { NameSpaceEnum } from "../types/Namespace"; +import { useSetAtom } from "jotai"; +import { currentNameSpace } from "../atoms/namespace"; +import { GetLocale } from "../utils/GetLocale"; +import { useDispatch } from "react-redux"; +import actions from "../store/actions"; +import EventsList from "../components/Event/EventList/EventsList"; +import AffixButton from "../components/AffixButton/AffixButton"; + +const EventPage: NextPage<{ + nameSpace: NameSpaceEnum; + sitekey: string; +}> = ({ nameSpace, sitekey }) => { + const setCurrentNameSpace = useSetAtom(currentNameSpace); + setCurrentNameSpace(nameSpace); + + const dispatch = useDispatch(); + dispatch(actions.setSitekey(sitekey)); + + return ( + <> + + + + ); +}; + +export async function getServerSideProps({ query, locale, locales, req }) { + locale = GetLocale(req, locale, locales); + query = JSON.parse(query.props); + + return { + props: { + ...(await serverSideTranslations(locale)), + nameSpace: query.nameSpace ? query.nameSpace : NameSpaceEnum.Main, + sitekey: query.sitekey, + href: req.protocol + "://" + req.get("host") + req.originalUrl, + }, + }; +} + +export default EventPage; diff --git a/src/pages/event-view-page.tsx b/src/pages/event-view-page.tsx new file mode 100644 index 000000000..30ecd6a1b --- /dev/null +++ b/src/pages/event-view-page.tsx @@ -0,0 +1,50 @@ +import { NextPage } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { FullEventResponse } from "../types/event"; +import { NameSpaceEnum } from "../types/Namespace"; +import { useSetAtom } from "jotai"; +import { currentNameSpace } from "../atoms/namespace"; +import { GetLocale } from "../utils/GetLocale"; +import { useDispatch } from "react-redux"; +import actions from "../store/actions"; + +interface EventPageProps { + fullEvent: FullEventResponse; + nameSpace: NameSpaceEnum; + sitekey: string; +} + +const EventViewPage: NextPage = ({ + fullEvent, + nameSpace, + sitekey +}) => { + const setCurrentNameSpace = useSetAtom(currentNameSpace); + setCurrentNameSpace(nameSpace); + + const dispatch = useDispatch(); + dispatch(actions.setSitekey(sitekey)); + + return ( +
+

pagina do evento

+
+ ); +}; + +export async function getServerSideProps({ query, locale, locales, req }) { + locale = GetLocale(req, locale, locales); + query = JSON.parse(query.props); + + return { + props: { + ...(await serverSideTranslations(locale)), + fullEvent: query.fullEvent || null, + nameSpace: query.nameSpace ? query.nameSpace : NameSpaceEnum.Main, + sitekey: query.sitekey, + href: req.protocol + "://" + req.get("host") + req.originalUrl, + }, + }; +} + +export default EventViewPage; diff --git a/src/types/VerificationRequest.ts b/src/types/VerificationRequest.ts index 78baf4cf5..312517622 100644 --- a/src/types/VerificationRequest.ts +++ b/src/types/VerificationRequest.ts @@ -144,8 +144,8 @@ interface IDynamicVerificationRequestForm { setRecaptchaString: React.Dispatch>; hasCaptcha: boolean; isEdit: boolean; - isDrawerOpen: boolean; - onClose: () => void; + isDrawerOpen?: boolean; + onClose?: () => void; } export type { diff --git a/src/types/event.ts b/src/types/event.ts new file mode 100644 index 000000000..6249a2b37 --- /dev/null +++ b/src/types/event.ts @@ -0,0 +1,39 @@ +import { NameSpaceEnum } from "./Namespace"; +import { Topic } from "./Topic"; + +export type EventOrder = "asc" | "desc"; +export type EventStatus = "happening" | "upcoming" | "finalized" | "all"; +export interface EventPayload { + nameSpace: NameSpaceEnum, + badge: string; + data_hash?: string; + name: string; + description: string; + location: string; + startDate: Date; + endDate: Date; + mainTopic: Topic; + filterTopics?: Topic[]; + recaptcha?: string +} + +export type FullEventResponse = Event & { + sentences: any[]; //to do: Improve Types + verificationRequests: any[]; //to do: Improve Types +}; +export interface ListEventsOptions { + page?: number; + pageSize?: number; + order?: EventOrder; + status?: EventStatus; +} + +export interface IDynamicEventForm { + data?: EventPayload; + onSubmit: (value: EventPayload) => void; + isLoading: boolean; + setRecaptchaString: React.Dispatch>; + hasCaptcha: boolean; + isDrawerOpen?: boolean; + onClose?: () => void; +}