From 5e3a8f2a40b55b5c7f5dc5519c20f6bbec99ee18 Mon Sep 17 00:00:00 2001 From: LuizFNJ Date: Thu, 19 Feb 2026 02:36:43 +0100 Subject: [PATCH 01/10] feat(events): implement backend logic and core infrastructure for event views --- server/app.module.ts | 2 + .../claim/types/sentence/sentence.service.ts | 65 +++- .../sentence/types/sentence.interfaces.ts | 12 + server/events/dto/event.dto.ts | 57 +++ server/events/dto/filter.dto.ts | 29 ++ server/events/event.controller.spec.ts | 161 ++++++++ server/events/event.controller.ts | 122 ++++++ server/events/event.module.ts | 35 ++ server/events/event.service.spec.ts | 358 ++++++++++++++++++ server/events/event.service.ts | 258 +++++++++++++ server/events/schema/event.schema.ts | 44 +++ server/events/types/event.interfaces.ts | 8 + server/mocks/EventMock.ts | 44 +++ server/tests/event.e2e.spec.ts | 243 ++++++++++++ server/topic/topic.service.ts | 8 +- server/topic/types/topic.interfaces.ts | 6 + src/pages/event-page.tsx | 41 ++ src/pages/event-view-page.tsx | 50 +++ src/types/event.ts | 17 + 19 files changed, 1553 insertions(+), 7 deletions(-) create mode 100644 server/claim/types/sentence/types/sentence.interfaces.ts create mode 100644 server/events/dto/event.dto.ts create mode 100644 server/events/dto/filter.dto.ts create mode 100644 server/events/event.controller.spec.ts create mode 100644 server/events/event.controller.ts create mode 100644 server/events/event.module.ts create mode 100644 server/events/event.service.spec.ts create mode 100644 server/events/event.service.ts create mode 100644 server/events/schema/event.schema.ts create mode 100644 server/events/types/event.interfaces.ts create mode 100644 server/mocks/EventMock.ts create mode 100644 server/tests/event.e2e.spec.ts create mode 100644 server/topic/types/topic.interfaces.ts create mode 100644 src/pages/event-page.tsx create mode 100644 src/pages/event-view-page.tsx create mode 100644 src/types/event.ts diff --git a/server/app.module.ts b/server/app.module.ts index 8650510c8..fd5ed0917 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"; @Module({}) export class AppModule implements NestModule { @@ -122,6 +123,7 @@ export class AppModule implements NestModule { ClaimRevisionModule, HistoryModule, TrackingModule, + EventsModule, StateEventModule, SourceModule, SpeechModule, 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..30b0bac5b --- /dev/null +++ b/server/events/dto/event.dto.ts @@ -0,0 +1,57 @@ +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() + @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; + + @IsArray() + @IsOptional() + @ApiProperty() + filterTopics: TopicData[]; +} + +export class UpdateEventDTO extends PartialType(CreateEventDTO) { } diff --git a/server/events/dto/filter.dto.ts b/server/events/dto/filter.dto.ts new file mode 100644 index 000000000..9819b8b3c --- /dev/null +++ b/server/events/dto/filter.dto.ts @@ -0,0 +1,29 @@ +import { + IsOptional, + IsInt, + Min, + IsString +} from "class-validator"; +import { Type } from "class-transformer"; + +export class FilterEventsDTO { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + page?: number = 0; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number = 10; + + @IsOptional() + @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..e153ca343 --- /dev/null +++ b/server/events/event.controller.spec.ts @@ -0,0 +1,161 @@ +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("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..7287fba08 --- /dev/null +++ b/server/events/event.controller.ts @@ -0,0 +1,122 @@ +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 + ); + } + + @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..6a325f0c4 --- /dev/null +++ b/server/events/event.service.spec.ts @@ -0,0 +1,358 @@ +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 filterTopic = { _id: "t2", name: "Science", wikidataId: "Q2" }; + const expectedHash = crypto + .createHash("md5") + .update(`${mockCreateEventDto.name}-${mockCreateEventDto.startDate}`) + .digest("hex"); + const createdEvent = { _id: "e1", data_hash: expectedHash }; + + mockTopicService.findOrCreateTopic + .mockResolvedValueOnce(mainTopic) + .mockResolvedValueOnce(filterTopic); + mockEventModel.create.mockResolvedValue(createdEvent); + + const result = await service.create(mockCreateEventDto as any); + + expect(mockTopicService.findOrCreateTopic).toHaveBeenCalledTimes(2); + expect(mockEventModel.create).toHaveBeenCalledWith( + expect.objectContaining({ + ...mockCreateEventDto, + data_hash: expectedHash, + mainTopic, + filterTopics: [filterTopic], + }) + ); + 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 lean = jest.fn().mockReturnValue({ exec }); + const sort = jest.fn().mockReturnValue({ lean }); + const limit = jest.fn().mockReturnValue({ sort }); + const skip = jest.fn().mockReturnValue({ limit }); + + mockEventModel.find.mockReturnValue({ skip }); + + 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(eventsResult); + }); + + it("should throw InternalServerErrorException when querying fails", async () => { + const exec = jest.fn().mockRejectedValue(new Error("db fail")); + const lean = jest.fn().mockReturnValue({ exec }); + const sort = jest.fn().mockReturnValue({ lean }); + const limit = jest.fn().mockReturnValue({ sort }); + const skip = jest.fn().mockReturnValue({ limit }); + + mockEventModel.find.mockReturnValue({ skip }); + + 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..ba1dcfa33 --- /dev/null +++ b/server/events/event.service.ts @@ -0,0 +1,258 @@ +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 { 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 allTopics = [ + createEventDto.mainTopic, + ...(createEventDto.filterTopics) || [] + ]; + + const createdTopics = await Promise.all( + allTopics.map(topicData => + this.topicService.findOrCreateTopic(topicData) + ) + ); + + const [mainTopic, ...filterTopics] = createdTopics; + + const data_hash = crypto + .createHash("md5") + .update(`${createEventDto.name}-${createEventDto.startDate}`) + .digest("hex"); + + const newEvent = await this.eventModel.create({ + ...createEventDto, + data_hash, + mainTopic, + filterTopics + }) + + 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 updateData = { ...updateEventDto }; + + if (updateEventDto.mainTopic) { + updateData.mainTopic = await this.topicService.findOrCreateTopic(updateEventDto.mainTopic); + } + + if (updateEventDto.filterTopics !== undefined) { + const uniqueTopics = Array.from( + new Map(updateEventDto.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 || "ALL"}`); + this.logger.verbose(`Built MongoDB Query: ${JSON.stringify(query)}`); + + const result = await this.eventModel.find(query) + .skip(page * parseInt(`${pageSize}`, 10)) + .limit(parseInt(`${pageSize}`, 10)) + .sort({ _id: order }) + .lean() + .exec(); + + return result + } 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) 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..f994943e2 --- /dev/null +++ b/server/events/types/event.interfaces.ts @@ -0,0 +1,8 @@ +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[]; +}; diff --git a/server/mocks/EventMock.ts b/server/mocks/EventMock.ts new file mode 100644 index 000000000..2727642b7 --- /dev/null +++ b/server/mocks/EventMock.ts @@ -0,0 +1,44 @@ +export const mockEventModel = { + create: jest.fn(), + findByIdAndUpdate: jest.fn(), + find: 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", wikidataId: "Q1" }, + filterTopics: [{ name: "Science", wikidataId: "Q2" }], +}; + +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..f3f83bb3a --- /dev/null +++ b/server/tests/event.e2e.spec.ts @@ -0,0 +1,243 @@ +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: { + name: mainTopicName, + wikidataId: "Q125928", + }, + filterTopics: [ + { + name: "Science", + wikidataId: "Q336", + }, + ], + }); + + 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(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(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"); + expect(body).toHaveProperty("filterTopics"); + }); + }); + + 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(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + + const created = body.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(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(1); + + for (const event of body) { + 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(Array.isArray(body)).toBe(true); + expect(body.length).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..4409dc15e 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..2c0566921 --- /dev/null +++ b/server/topic/types/topic.interfaces.ts @@ -0,0 +1,6 @@ +export interface TopicData { + name: string; + wikidataId?: string; + language?: string; + description?: string; // Future use +} diff --git a/src/pages/event-page.tsx b/src/pages/event-page.tsx new file mode 100644 index 000000000..8913078bb --- /dev/null +++ b/src/pages/event-page.tsx @@ -0,0 +1,41 @@ +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"; + +const EventPage: NextPage<{ + nameSpace: NameSpaceEnum; + sitekey: string; +}> = ({ nameSpace, sitekey }) => { + const setCurrentNameSpace = useSetAtom(currentNameSpace); + setCurrentNameSpace(nameSpace); + + const dispatch = useDispatch(); + dispatch(actions.setSitekey(sitekey)); + + return ( +
+

pagina dos eventos

+
+ ); +}; + +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/event.ts b/src/types/event.ts new file mode 100644 index 000000000..6827ff3c3 --- /dev/null +++ b/src/types/event.ts @@ -0,0 +1,17 @@ +import { Topic } from "./Topic"; + +interface Event { + badge: string; + name: string; + description: string; + location: string; + startDate: Date; + endDate: Date; + mainTopic: Topic; + filterTopics: Topic[] | []; +} + +export type FullEventResponse = Event & { + sentences: any[]; //to do: Improve Types + verificationRequests: any[]; //to do: Improve Types +}; From 76e7e321dd30adce47c290a92ec4aa94fd8202c8 Mon Sep 17 00:00:00 2001 From: LuizFNJ Date: Thu, 19 Feb 2026 03:13:43 +0100 Subject: [PATCH 02/10] refactor(events): implement safe update pattern and improve type safety in update function --- server/events/event.service.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/server/events/event.service.ts b/server/events/event.service.ts index ba1dcfa33..7da61950c 100644 --- a/server/events/event.service.ts +++ b/server/events/event.service.ts @@ -26,7 +26,7 @@ export class EventsService { private readonly verificationRequestService: VerificationRequestService, private readonly sentenceService: SentenceService, private readonly topicService: TopicService, - ) {} + ) { } /** * Creates a new event, processes associated topics, and generates a data hash. @@ -95,15 +95,17 @@ export class EventsService { throw new BadRequestException(`Invalid event ID format: ${id}`); } - const updateData = { ...updateEventDto }; + const { mainTopic, filterTopics, ...otherFields } = updateEventDto; - if (updateEventDto.mainTopic) { - updateData.mainTopic = await this.topicService.findOrCreateTopic(updateEventDto.mainTopic); + const updateData: Partial = { ...otherFields }; + + if (mainTopic) { + updateData.mainTopic = await this.topicService.findOrCreateTopic(mainTopic); } - if (updateEventDto.filterTopics !== undefined) { + if (filterTopics !== undefined) { const uniqueTopics = Array.from( - new Map(updateEventDto.filterTopics.map(topic => [topic.name, topic])).values() + new Map(filterTopics.map(topic => [topic.name, topic])).values() ); updateData.filterTopics = await Promise.all( From caac8ce720bfd9e37c1d0ba5e791aacf7da100e4 Mon Sep 17 00:00:00 2001 From: LuizFNJ Date: Sun, 1 Mar 2026 01:32:30 +0100 Subject: [PATCH 03/10] feat: implement event page with filtering, pagination and UI cards --- public/locales/en/events.json | 10 +++ public/locales/pt/events.json | 10 +++ server/events/dto/filter.dto.ts | 3 - server/events/event.service.ts | 26 +++--- server/events/types/event.interfaces.ts | 5 ++ src/api/eventApi.ts | 77 +++++++++++++++++ src/components/event/ErrorState.tsx | 34 ++++++++ src/components/event/EventCard.tsx | 39 +++++++++ src/components/event/EventCardAction.tsx | 22 +++++ src/components/event/EventCardDateRange.tsx | 30 +++++++ src/components/event/EventCardHeader.tsx | 39 +++++++++ src/components/event/EventCardTitle.tsx | 26 ++++++ src/components/event/EventDrawer.tsx | 10 +++ src/components/event/EventFilters.tsx | 39 +++++++++ src/components/event/EventLoadMore.tsx | 30 +++++++ src/components/event/EventView.tsx | 96 +++++++++++++++++++++ src/pages/event-page.tsx | 9 +- src/types/event.ts | 12 ++- 18 files changed, 498 insertions(+), 19 deletions(-) create mode 100644 public/locales/en/events.json create mode 100644 public/locales/pt/events.json create mode 100644 src/api/eventApi.ts create mode 100644 src/components/event/ErrorState.tsx create mode 100644 src/components/event/EventCard.tsx create mode 100644 src/components/event/EventCardAction.tsx create mode 100644 src/components/event/EventCardDateRange.tsx create mode 100644 src/components/event/EventCardHeader.tsx create mode 100644 src/components/event/EventCardTitle.tsx create mode 100644 src/components/event/EventDrawer.tsx create mode 100644 src/components/event/EventFilters.tsx create mode 100644 src/components/event/EventLoadMore.tsx create mode 100644 src/components/event/EventView.tsx diff --git a/public/locales/en/events.json b/public/locales/en/events.json new file mode 100644 index 000000000..5bab9981e --- /dev/null +++ b/public/locales/en/events.json @@ -0,0 +1,10 @@ +{ + "openEvent": "See event", + "fetchError": "Error fetching events. Please try again later.", + "loadMoreButton": "Load more", + "eventsList": "Events", + "filterAll": "All", + "filterOngoing": "Ongoing", + "filterUpcoming": "Upcoming", + "filterFinished": "Finished" +} diff --git a/public/locales/pt/events.json b/public/locales/pt/events.json new file mode 100644 index 000000000..f3f9efe38 --- /dev/null +++ b/public/locales/pt/events.json @@ -0,0 +1,10 @@ +{ + "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" +} diff --git a/server/events/dto/filter.dto.ts b/server/events/dto/filter.dto.ts index 9819b8b3c..9e991def2 100644 --- a/server/events/dto/filter.dto.ts +++ b/server/events/dto/filter.dto.ts @@ -7,19 +7,16 @@ import { import { Type } from "class-transformer"; export class FilterEventsDTO { - @IsOptional() @Type(() => Number) @IsInt() @Min(0) page?: number = 0; - @IsOptional() @Type(() => Number) @IsInt() @Min(1) pageSize?: number = 10; - @IsOptional() @IsString() order?: "asc" | "desc"; diff --git a/server/events/event.service.ts b/server/events/event.service.ts index 7da61950c..5b44b3725 100644 --- a/server/events/event.service.ts +++ b/server/events/event.service.ts @@ -14,7 +14,7 @@ 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 { FullEventResponse } from "./types/event.interfaces"; +import { FindAllResponse, FullEventResponse } from "./types/event.interfaces"; import * as crypto from "crypto"; @Injectable() @@ -145,22 +145,26 @@ export class EventsService { * @param queryDto - DTO containing pagination and filter parameters. * @returns A list of event documents. */ - async findAll(queryDto: FilterEventsDTO): Promise { + 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 || "ALL"}`); + this.logger.debug(`Fetching events list. Page: ${page}, PageSize: ${pageSize}, Status: ${status}`); this.logger.verbose(`Built MongoDB Query: ${JSON.stringify(query)}`); - const result = await this.eventModel.find(query) - .skip(page * parseInt(`${pageSize}`, 10)) - .limit(parseInt(`${pageSize}`, 10)) - .sort({ _id: order }) - .lean() - .exec(); + const [events, total] = await Promise.all([ + this.eventModel.find(query) + .skip(page * parseInt(`${pageSize}`, 10)) + .limit(parseInt(`${pageSize}`, 10)) + .sort({ _id: order }) + .lean() + .exec(), - return result + 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."); @@ -238,7 +242,7 @@ export class EventsService { const now = new Date(); now.setHours(0, 0, 0, 0); - if (!status) return query; + if (!status || status === "all") return query; switch (status) { case "finalized": diff --git a/server/events/types/event.interfaces.ts b/server/events/types/event.interfaces.ts index f994943e2..5dc5f5b5d 100644 --- a/server/events/types/event.interfaces.ts +++ b/server/events/types/event.interfaces.ts @@ -6,3 +6,8 @@ export type FullEventResponse = Event & { sentences: TopicRelatedSentencesResponse[]; verificationRequests: VerificationRequest[]; }; + +export interface FindAllResponse { + events: Event[]; + total: number; +} diff --git a/src/api/eventApi.ts b/src/api/eventApi.ts new file mode 100644 index 000000000..5381a3f7b --- /dev/null +++ b/src/api/eventApi.ts @@ -0,0 +1,77 @@ +import axios from "axios"; +import { TFunction } from "i18next"; +import { MessageManager } from "../components/Messages"; +import { HEX24 } from "../types/History"; +import { EventPayload, ListEventsOptions } from "../types/event"; + +const request = axios.create({ + withCredentials: true, + baseURL: `/api/event`, +}); + +const createEvent = (newEvent: EventPayload, t?: TFunction) => { + return request + .post("/", newEvent) + .then((response) => 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/event/ErrorState.tsx b/src/components/event/ErrorState.tsx new file mode 100644 index 000000000..ef51accee --- /dev/null +++ b/src/components/event/ErrorState.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Grid, Typography } from "@mui/material"; +import colors from "../../styles/colors"; + +interface ErrorStateProps { + message: string; +} + +const ErrorState = ({ message }: ErrorStateProps) => { + return ( + + + + {message} + + + + ); +}; + +export default ErrorState; diff --git a/src/components/event/EventCard.tsx b/src/components/event/EventCard.tsx new file mode 100644 index 000000000..8b85e87f3 --- /dev/null +++ b/src/components/event/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/EventCardAction.tsx b/src/components/event/EventCardAction.tsx new file mode 100644 index 000000000..f24d15521 --- /dev/null +++ b/src/components/event/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/EventCardDateRange.tsx b/src/components/event/EventCardDateRange.tsx new file mode 100644 index 000000000..21224187c --- /dev/null +++ b/src/components/event/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/EventCardHeader.tsx b/src/components/event/EventCardHeader.tsx new file mode 100644 index 000000000..f8721c234 --- /dev/null +++ b/src/components/event/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/EventCardTitle.tsx b/src/components/event/EventCardTitle.tsx new file mode 100644 index 000000000..27558969f --- /dev/null +++ b/src/components/event/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/EventDrawer.tsx b/src/components/event/EventDrawer.tsx new file mode 100644 index 000000000..4080e0739 --- /dev/null +++ b/src/components/event/EventDrawer.tsx @@ -0,0 +1,10 @@ + + +const EventDrawer = () => { + + return ( + "EventDrawer" + ) +} + +export default EventDrawer diff --git a/src/components/event/EventFilters.tsx b/src/components/event/EventFilters.tsx new file mode 100644 index 000000000..481f60663 --- /dev/null +++ b/src/components/event/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/EventLoadMore.tsx b/src/components/event/EventLoadMore.tsx new file mode 100644 index 000000000..f4ff1b8ec --- /dev/null +++ b/src/components/event/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/EventView.tsx b/src/components/event/EventView.tsx new file mode 100644 index 000000000..ca24afd0b --- /dev/null +++ b/src/components/event/EventView.tsx @@ -0,0 +1,96 @@ +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"; + +export interface IData { + events: EventPayload[], + total: number +} + +const EventView = () => { + 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} + /> + + + + } + /> + + data.events.length} + onLoadMore={() => setQuery(prev => ({ ...prev, page: prev.page + 1 }))} + label={t("events:loadMoreButton")} + /> + + +
+ ) +} + +export default EventView diff --git a/src/pages/event-page.tsx b/src/pages/event-page.tsx index 8913078bb..047d2ac8a 100644 --- a/src/pages/event-page.tsx +++ b/src/pages/event-page.tsx @@ -6,6 +6,8 @@ import { currentNameSpace } from "../atoms/namespace"; import { GetLocale } from "../utils/GetLocale"; import { useDispatch } from "react-redux"; import actions from "../store/actions"; +import EventView from "../components/event/EventView"; +import EventDrawer from "../components/event/EventDrawer"; const EventPage: NextPage<{ nameSpace: NameSpaceEnum; @@ -18,9 +20,10 @@ const EventPage: NextPage<{ dispatch(actions.setSitekey(sitekey)); return ( -
-

pagina dos eventos

-
+ <> + + {/* */} + ); }; diff --git a/src/types/event.ts b/src/types/event.ts index 6827ff3c3..af69a26ce 100644 --- a/src/types/event.ts +++ b/src/types/event.ts @@ -1,6 +1,8 @@ import { Topic } from "./Topic"; -interface Event { +export type EventOrder = "asc" | "desc"; +export type EventStatus = "happening" | "upcoming" | "finalized" | "all"; +export interface EventPayload { badge: string; name: string; description: string; @@ -8,10 +10,16 @@ interface Event { startDate: Date; endDate: Date; mainTopic: Topic; - filterTopics: Topic[] | []; + filterTopics?: Topic[]; } 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; +} From 5d8db2ff7b88c7a901bbf7de69d9bbfd153cac2f Mon Sep 17 00:00:00 2001 From: LuizFNJ Date: Sun, 1 Mar 2026 19:58:55 +0100 Subject: [PATCH 04/10] refactor: update unit and e2e tests to verify total value --- server/events/event.service.spec.ts | 13 +++++++++--- server/mocks/EventMock.ts | 1 + server/tests/event.e2e.spec.ts | 32 ++++++++++++++++++++--------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/server/events/event.service.spec.ts b/server/events/event.service.spec.ts index 6a325f0c4..80acef876 100644 --- a/server/events/event.service.spec.ts +++ b/server/events/event.service.spec.ts @@ -204,12 +204,14 @@ describe("EventsService (Unit)", () => { it("should return events list with pagination and order", async () => { const eventsResult = [{ _id: "e1" }, { _id: "e2" }]; const exec = jest.fn().mockResolvedValue(eventsResult); - const lean = jest.fn().mockReturnValue({ exec }); + 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, @@ -227,17 +229,22 @@ describe("EventsService (Unit)", () => { expect(skip).toHaveBeenCalledWith(5); expect(limit).toHaveBeenCalledWith(5); expect(sort).toHaveBeenCalledWith({ _id: "desc" }); - expect(result).toEqual(eventsResult); + 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 lean = jest.fn().mockReturnValue({ exec }); + 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" }) diff --git a/server/mocks/EventMock.ts b/server/mocks/EventMock.ts index 2727642b7..cd3faef1d 100644 --- a/server/mocks/EventMock.ts +++ b/server/mocks/EventMock.ts @@ -2,6 +2,7 @@ export const mockEventModel = { create: jest.fn(), findByIdAndUpdate: jest.fn(), find: jest.fn(), + countDocuments: jest.fn(), findOne: jest.fn(), }; diff --git a/server/tests/event.e2e.spec.ts b/server/tests/event.e2e.spec.ts index f3f83bb3a..4d7d3e97a 100644 --- a/server/tests/event.e2e.spec.ts +++ b/server/tests/event.e2e.spec.ts @@ -93,8 +93,11 @@ describe("EventController (e2e)", () => { }) .expect(200) .expect(({ body }) => { - expect(Array.isArray(body)).toBe(true); - expect(body).toHaveLength(0); + 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); }); }); @@ -145,10 +148,13 @@ describe("EventController (e2e)", () => { }) .expect(200) .expect(({ body }) => { - expect(Array.isArray(body)).toBe(true); - expect(body.length).toBeGreaterThanOrEqual(2); + 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.find((event) => event._id === createdEventId); + const created = body.events.find((event) => event._id === createdEventId); expect(created).toBeDefined(); expect(created.name).toEqual("Future Event Alpha"); }); @@ -194,10 +200,13 @@ describe("EventController (e2e)", () => { }) .expect(200) .expect(({ body }) => { - expect(Array.isArray(body)).toBe(true); - expect(body.length).toBeGreaterThanOrEqual(1); + 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) { + for (const event of body.events) { expect(new Date(event.startDate).getTime()).toBeGreaterThan(Date.now() - 24 * 60 * 60 * 1000); } }); @@ -214,8 +223,11 @@ describe("EventController (e2e)", () => { }) .expect(200) .expect(({ body }) => { - expect(Array.isArray(body)).toBe(true); - expect(body.length).toBeGreaterThanOrEqual(1); + 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); }); }); From 12ae6a92bebe214d51d04ad8cddd3e72e4c6f1e6 Mon Sep 17 00:00:00 2001 From: LuizFNJ Date: Sun, 1 Mar 2026 20:50:28 +0100 Subject: [PATCH 05/10] UI: improving the UI to see total events --- public/locales/en/events.json | 1 + public/locales/pt/events.json | 1 + src/components/GridList.tsx | 8 +++- .../Personality/MorePersonalities.tsx | 6 +-- src/components/SectionTitle.tsx | 41 ++++++++++++------- src/components/event/EventTitle.tsx | 24 +++++++++++ src/components/event/EventView.tsx | 9 +++- 7 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 src/components/event/EventTitle.tsx diff --git a/public/locales/en/events.json b/public/locales/en/events.json index 5bab9981e..8948cea22 100644 --- a/public/locales/en/events.json +++ b/public/locales/en/events.json @@ -1,4 +1,5 @@ { + "totalItems": "{{total}} in total", "openEvent": "See event", "fetchError": "Error fetching events. Please try again later.", "loadMoreButton": "Load more", diff --git a/public/locales/pt/events.json b/public/locales/pt/events.json index f3f9efe38..212ba1595 100644 --- a/public/locales/pt/events.json +++ b/public/locales/pt/events.json @@ -1,4 +1,5 @@ { + "totalItems": "{{total}} no total", "openEvent": "Veja evento", "fetchError": "Erro ao carregar eventos. Por favor, tente novamente mais tarde.", "loadMoreButton": "Carregar mais", 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/event/EventTitle.tsx b/src/components/event/EventTitle.tsx new file mode 100644 index 000000000..91d1bf09c --- /dev/null +++ b/src/components/event/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/EventView.tsx b/src/components/event/EventView.tsx index ca24afd0b..76a75fa8d 100644 --- a/src/components/event/EventView.tsx +++ b/src/components/event/EventView.tsx @@ -9,6 +9,7 @@ import EventCard from "./EventCard"; import ErrorState from "./ErrorState"; import EventFilters from "./EventFilters"; import EventLoadMore from "./EventLoadMore"; +import EventTitle from "./EventTitle"; export interface IData { events: EventPayload[], @@ -70,10 +71,16 @@ const EventView = () => { /> + } dataSource={data.events} loggedInMaxColumns={6} disableSeeMoreButton={true} + hasDivider={true} renderItem={(event) => Date: Sun, 1 Mar 2026 21:12:41 +0100 Subject: [PATCH 06/10] feat: implement routing for event details page --- src/components/{event => }/ErrorState.tsx | 2 +- src/components/event/EventCard.tsx | 2 +- src/components/event/EventView.tsx | 6 +++--- src/types/event.ts | 1 + 4 files changed, 6 insertions(+), 5 deletions(-) rename src/components/{event => }/ErrorState.tsx (94%) diff --git a/src/components/event/ErrorState.tsx b/src/components/ErrorState.tsx similarity index 94% rename from src/components/event/ErrorState.tsx rename to src/components/ErrorState.tsx index ef51accee..5467eaacc 100644 --- a/src/components/event/ErrorState.tsx +++ b/src/components/ErrorState.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Grid, Typography } from "@mui/material"; -import colors from "../../styles/colors"; +import colors from "../styles/colors"; interface ErrorStateProps { message: string; diff --git a/src/components/event/EventCard.tsx b/src/components/event/EventCard.tsx index 8b85e87f3..09942c9f4 100644 --- a/src/components/event/EventCard.tsx +++ b/src/components/event/EventCard.tsx @@ -28,7 +28,7 @@ const EventCard = ({ event, openEventLabel }: EventCardProps) => { /> } diff --git a/src/components/event/EventView.tsx b/src/components/event/EventView.tsx index 76a75fa8d..d36ef13af 100644 --- a/src/components/event/EventView.tsx +++ b/src/components/event/EventView.tsx @@ -6,12 +6,12 @@ import EventApi from "../../api/eventApi"; import Loading from "../Loading"; import { EventPayload, ListEventsOptions } from "../../types/event"; import EventCard from "./EventCard"; -import ErrorState from "./ErrorState"; +import ErrorState from "../ErrorState"; import EventFilters from "./EventFilters"; import EventLoadMore from "./EventLoadMore"; import EventTitle from "./EventTitle"; -export interface IData { +interface IData { events: EventPayload[], total: number } @@ -63,7 +63,7 @@ const EventView = () => { return (
- + setQuery(prev => ({ ...prev, status, page: 1 }))} diff --git a/src/types/event.ts b/src/types/event.ts index af69a26ce..4cb4248d0 100644 --- a/src/types/event.ts +++ b/src/types/event.ts @@ -4,6 +4,7 @@ export type EventOrder = "asc" | "desc"; export type EventStatus = "happening" | "upcoming" | "finalized" | "all"; export interface EventPayload { badge: string; + data_hash: string; name: string; description: string; location: string; From 0e3ecb5be382fb5ce21fb74617c86ea895346200 Mon Sep 17 00:00:00 2001 From: LuizFNJ Date: Tue, 3 Mar 2026 00:49:12 +0100 Subject: [PATCH 07/10] feat: add event creation form page --- public/locales/en/affix.json | 17 ++-- public/locales/en/events.json | 19 ++++- public/locales/en/seo.json | 4 +- public/locales/pt/affix.json | 17 ++-- public/locales/pt/events.json | 19 ++++- public/locales/pt/seo.json | 4 +- server/events/dto/event.dto.ts | 14 +++- server/events/event.controller.ts | 21 +++++ server/events/event.service.ts | 19 ++--- server/topic/topic.service.ts | 2 +- server/topic/types/topic.interfaces.ts | 1 + .../verification-request.service.ts | 4 +- src/api/eventApi.ts | 25 +++++- src/components/AffixButton/AffixButton.tsx | 16 +++- .../Claim/CreateClaim/BaseClaimForm.tsx | 9 +-- .../Claim/CreateClaim/ClaimCreate.tsx | 3 +- .../Claim/CreateClaim/ClaimUploadImage.tsx | 1 - src/components/Form/DatePickerInput.tsx | 79 ++++++++++++------- src/components/Form/DynamicForm.tsx | 4 +- src/components/Form/DynamicInput.tsx | 6 +- src/components/Form/FormField.ts | 7 +- .../DynamicVerificationRequestForm.tsx | 4 - src/components/badges/DynamicBadgesForm.tsx | 4 - src/components/event/CreateEventForm.ts | 48 +++++++++++ src/components/event/CreateEventView.tsx | 54 +++++++++++++ src/components/event/DynamicEventForm.tsx | 48 +++++++++++ .../namespace/DynamicNameSpaceForm.tsx | 4 - src/pages/event-create.tsx | 47 +++++++++++ src/pages/event-page.tsx | 4 +- src/types/VerificationRequest.ts | 4 +- src/types/event.ts | 15 +++- 31 files changed, 415 insertions(+), 108 deletions(-) create mode 100644 src/components/event/CreateEventForm.ts create mode 100644 src/components/event/CreateEventView.tsx create mode 100644 src/components/event/DynamicEventForm.tsx create mode 100644 src/pages/event-create.tsx 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 index 8948cea22..22583b901 100644 --- a/public/locales/en/events.json +++ b/public/locales/en/events.json @@ -1,4 +1,5 @@ { + "eventCreateSuccess": "Event created successfully", "totalItems": "{{total}} in total", "openEvent": "See event", "fetchError": "Error fetching events. Please try again later.", @@ -7,5 +8,21 @@ "filterAll": "All", "filterOngoing": "Ongoing", "filterUpcoming": "Upcoming", - "filterFinished": "Finished" + "filterFinished": "Finished", + "badgeLabel": "Badge", + "badgePlaceholder": "Enter badge", + "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", + "filterTopicsLabel": "Filter topics", + "filterTopicsPlaceholder": "Search and select filter topics" } 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 index 212ba1595..090f1b373 100644 --- a/public/locales/pt/events.json +++ b/public/locales/pt/events.json @@ -1,4 +1,5 @@ { + "eventCreateSuccess": "Evento criado com sucesso", "totalItems": "{{total}} no total", "openEvent": "Veja evento", "fetchError": "Erro ao carregar eventos. Por favor, tente novamente mais tarde.", @@ -7,5 +8,21 @@ "filterAll": "Todos", "filterHappening": "Agora", "filterUpcoming": "Em breve", - "filterFinished": "Encerrados" + "filterFinished": "Encerrados", + "badgeLabel": "Badge", + "badgePlaceholder": "Digite o badge", + "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", + "filterTopicsLabel": "Tópicos de filtro", + "filterTopicsPlaceholder": "Busque e selecione tópicos de filtro" } 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/events/dto/event.dto.ts b/server/events/dto/event.dto.ts index 30b0bac5b..e1b609fa4 100644 --- a/server/events/dto/event.dto.ts +++ b/server/events/dto/event.dto.ts @@ -11,6 +11,11 @@ import { Type } from "class-transformer"; import type { TopicData } from "../../topic/types/topic.interfaces"; export class CreateEventDTO { + @IsString() + @IsOptional() + @ApiProperty() + nameSpace?: string; + @IsString() @IsNotEmpty() @ApiProperty() @@ -48,10 +53,15 @@ export class CreateEventDTO { @ApiProperty() mainTopic: TopicData; + @IsString() + @IsOptional() + @ApiProperty() + recaptcha?: string; +} + +export class UpdateEventDTO extends PartialType(CreateEventDTO) { @IsArray() @IsOptional() @ApiProperty() filterTopics: TopicData[]; } - -export class UpdateEventDTO extends PartialType(CreateEventDTO) { } diff --git a/server/events/event.controller.ts b/server/events/event.controller.ts index 7287fba08..037934438 100644 --- a/server/events/event.controller.ts +++ b/server/events/event.controller.ts @@ -82,6 +82,27 @@ export class EventsController { ); } + @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") diff --git a/server/events/event.service.ts b/server/events/event.service.ts index 5b44b3725..87f6bc5e3 100644 --- a/server/events/event.service.ts +++ b/server/events/event.service.ts @@ -37,18 +37,10 @@ export class EventsService { try { this.logger.debug("Creating event", { createEventDto }); - const allTopics = [ - createEventDto.mainTopic, - ...(createEventDto.filterTopics) || [] - ]; - - const createdTopics = await Promise.all( - allTopics.map(topicData => - this.topicService.findOrCreateTopic(topicData) - ) - ); - - const [mainTopic, ...filterTopics] = createdTopics; + const createdTopic = await this.topicService.findOrCreateTopic({ + ...createEventDto.mainTopic, + name: createEventDto.mainTopic.label + }) const data_hash = crypto .createHash("md5") @@ -58,8 +50,7 @@ export class EventsService { const newEvent = await this.eventModel.create({ ...createEventDto, data_hash, - mainTopic, - filterTopics + mainTopic: createdTopic._id }) this.logger.log(`Event created successfully: ${newEvent._id}`); diff --git a/server/topic/topic.service.ts b/server/topic/topic.service.ts index 4409dc15e..96732ab49 100644 --- a/server/topic/topic.service.ts +++ b/server/topic/topic.service.ts @@ -218,7 +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: TopicData): 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 index 2c0566921..002577e8a 100644 --- a/server/topic/types/topic.interfaces.ts +++ b/server/topic/types/topic.interfaces.ts @@ -1,4 +1,5 @@ export interface TopicData { + label?: string; name: string; wikidataId?: string; language?: string; 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 index 5381a3f7b..3c28b37e5 100644 --- a/src/api/eventApi.ts +++ b/src/api/eventApi.ts @@ -3,16 +3,37 @@ 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, t?: TFunction) => { +const createEvent = ( + newEvent: EventPayload, + router: NextRouter, + t?: TFunction +) => { + const { nameSpace = NameSpaceEnum.Main } = newEvent; + return request .post("/", newEvent) - .then((response) => response.data) + .then((response) => { + MessageManager.showMessage( + "success", + t("events:eventCreateSuccess") + ); + + router.push( + nameSpace === NameSpaceEnum.Main + ? "/event" + : `/${nameSpace}/event` + ); + + return response.data + }) .catch((err) => { MessageManager.showMessage( "error", 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 = () => {
{ - 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..be13cd82f 100644 --- a/src/components/Form/DynamicForm.tsx +++ b/src/components/Form/DynamicForm.tsx @@ -10,7 +10,7 @@ const DynamicForm = ({ control, errors, machineValues = {}, - disabledDate = {}, + disabledFuture = true, }) => { const { t } = useTranslation(); return ( @@ -69,7 +69,7 @@ const DynamicForm = ({ defaultValue={defaultValue} data-cy={`testClaimReview${fieldName}`} extraProps={extraProps} - disabledDate={disabledDate} + disabledFuture={disabledFuture} disabled={fieldItem.disabled} /> )} 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..dccdc5bd9 100644 --- a/src/components/Form/FormField.ts +++ b/src/components/Form/FormField.ts @@ -126,9 +126,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/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/event/CreateEventForm.ts b/src/components/event/CreateEventForm.ts new file mode 100644 index 000000000..5576b654d --- /dev/null +++ b/src/components/event/CreateEventForm.ts @@ -0,0 +1,48 @@ +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: "", + i18nNamespace: "events", + }), +]; + +export default lifecycleEventForm; diff --git a/src/components/event/CreateEventView.tsx b/src/components/event/CreateEventView.tsx new file mode 100644 index 000000000..412b9adee --- /dev/null +++ b/src/components/event/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/DynamicEventForm.tsx b/src/components/event/DynamicEventForm.tsx new file mode 100644 index 000000000..2459766d9 --- /dev/null +++ b/src/components/event/DynamicEventForm.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { useForm } from "react-hook-form"; +import moment from "moment"; +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/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..3a4fe71c9 --- /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/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 index 047d2ac8a..d5a56bb22 100644 --- a/src/pages/event-page.tsx +++ b/src/pages/event-page.tsx @@ -7,7 +7,7 @@ import { GetLocale } from "../utils/GetLocale"; import { useDispatch } from "react-redux"; import actions from "../store/actions"; import EventView from "../components/event/EventView"; -import EventDrawer from "../components/event/EventDrawer"; +import AffixButton from "../components/AffixButton/AffixButton"; const EventPage: NextPage<{ nameSpace: NameSpaceEnum; @@ -22,7 +22,7 @@ const EventPage: NextPage<{ return ( <> - {/* */} + ); }; 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 index 4cb4248d0..6249a2b37 100644 --- a/src/types/event.ts +++ b/src/types/event.ts @@ -1,10 +1,12 @@ +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; + data_hash?: string; name: string; description: string; location: string; @@ -12,6 +14,7 @@ export interface EventPayload { endDate: Date; mainTopic: Topic; filterTopics?: Topic[]; + recaptcha?: string } export type FullEventResponse = Event & { @@ -24,3 +27,13 @@ export interface ListEventsOptions { order?: EventOrder; status?: EventStatus; } + +export interface IDynamicEventForm { + data?: EventPayload; + onSubmit: (value: EventPayload) => void; + isLoading: boolean; + setRecaptchaString: React.Dispatch>; + hasCaptcha: boolean; + isDrawerOpen?: boolean; + onClose?: () => void; +} From 22a65ca6fb50dfa94f7bc56514bf990ab987200b Mon Sep 17 00:00:00 2001 From: LuizFNJ Date: Tue, 3 Mar 2026 01:43:33 +0100 Subject: [PATCH 08/10] test: update unit and e2e tests to match function updates --- server/events/event.controller.spec.ts | 24 ++++++++++++++++++++++++ server/events/event.service.spec.ts | 14 +++++++------- server/mocks/EventMock.ts | 3 +-- server/tests/event.e2e.spec.ts | 8 +------- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/server/events/event.controller.spec.ts b/server/events/event.controller.spec.ts index e153ca343..8b0689173 100644 --- a/server/events/event.controller.spec.ts +++ b/server/events/event.controller.spec.ts @@ -98,6 +98,30 @@ describe("EventsController (Unit)", () => { }); }); + 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 = { diff --git a/server/events/event.service.spec.ts b/server/events/event.service.spec.ts index 80acef876..ac62004dc 100644 --- a/server/events/event.service.spec.ts +++ b/server/events/event.service.spec.ts @@ -56,27 +56,27 @@ describe("EventsService (Unit)", () => { describe("create", () => { it("should create an event with generated hash and resolved topics", async () => { const mainTopic = { _id: "t1", name: "Climate", wikidataId: "Q1" }; - const filterTopic = { _id: "t2", name: "Science", wikidataId: "Q2" }; const expectedHash = crypto .createHash("md5") .update(`${mockCreateEventDto.name}-${mockCreateEventDto.startDate}`) .digest("hex"); const createdEvent = { _id: "e1", data_hash: expectedHash }; - mockTopicService.findOrCreateTopic - .mockResolvedValueOnce(mainTopic) - .mockResolvedValueOnce(filterTopic); + mockTopicService.findOrCreateTopic.mockResolvedValueOnce(mainTopic); mockEventModel.create.mockResolvedValue(createdEvent); const result = await service.create(mockCreateEventDto as any); - expect(mockTopicService.findOrCreateTopic).toHaveBeenCalledTimes(2); + 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, - filterTopics: [filterTopic], + mainTopic: mainTopic._id, }) ); expect(result).toEqual(createdEvent); diff --git a/server/mocks/EventMock.ts b/server/mocks/EventMock.ts index cd3faef1d..6384e042b 100644 --- a/server/mocks/EventMock.ts +++ b/server/mocks/EventMock.ts @@ -25,8 +25,7 @@ export const mockCreateEventDto = { location: "NYC", startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-10T00:00:00.000Z"), - mainTopic: { name: "Climate", wikidataId: "Q1" }, - filterTopics: [{ name: "Science", wikidataId: "Q2" }], + mainTopic: { name: "Climate", label: "Climate", wikidataId: "Q1" }, }; export const mockEventsService = { diff --git a/server/tests/event.e2e.spec.ts b/server/tests/event.e2e.spec.ts index 4d7d3e97a..8cc7ded9f 100644 --- a/server/tests/event.e2e.spec.ts +++ b/server/tests/event.e2e.spec.ts @@ -33,15 +33,10 @@ describe("EventController (e2e)", () => { startDate: startDate.toISOString(), endDate: endDate.toISOString(), mainTopic: { + label: mainTopicName, name: mainTopicName, wikidataId: "Q125928", }, - filterTopics: [ - { - name: "Science", - wikidataId: "Q336", - }, - ], }); beforeAll(async () => { @@ -117,7 +112,6 @@ describe("EventController (e2e)", () => { expect(body).toHaveProperty("data_hash"); expect(body.name).toEqual("Future Event Alpha"); expect(body).toHaveProperty("mainTopic"); - expect(body).toHaveProperty("filterTopics"); }); }); From 0d89f34280aa19e9afc04c416dcd726768c663b8 Mon Sep 17 00:00:00 2001 From: LuizFNJ Date: Tue, 3 Mar 2026 17:14:28 +0100 Subject: [PATCH 09/10] refactor(events): reorganize event components using the proximity rule --- public/locales/en/events.json | 2 +- public/locales/pt/events.json | 4 ++-- .../{event => Event/EventForm}/CreateEventForm.ts | 4 ++-- .../{event => Event/EventForm}/CreateEventView.tsx | 6 +++--- .../EventForm}/DynamicEventForm.tsx | 7 +++---- .../{event => Event/EventForm}/EventDrawer.tsx | 0 .../{event => Event/EventList}/EventCard.tsx | 4 ++-- .../{event => Event/EventList}/EventCardAction.tsx | 2 +- .../EventList}/EventCardDateRange.tsx | 2 +- .../{event => Event/EventList}/EventCardHeader.tsx | 2 +- .../{event => Event/EventList}/EventCardTitle.tsx | 2 +- .../{event => Event/EventList}/EventFilters.tsx | 4 ++-- .../{event => Event/EventList}/EventLoadMore.tsx | 2 +- .../{event => Event/EventList}/EventTitle.tsx | 2 +- .../EventList/EventsList.tsx} | 14 +++++++------- src/components/Form/FormField.ts | 2 +- src/pages/event-create.tsx | 2 +- src/pages/event-page.tsx | 4 ++-- 18 files changed, 32 insertions(+), 33 deletions(-) rename src/components/{event => Event/EventForm}/CreateEventForm.ts (86%) rename src/components/{event => Event/EventForm}/CreateEventView.tsx (91%) rename src/components/{event => Event/EventForm}/DynamicEventForm.tsx (85%) rename src/components/{event => Event/EventForm}/EventDrawer.tsx (100%) rename src/components/{event => Event/EventList}/EventCard.tsx (93%) rename src/components/{event => Event/EventList}/EventCardAction.tsx (88%) rename src/components/{event => Event/EventList}/EventCardDateRange.tsx (95%) rename src/components/{event => Event/EventList}/EventCardHeader.tsx (95%) rename src/components/{event => Event/EventList}/EventCardTitle.tsx (92%) rename src/components/{event => Event/EventList}/EventFilters.tsx (92%) rename src/components/{event => Event/EventList}/EventLoadMore.tsx (92%) rename src/components/{event => Event/EventList}/EventTitle.tsx (93%) rename src/components/{event/EventView.tsx => Event/EventList/EventsList.tsx} (90%) diff --git a/public/locales/en/events.json b/public/locales/en/events.json index 22583b901..10741df9c 100644 --- a/public/locales/en/events.json +++ b/public/locales/en/events.json @@ -10,7 +10,7 @@ "filterUpcoming": "Upcoming", "filterFinished": "Finished", "badgeLabel": "Badge", - "badgePlaceholder": "Enter badge", + "badgePlaceholder": "Enter tag (e.g., COP30)", "nameLabel": "Name", "namePlaceholder": "Enter event name", "descriptionLabel": "Description", diff --git a/public/locales/pt/events.json b/public/locales/pt/events.json index 090f1b373..d56c6486e 100644 --- a/public/locales/pt/events.json +++ b/public/locales/pt/events.json @@ -9,8 +9,8 @@ "filterHappening": "Agora", "filterUpcoming": "Em breve", "filterFinished": "Encerrados", - "badgeLabel": "Badge", - "badgePlaceholder": "Digite o badge", + "badgeLabel": "Etiqueta", + "badgePlaceholder": "Digite a etiqueta (ex: COP30)", "nameLabel": "Nome", "namePlaceholder": "Digite o nome do evento", "descriptionLabel": "Descrição", diff --git a/src/components/event/CreateEventForm.ts b/src/components/Event/EventForm/CreateEventForm.ts similarity index 86% rename from src/components/event/CreateEventForm.ts rename to src/components/Event/EventForm/CreateEventForm.ts index 5576b654d..66d0c34bd 100644 --- a/src/components/event/CreateEventForm.ts +++ b/src/components/Event/EventForm/CreateEventForm.ts @@ -1,4 +1,4 @@ -import { createFormField, FormField } from "../Form/FormField"; +import { createFormField, FormField } from "../../Form/FormField"; const lifecycleEventForm: FormField[] = [ createFormField({ @@ -39,7 +39,7 @@ const lifecycleEventForm: FormField[] = [ }), createFormField({ fieldName: "mainTopic", - type: "selectImpactArea",//standardize the type and component to a more generic name + type: "selectImpactArea", //standardize the type and component to a more generic name defaultValue: "", i18nNamespace: "events", }), diff --git a/src/components/event/CreateEventView.tsx b/src/components/Event/EventForm/CreateEventView.tsx similarity index 91% rename from src/components/event/CreateEventView.tsx rename to src/components/Event/EventForm/CreateEventView.tsx index 412b9adee..337135d2a 100644 --- a/src/components/event/CreateEventView.tsx +++ b/src/components/Event/EventForm/CreateEventView.tsx @@ -1,11 +1,11 @@ import React, { useState } from "react"; import { Grid } from "@mui/material"; -import colors from "../../styles/colors"; +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 { currentNameSpace } from "../../../atoms/namespace"; +import EventApi from "../../../api/eventApi"; import DynamicEventForm from "./DynamicEventForm"; const CreateEventView = () => { diff --git a/src/components/event/DynamicEventForm.tsx b/src/components/Event/EventForm/DynamicEventForm.tsx similarity index 85% rename from src/components/event/DynamicEventForm.tsx rename to src/components/Event/EventForm/DynamicEventForm.tsx index 2459766d9..a5d19864a 100644 --- a/src/components/event/DynamicEventForm.tsx +++ b/src/components/Event/EventForm/DynamicEventForm.tsx @@ -1,9 +1,8 @@ import React from "react"; import { useForm } from "react-hook-form"; -import moment from "moment"; -import DynamicForm from "../Form/DynamicForm"; -import SharedFormFooter from "../SharedFormFooter"; -import { IDynamicEventForm } from "../../types/event"; +import DynamicForm from "../../Form/DynamicForm"; +import SharedFormFooter from "../../SharedFormFooter"; +import { IDynamicEventForm } from "../../../types/event"; import lifecycleEventForm from "./CreateEventForm"; const DynamicEventForm = ({ diff --git a/src/components/event/EventDrawer.tsx b/src/components/Event/EventForm/EventDrawer.tsx similarity index 100% rename from src/components/event/EventDrawer.tsx rename to src/components/Event/EventForm/EventDrawer.tsx diff --git a/src/components/event/EventCard.tsx b/src/components/Event/EventList/EventCard.tsx similarity index 93% rename from src/components/event/EventCard.tsx rename to src/components/Event/EventList/EventCard.tsx index 09942c9f4..753fb4bc2 100644 --- a/src/components/event/EventCard.tsx +++ b/src/components/Event/EventList/EventCard.tsx @@ -1,8 +1,8 @@ import React from "react"; import { Grid } from "@mui/material"; -import CardBase from "../CardBase"; +import CardBase from "../../CardBase"; import { i18n } from "next-i18next"; -import { EventPayload } from "../../types/event"; +import { EventPayload } from "../../../types/event"; import EventCardHeader from "./EventCardHeader"; import EventCardTitle from "./EventCardTitle"; import EventCardDateRange from "./EventCardDateRange"; diff --git a/src/components/event/EventCardAction.tsx b/src/components/Event/EventList/EventCardAction.tsx similarity index 88% rename from src/components/event/EventCardAction.tsx rename to src/components/Event/EventList/EventCardAction.tsx index f24d15521..52c9c2ddb 100644 --- a/src/components/event/EventCardAction.tsx +++ b/src/components/Event/EventList/EventCardAction.tsx @@ -1,5 +1,5 @@ import React from "react"; -import AletheiaButton, { ButtonType } from "../Button"; +import AletheiaButton, { ButtonType } from "../../Button"; interface EventCardActionProps { label: string; diff --git a/src/components/event/EventCardDateRange.tsx b/src/components/Event/EventList/EventCardDateRange.tsx similarity index 95% rename from src/components/event/EventCardDateRange.tsx rename to src/components/Event/EventList/EventCardDateRange.tsx index 21224187c..bb5189c1f 100644 --- a/src/components/event/EventCardDateRange.tsx +++ b/src/components/Event/EventList/EventCardDateRange.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Grid, Typography } from "@mui/material"; import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; -import colors from "../../styles/colors"; +import colors from "../../../styles/colors"; interface EventCardDateRangeProps { startDate: Date; diff --git a/src/components/event/EventCardHeader.tsx b/src/components/Event/EventList/EventCardHeader.tsx similarity index 95% rename from src/components/event/EventCardHeader.tsx rename to src/components/Event/EventList/EventCardHeader.tsx index f8721c234..447715f0c 100644 --- a/src/components/event/EventCardHeader.tsx +++ b/src/components/Event/EventList/EventCardHeader.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Grid, Typography } from "@mui/material"; -import colors from "../../styles/colors"; +import colors from "../../../styles/colors"; interface EventCardHeaderProps { badge: string; diff --git a/src/components/event/EventCardTitle.tsx b/src/components/Event/EventList/EventCardTitle.tsx similarity index 92% rename from src/components/event/EventCardTitle.tsx rename to src/components/Event/EventList/EventCardTitle.tsx index 27558969f..52c319264 100644 --- a/src/components/event/EventCardTitle.tsx +++ b/src/components/Event/EventList/EventCardTitle.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Grid, Typography } from "@mui/material"; -import colors from "../../styles/colors"; +import colors from "../../../styles/colors"; interface EventCardTitleProps { title: string; diff --git a/src/components/event/EventFilters.tsx b/src/components/Event/EventList/EventFilters.tsx similarity index 92% rename from src/components/event/EventFilters.tsx rename to src/components/Event/EventList/EventFilters.tsx index 481f60663..88e5168ab 100644 --- a/src/components/event/EventFilters.tsx +++ b/src/components/Event/EventList/EventFilters.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Grid } from "@mui/material"; -import AletheiaButton, { ButtonType } from "../Button"; -import { EventStatus } from "../../types/event"; +import AletheiaButton, { ButtonType } from "../../Button"; +import { EventStatus } from "../../../types/event"; interface EventFiltersProps { selectedStatus: EventStatus; diff --git a/src/components/event/EventLoadMore.tsx b/src/components/Event/EventList/EventLoadMore.tsx similarity index 92% rename from src/components/event/EventLoadMore.tsx rename to src/components/Event/EventList/EventLoadMore.tsx index f4ff1b8ec..5a4af15a2 100644 --- a/src/components/event/EventLoadMore.tsx +++ b/src/components/Event/EventList/EventLoadMore.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Grid } from "@mui/material"; -import AletheiaButton, { ButtonType } from "../Button"; +import AletheiaButton, { ButtonType } from "../../Button"; interface EventLoadMoreProps { visible: boolean; diff --git a/src/components/event/EventTitle.tsx b/src/components/Event/EventList/EventTitle.tsx similarity index 93% rename from src/components/event/EventTitle.tsx rename to src/components/Event/EventList/EventTitle.tsx index 91d1bf09c..896ce3a52 100644 --- a/src/components/event/EventTitle.tsx +++ b/src/components/Event/EventList/EventTitle.tsx @@ -1,5 +1,5 @@ import { Grid, Typography } from "@mui/material"; -import colors from "../../styles/colors"; +import colors from "../../../styles/colors"; interface EventTitleProps { total: number; diff --git a/src/components/event/EventView.tsx b/src/components/Event/EventList/EventsList.tsx similarity index 90% rename from src/components/event/EventView.tsx rename to src/components/Event/EventList/EventsList.tsx index d36ef13af..2a3ec43c7 100644 --- a/src/components/event/EventView.tsx +++ b/src/components/Event/EventList/EventsList.tsx @@ -1,12 +1,12 @@ 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 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 ErrorState from "../../ErrorState"; import EventFilters from "./EventFilters"; import EventLoadMore from "./EventLoadMore"; import EventTitle from "./EventTitle"; @@ -16,7 +16,7 @@ interface IData { total: number } -const EventView = () => { +const EventsList = () => { const { t } = useTranslation(); const [data, setData] = useState({ events: [], total: 0 }); const [loading, setLoading] = useState(false); @@ -100,4 +100,4 @@ const EventView = () => { ) } -export default EventView +export default EventsList diff --git a/src/components/Form/FormField.ts b/src/components/Form/FormField.ts index dccdc5bd9..60a84b065 100644 --- a/src/components/Form/FormField.ts +++ b/src/components/Form/FormField.ts @@ -111,7 +111,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)) { diff --git a/src/pages/event-create.tsx b/src/pages/event-create.tsx index 3a4fe71c9..aaee12224 100644 --- a/src/pages/event-create.tsx +++ b/src/pages/event-create.tsx @@ -8,7 +8,7 @@ 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/CreateEventView"; +import CreateEventView from "../components/Event/EventForm/CreateEventView"; const CreateEventPage: NextPage<{ nameSpace: NameSpaceEnum; diff --git a/src/pages/event-page.tsx b/src/pages/event-page.tsx index d5a56bb22..69c22b6fc 100644 --- a/src/pages/event-page.tsx +++ b/src/pages/event-page.tsx @@ -6,7 +6,7 @@ import { currentNameSpace } from "../atoms/namespace"; import { GetLocale } from "../utils/GetLocale"; import { useDispatch } from "react-redux"; import actions from "../store/actions"; -import EventView from "../components/event/EventView"; +import EventsList from "../components/Event/EventList/EventsList"; import AffixButton from "../components/AffixButton/AffixButton"; const EventPage: NextPage<{ @@ -21,7 +21,7 @@ const EventPage: NextPage<{ return ( <> - + ); From 78738e6802cbfb907949a3f785629ee1c3fa4445 Mon Sep 17 00:00:00 2001 From: LuizFNJ Date: Tue, 3 Mar 2026 17:15:48 +0100 Subject: [PATCH 10/10] feat(forms): add tooltip support to dynamic components --- public/locales/en/events.json | 3 +-- public/locales/pt/events.json | 3 +-- src/components/Event/EventForm/CreateEventForm.ts | 1 + src/components/Form/DynamicForm.tsx | 11 +++++++++++ src/components/Form/FormField.ts | 4 ++++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/public/locales/en/events.json b/public/locales/en/events.json index 10741df9c..d0d6bdbdc 100644 --- a/public/locales/en/events.json +++ b/public/locales/en/events.json @@ -23,6 +23,5 @@ "endDatePlaceholder": "Select end date", "mainTopicLabel": "Main topic", "mainTopicPlaceholder": "Search and select the main topic", - "filterTopicsLabel": "Filter topics", - "filterTopicsPlaceholder": "Search and select filter topics" + "mainTopicLabelTooltip": "Reference topic used to link the fact-checks and verification requests that will appear on the event page." } diff --git a/public/locales/pt/events.json b/public/locales/pt/events.json index d56c6486e..0ad2c14f5 100644 --- a/public/locales/pt/events.json +++ b/public/locales/pt/events.json @@ -23,6 +23,5 @@ "endDatePlaceholder": "Selecione a data de término", "mainTopicLabel": "Tópico principal", "mainTopicPlaceholder": "Busque e selecione o tópico principal", - "filterTopicsLabel": "Tópicos de filtro", - "filterTopicsPlaceholder": "Busque e selecione tópicos de filtro" + "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/src/components/Event/EventForm/CreateEventForm.ts b/src/components/Event/EventForm/CreateEventForm.ts index 66d0c34bd..34f78b548 100644 --- a/src/components/Event/EventForm/CreateEventForm.ts +++ b/src/components/Event/EventForm/CreateEventForm.ts @@ -41,6 +41,7 @@ const lifecycleEventForm: FormField[] = [ fieldName: "mainTopic", type: "selectImpactArea", //standardize the type and component to a more generic name defaultValue: "", + hasTooltip: true, i18nNamespace: "events", }), ]; diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx index be13cd82f..97cab4e0e 100644 --- a/src/components/Form/DynamicForm.tsx +++ b/src/components/Form/DynamicForm.tsx @@ -4,6 +4,8 @@ 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, @@ -43,6 +45,15 @@ const DynamicForm = ({ * } {t(label)} + {fieldItem.hasTooltip ? + + } + useCustomStyle={false} + /> : null + }
{ 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",