diff --git a/public/locales/en/affix.json b/public/locales/en/affix.json
index 711800335..b2420a85a 100644
--- a/public/locales/en/affix.json
+++ b/public/locales/en/affix.json
@@ -1,10 +1,11 @@
{
- "affixButtonTitle": "Click here to add a claim or a personality",
- "affixButtonCreateClaim": "Click here to add a claim",
- "affixButtonCreatePersonality": "Click here to add a personality",
- "affixButtonCreateVerifiedSources": "Click here to add a source",
- "affixButtonCreateVerificationRequest": "Click here to add a verification request",
- "affixCallToActionButton": "Click here",
- "AffixCloseTooltip": "Close",
- "affixCopilotTitle": "Click here to open the Aletheia's Assistant"
+ "affixButtonTitle": "Click here to add a claim or a personality",
+ "affixButtonCreateClaim": "Click here to add a claim",
+ "affixButtonCreatePersonality": "Click here to add a personality",
+ "affixButtonCreateVerifiedSources": "Click here to add a source",
+ "affixButtonCreateVerificationRequest": "Click here to add a verification request",
+ "affixButtonCreateEvent": "Click here to add a event",
+ "affixCallToActionButton": "Click here",
+ "AffixCloseTooltip": "Close",
+ "affixCopilotTitle": "Click here to open the Aletheia's Assistant"
}
diff --git a/public/locales/en/events.json b/public/locales/en/events.json
new file mode 100644
index 000000000..d0d6bdbdc
--- /dev/null
+++ b/public/locales/en/events.json
@@ -0,0 +1,27 @@
+{
+ "eventCreateSuccess": "Event created successfully",
+ "totalItems": "{{total}} in total",
+ "openEvent": "See event",
+ "fetchError": "Error fetching events. Please try again later.",
+ "loadMoreButton": "Load more",
+ "eventsList": "Events",
+ "filterAll": "All",
+ "filterOngoing": "Ongoing",
+ "filterUpcoming": "Upcoming",
+ "filterFinished": "Finished",
+ "badgeLabel": "Badge",
+ "badgePlaceholder": "Enter tag (e.g., COP30)",
+ "nameLabel": "Name",
+ "namePlaceholder": "Enter event name",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "Describe the event",
+ "locationLabel": "Location",
+ "locationPlaceholder": "Enter event location",
+ "startDateLabel": "Start date",
+ "startDatePlaceholder": "Select start date",
+ "endDateLabel": "End date",
+ "endDatePlaceholder": "Select end date",
+ "mainTopicLabel": "Main topic",
+ "mainTopicPlaceholder": "Search and select the main topic",
+ "mainTopicLabelTooltip": "Reference topic used to link the fact-checks and verification requests that will appear on the event page."
+}
diff --git a/public/locales/en/seo.json b/public/locales/en/seo.json
index c6d90cdf3..73942f37e 100644
--- a/public/locales/en/seo.json
+++ b/public/locales/en/seo.json
@@ -17,5 +17,7 @@
"verificationRequestTitle": "Verification Requests",
"verificationRequestDescription": "See verification requests on AletheiaFact.org",
"createVerificationRequestTitle": "Create verification request",
- "createVerificationRequestDescription": "Create verification requests to AletheiaFact.org"
+ "createVerificationRequestDescription": "Create verification requests to AletheiaFact.org",
+ "createEventTitle": "Create event",
+ "createEventDescription": "Create event to AletheiaFact.org"
}
diff --git a/public/locales/pt/affix.json b/public/locales/pt/affix.json
index 22983d3c5..76542dbef 100644
--- a/public/locales/pt/affix.json
+++ b/public/locales/pt/affix.json
@@ -1,10 +1,11 @@
{
- "affixButtonTitle": "Clique aqui para adicionar uma afirmação ou personalidade",
- "affixButtonCreateClaim": "Clique aqui para adicionar uma afirmação",
- "affixButtonCreatePersonality": "Clique aqui para adicionar uma personalidade",
- "affixButtonCreateVerifiedSources": "Clique aqui para adicionar uma informação checada",
- "affixButtonCreateVerificationRequest": "Clique aqui para adicionar uma denúncia",
- "affixCallToActionButton": "Clique aqui",
- "AffixCloseTooltip": "Fechar",
- "affixCopilotTitle": "Clique aqui para abrir o Assistente Aletheia"
+ "affixButtonTitle": "Clique aqui para adicionar uma afirmação ou personalidade",
+ "affixButtonCreateClaim": "Clique aqui para adicionar uma afirmação",
+ "affixButtonCreatePersonality": "Clique aqui para adicionar uma personalidade",
+ "affixButtonCreateVerifiedSources": "Clique aqui para adicionar uma informação checada",
+ "affixButtonCreateVerificationRequest": "Clique aqui para adicionar uma denúncia",
+ "affixButtonCreateEvent": "Clique aqui para adicionar um evento",
+ "affixCallToActionButton": "Clique aqui",
+ "AffixCloseTooltip": "Fechar",
+ "affixCopilotTitle": "Clique aqui para abrir o Assistente Aletheia"
}
diff --git a/public/locales/pt/events.json b/public/locales/pt/events.json
new file mode 100644
index 000000000..0ad2c14f5
--- /dev/null
+++ b/public/locales/pt/events.json
@@ -0,0 +1,27 @@
+{
+ "eventCreateSuccess": "Evento criado com sucesso",
+ "totalItems": "{{total}} no total",
+ "openEvent": "Veja evento",
+ "fetchError": "Erro ao carregar eventos. Por favor, tente novamente mais tarde.",
+ "loadMoreButton": "Carregar mais",
+ "eventsList": "Eventos",
+ "filterAll": "Todos",
+ "filterHappening": "Agora",
+ "filterUpcoming": "Em breve",
+ "filterFinished": "Encerrados",
+ "badgeLabel": "Etiqueta",
+ "badgePlaceholder": "Digite a etiqueta (ex: COP30)",
+ "nameLabel": "Nome",
+ "namePlaceholder": "Digite o nome do evento",
+ "descriptionLabel": "Descrição",
+ "descriptionPlaceholder": "Descreva o evento",
+ "locationLabel": "Local",
+ "locationPlaceholder": "Digite o local do evento",
+ "startDateLabel": "Data de início",
+ "startDatePlaceholder": "Selecione a data de início",
+ "endDateLabel": "Data de término",
+ "endDatePlaceholder": "Selecione a data de término",
+ "mainTopicLabel": "Tópico principal",
+ "mainTopicPlaceholder": "Busque e selecione o tópico principal",
+ "mainTopicLabelTooltip": "Tópico de referência usado para vincular as checagens e as denúncias que serão exibidos na página do evento."
+}
diff --git a/public/locales/pt/seo.json b/public/locales/pt/seo.json
index 8c5726e36..7d1a90a85 100644
--- a/public/locales/pt/seo.json
+++ b/public/locales/pt/seo.json
@@ -17,5 +17,7 @@
"verificationRequestTitle": "Denúncias",
"verificationRequestDescription": "Veja as denúncias na AletheiaFact.org",
"createVerificationRequestTitle": "Adicione uma denuncia",
- "createVerificationRequestDescription": "Adiciona uma denúncia na AletheiaFact.org"
+ "createVerificationRequestDescription": "Adiciona uma denúncia na AletheiaFact.org",
+ "createEventTitle": "Adicione um evento",
+ "createEventDescription": "Adicione um na AletheiaFact.org"
}
diff --git a/server/app.module.ts b/server/app.module.ts
index f45273eed..fe83b7423 100644
--- a/server/app.module.ts
+++ b/server/app.module.ts
@@ -65,6 +65,7 @@ import { M2MGuard } from "./auth/m2m.guard";
import { CallbackDispatcherModule } from "./callback-dispatcher/callback-dispatcher.module";
import { AiTaskModule } from "./ai-task/ai-task.module";
import { TrackingModule } from "./tracking/tracking.module";
+import { EventsModule } from "./events/event.module";
import { ManagementModule } from "./management/management.module";
@Module({})
@@ -123,6 +124,7 @@ export class AppModule implements NestModule {
ClaimRevisionModule,
HistoryModule,
TrackingModule,
+ EventsModule,
ManagementModule,
StateEventModule,
SourceModule,
diff --git a/server/claim/types/sentence/sentence.service.ts b/server/claim/types/sentence/sentence.service.ts
index 607a40575..fb4e34223 100644
--- a/server/claim/types/sentence/sentence.service.ts
+++ b/server/claim/types/sentence/sentence.service.ts
@@ -1,6 +1,8 @@
import {
BadRequestException,
Injectable,
+ InternalServerErrorException,
+ Logger,
NotFoundException,
} from "@nestjs/common";
import { Model } from "mongoose";
@@ -8,8 +10,9 @@ import { SentenceDocument, Sentence } from "./schemas/sentence.schema";
import { InjectModel } from "@nestjs/mongoose";
import { ReportService } from "../../../report/report.service";
import { UtilService } from "../../../util";
-import { allCop30WikiDataIds } from "../../../../src/constants/cop30Filters";
import type { Cop30Sentence } from "../../../../src/types/Cop30Sentence";
+import { TopicRelatedSentencesResponse } from "./types/sentence.interfaces";
+import { allCop30WikiDataIds } from "../../../../src/constants/cop30Filters";
import type { Cop30Stats } from "../../../../src/types/Cop30Stats";
import { buildStats } from "../../../../src/components/Home/COP30/utils/classification";
@@ -24,6 +27,8 @@ interface FindAllOptionsFilters {
@Injectable()
export class SentenceService {
+ private readonly logger = new Logger(SentenceService.name);
+
constructor(
@InjectModel(Sentence.name)
private SentenceModel: Model,
@@ -31,6 +36,64 @@ export class SentenceService {
private util: UtilService
) {}
+ /**
+ * Fetches sentences based on topic values and enriches them with review classification.
+ * Uses an aggregation pipeline for cross-collection lookup.
+ * @param query - Topic wikidataId array to filter sentences.
+ * @returns A list of sentences with their respective classification.
+ */
+ async getSentencesByTopics(query: string[]): Promise {
+ try {
+ this.logger.debug(`Fetching sentences for topics: ${query.join(', ')}`);
+
+ const aggregation: any[] = [
+ { $match: { "topics.value": { $in: query } } },
+ {
+ $lookup: {
+ from: "reviewtasks",
+ let: { sentenceDataHash: "$data_hash" },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $eq: [
+ "$machine.context.reviewData.data_hash",
+ "$$sentenceDataHash",
+ ],
+ },
+ },
+ },
+ {
+ $project: {
+ _id: 0,
+ classification: "$machine.context.reviewData.classification",
+ },
+ },
+ ],
+ as: "reviewInfo",
+ },
+ },
+ {
+ $addFields: {
+ classification: {
+ $arrayElemAt: ["$reviewInfo.classification", 0],
+ },
+ },
+ },
+ { $project: { reviewInfo: 0 } },
+ ];
+
+ const result = await this.SentenceModel.aggregate(aggregation).exec();
+
+ this.logger.log(`Found ${result.length} sentences for the requested topics.`);
+
+ return result;
+ } catch (error) {
+ this.logger.error(`Error in getSentencesByTopics: ${error.message}`, error.stack);
+ throw new InternalServerErrorException("Failed to aggregate sentences with review data.");
+ }
+ }
+
async getSentencesWithCop30Topics(): Promise {
const aggregation = [
{ $match: { "topics.value": { $in: allCop30WikiDataIds } } },
diff --git a/server/claim/types/sentence/types/sentence.interfaces.ts b/server/claim/types/sentence/types/sentence.interfaces.ts
new file mode 100644
index 000000000..dc74c5fe8
--- /dev/null
+++ b/server/claim/types/sentence/types/sentence.interfaces.ts
@@ -0,0 +1,12 @@
+import { Sentence } from "../schemas/sentence.schema";
+
+export interface Review {
+ personality: string;
+ usersId: string;
+ isPartialReview: boolean;
+}
+
+export type TopicRelatedSentencesResponse = Sentence & {
+ classification?: string;
+ review?: Review;
+}
diff --git a/server/events/dto/event.dto.ts b/server/events/dto/event.dto.ts
new file mode 100644
index 000000000..e1b609fa4
--- /dev/null
+++ b/server/events/dto/event.dto.ts
@@ -0,0 +1,67 @@
+import {
+ IsArray,
+ IsDate,
+ IsNotEmpty,
+ IsObject,
+ IsOptional,
+ IsString
+} from "class-validator";
+import { ApiProperty, PartialType } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import type { TopicData } from "../../topic/types/topic.interfaces";
+
+export class CreateEventDTO {
+ @IsString()
+ @IsOptional()
+ @ApiProperty()
+ nameSpace?: string;
+
+ @IsString()
+ @IsNotEmpty()
+ @ApiProperty()
+ badge: string;
+
+ @IsString()
+ @IsNotEmpty()
+ @ApiProperty()
+ name: string;
+
+ @IsString()
+ @IsNotEmpty()
+ @ApiProperty()
+ description: string;
+
+ @IsString()
+ @IsNotEmpty()
+ @ApiProperty()
+ location: string;
+
+ @IsDate()
+ @IsNotEmpty()
+ @Type(() => Date)
+ @ApiProperty()
+ startDate: Date;
+
+ @IsDate()
+ @IsNotEmpty()
+ @Type(() => Date)
+ @ApiProperty()
+ endDate: Date;
+
+ @IsObject()
+ @IsNotEmpty()
+ @ApiProperty()
+ mainTopic: TopicData;
+
+ @IsString()
+ @IsOptional()
+ @ApiProperty()
+ recaptcha?: string;
+}
+
+export class UpdateEventDTO extends PartialType(CreateEventDTO) {
+ @IsArray()
+ @IsOptional()
+ @ApiProperty()
+ filterTopics: TopicData[];
+}
diff --git a/server/events/dto/filter.dto.ts b/server/events/dto/filter.dto.ts
new file mode 100644
index 000000000..9e991def2
--- /dev/null
+++ b/server/events/dto/filter.dto.ts
@@ -0,0 +1,26 @@
+import {
+ IsOptional,
+ IsInt,
+ Min,
+ IsString
+} from "class-validator";
+import { Type } from "class-transformer";
+
+export class FilterEventsDTO {
+ @Type(() => Number)
+ @IsInt()
+ @Min(0)
+ page?: number = 0;
+
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ pageSize?: number = 10;
+
+ @IsString()
+ order?: "asc" | "desc";
+
+ @IsOptional()
+ @IsString()
+ status?: string;
+}
diff --git a/server/events/event.controller.spec.ts b/server/events/event.controller.spec.ts
new file mode 100644
index 000000000..8b0689173
--- /dev/null
+++ b/server/events/event.controller.spec.ts
@@ -0,0 +1,185 @@
+import { Test, TestingModule } from "@nestjs/testing";
+import { NotFoundException } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { EventsController } from "./event.controller";
+import { EventsService } from "./event.service";
+import { ViewService } from "../view/view.service";
+import { AbilitiesGuard } from "../auth/ability/abilities.guard";
+import {
+ mockConfigService,
+ mockEventsService,
+ mockViewService,
+} from "../mocks/EventMock";
+
+describe("EventsController (Unit)", () => {
+ let controller: EventsController;
+
+ beforeEach(async () => {
+ const testingModule: TestingModule = await Test.createTestingModule({
+ controllers: [EventsController],
+ providers: [
+ { provide: ConfigService, useValue: mockConfigService },
+ { provide: EventsService, useValue: mockEventsService },
+ { provide: ViewService, useValue: mockViewService },
+ ],
+ })
+ .overrideGuard(AbilitiesGuard)
+ .useValue({})
+ .compile();
+
+ controller = testingModule.get(EventsController);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("create", () => {
+ it("should delegate creation to eventsService", async () => {
+ const dto = { name: "Event" };
+ const created = { _id: "e1", ...dto };
+ mockEventsService.create.mockResolvedValue(created);
+
+ const result = await controller.create(dto as any);
+
+ expect(mockEventsService.create).toHaveBeenCalledWith(dto);
+ expect(result).toEqual(created);
+ });
+ });
+
+ describe("update", () => {
+ it("should delegate update to eventsService", async () => {
+ const id = "507f1f77bcf86cd799439011";
+ const dto = { name: "Updated" };
+ const updated = { _id: id, ...dto };
+ mockEventsService.update.mockResolvedValue(updated);
+
+ const result = await controller.update(id, dto as any);
+
+ expect(mockEventsService.update).toHaveBeenCalledWith(id, dto);
+ expect(result).toEqual(updated);
+ });
+ });
+
+ describe("findAll", () => {
+ it("should delegate filtering to eventsService", async () => {
+ const query = { page: 0, pageSize: 10, order: "asc", status: "upcoming" };
+ const events = [{ _id: "e1" }];
+ mockEventsService.findAll.mockResolvedValue(events);
+
+ const result = await controller.findAll(query as any);
+
+ expect(mockEventsService.findAll).toHaveBeenCalledWith(query);
+ expect(result).toEqual(events);
+ });
+ });
+
+ describe("eventPage", () => {
+ it("should render /event-page with parsed query, namespace and site key", async () => {
+ const req = {
+ url: "/event?foo=bar",
+ params: { namespace: "main" },
+ };
+ const res = {};
+
+ await controller.eventPage(req as any, res as any);
+
+ expect(mockConfigService.get).toHaveBeenCalledWith("recaptcha_sitekey");
+ expect(mockViewService.render).toHaveBeenCalledWith(
+ req,
+ res,
+ "/event-page",
+ expect.objectContaining({
+ foo: "bar",
+ nameSpace: "main",
+ sitekey: "test-site-key",
+ })
+ );
+ });
+ });
+
+ describe("createEventPage", () => {
+ it("should render /event-create with parsed query, namespace and site key", async () => {
+ const req = {
+ url: "/event/create?foo=bar",
+ params: { namespace: "main" },
+ };
+ const res = {};
+
+ await controller.createEventPage(req as any, res as any);
+
+ expect(mockConfigService.get).toHaveBeenCalledWith("recaptcha_sitekey");
+ expect(mockViewService.render).toHaveBeenCalledWith(
+ req,
+ res,
+ "/event-create",
+ expect.objectContaining({
+ foo: "bar",
+ nameSpace: "main",
+ sitekey: "test-site-key",
+ })
+ );
+ });
+ });
+
+ describe("eventViewPage", () => {
+ it("should render /event-view-page when event exists", async () => {
+ const req = {
+ url: "/event/hash-1?lang=en",
+ params: { namespace: "main", data_hash: "hash-1" },
+ };
+ const res = {};
+ const fullEvent = { _id: "e1", data_hash: "hash-1" };
+
+ mockEventsService.getFullEventByHash.mockResolvedValue(fullEvent);
+
+ await controller.eventViewPage(req as any, res as any);
+
+ expect(mockEventsService.getFullEventByHash).toHaveBeenCalledWith("hash-1");
+ expect(mockConfigService.get).toHaveBeenCalledWith("recaptcha_sitekey");
+ expect(mockViewService.render).toHaveBeenCalledWith(
+ req,
+ res,
+ "/event-view-page",
+ expect.objectContaining({
+ lang: "en",
+ fullEvent,
+ namespace: "main",
+ sitekey: "test-site-key",
+ })
+ );
+ });
+
+ it("should throw NotFoundException when service returns empty event", async () => {
+ const req = {
+ url: "/event/hash-1",
+ params: { namespace: "main", data_hash: "hash-1" },
+ };
+ const res = {};
+
+ mockEventsService.getFullEventByHash.mockResolvedValue(null);
+
+ await expect(controller.eventViewPage(req as any, res as any)).rejects.toThrow(
+ NotFoundException
+ );
+ expect(mockViewService.render).not.toHaveBeenCalled();
+ });
+
+ it("should rethrow service errors and log only non-NotFound errors", async () => {
+ const req = {
+ url: "/event/hash-1",
+ params: { namespace: "main", data_hash: "hash-1" },
+ };
+ const res = {};
+ const error = new Error("unexpected");
+ const loggerErrorSpy = jest.spyOn((controller as any).logger, "error");
+
+ mockEventsService.getFullEventByHash.mockRejectedValue(error);
+
+ await expect(controller.eventViewPage(req as any, res as any)).rejects.toThrow(
+ "unexpected"
+ );
+ expect(loggerErrorSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/server/events/event.controller.ts b/server/events/event.controller.ts
new file mode 100644
index 000000000..037934438
--- /dev/null
+++ b/server/events/event.controller.ts
@@ -0,0 +1,143 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Header,
+ Logger,
+ NotFoundException,
+ Param,
+ Patch,
+ Post,
+ Query,
+ Req,
+ Res
+} from "@nestjs/common";
+import { CreateEventDTO, UpdateEventDTO } from "./dto/event.dto";
+import { ApiTags } from "@nestjs/swagger";
+import { FilterEventsDTO } from "./dto/filter.dto";
+import type { BaseRequest } from "../types";
+import type { Response } from "express";
+import { FactCheckerOnly, Public } from "../auth/decorators/auth.decorator";
+import { parse } from "url";
+import { ObjectIdValidationPipe } from "../ai-task/pipes/objectid-validation.pipe";
+import { ConfigService } from "@nestjs/config";
+import { ViewService } from "../view/view.service";
+import { EventsService } from "./event.service";
+
+
+@Controller(":namespace?")
+export class EventsController {
+ private readonly logger = new Logger(EventsController.name);
+
+ constructor(
+ private configService: ConfigService,
+ private readonly eventsService: EventsService,
+ private viewService: ViewService,
+ ) { }
+
+ @FactCheckerOnly()
+ @ApiTags("event")
+ @Post("api/event")
+ async create(@Body() newEvent: CreateEventDTO) {
+ return this.eventsService.create(newEvent);
+ }
+
+ @FactCheckerOnly()
+ @ApiTags("event")
+ @Patch("api/event/:id")
+ async update(
+ @Param("id", ObjectIdValidationPipe) eventId: string,
+ @Body() updatedEvent: UpdateEventDTO
+ ) {
+ return this.eventsService.update(eventId, updatedEvent);
+ }
+
+ @Public()
+ @ApiTags("event")
+ @Get("api/event")
+ public async findAll(@Query() query: FilterEventsDTO) {
+ return this.eventsService.findAll(query);
+ }
+
+ @Public()
+ @ApiTags("pages")
+ @Get("event")
+ @Header("Cache-Control", "max-age=60")
+ public async eventPage(
+ @Req() req: BaseRequest,
+ @Res() res: Response
+ ) {
+ const parsedUrl = parse(req.url, true);
+
+ const queryObject = Object.assign(parsedUrl.query, {
+ nameSpace: req.params.namespace,
+ sitekey: this.configService.get("recaptcha_sitekey"),
+ });
+
+ await this.viewService.render(
+ req,
+ res,
+ "/event-page",
+ queryObject
+ );
+ }
+
+ @FactCheckerOnly()
+ @ApiTags("pages")
+ @Get("event/create")
+ public async createEventPage(
+ @Req() req: BaseRequest,
+ @Res() res: Response
+ ) {
+ const parsedUrl = parse(req.url, true);
+ const queryObject = Object.assign(parsedUrl.query, {
+ sitekey: this.configService.get("recaptcha_sitekey"),
+ nameSpace: req.params.namespace,
+ });
+
+ await this.viewService.render(
+ req,
+ res,
+ "/event-create",
+ queryObject
+ );
+ }
+
+ @Public()
+ @ApiTags("pages")
+ @Get("event/:data_hash")
+ @Header("Cache-Control", "max-age=60, must-revalidate")
+ public async eventViewPage(
+ @Req() req: BaseRequest,
+ @Res() res: Response,
+ ) {
+ const parsedUrl = parse(req.url, true);
+ const { data_hash } = req.params;
+ try {
+ const fullEvent = await this.eventsService.getFullEventByHash(data_hash);
+
+ if (!fullEvent) {
+ this.logger.warn(`Event not found for hash: ${data_hash}`);
+ throw new NotFoundException("Event not found");
+ }
+
+ const queryObject = Object.assign(parsedUrl.query, {
+ fullEvent,
+ namespace: req.params.namespace,
+ sitekey: this.configService.get("recaptcha_sitekey"),
+ });
+
+ await this.viewService.render(
+ req,
+ res,
+ "/event-view-page",
+ queryObject
+ );
+ } catch (error) {
+ if (!(error instanceof NotFoundException)) {
+ this.logger.error(`Error rendering event page: ${error.message}`, error.stack);
+ }
+ throw error;
+ }
+ }
+}
diff --git a/server/events/event.module.ts b/server/events/event.module.ts
new file mode 100644
index 000000000..880b017e4
--- /dev/null
+++ b/server/events/event.module.ts
@@ -0,0 +1,35 @@
+import { Module } from "@nestjs/common";
+import { MongooseModule } from "@nestjs/mongoose";
+import { EventSchema } from "./schema/event.schema";
+import { EventsController } from "./event.controller";
+import { ConfigModule } from "@nestjs/config";
+import { ViewModule } from "../view/view.module";
+import { EventsService } from "./event.service";
+import { VerificationRequestModule } from "../verification-request/verification-request.module";
+import { SentenceModule } from "../claim/types/sentence/sentence.module";
+import { TopicModule } from "../topic/topic.module";
+import { AbilityModule } from "../auth/ability/ability.module";
+
+const EventModel = MongooseModule.forFeature([
+ {
+ name: Event.name,
+ schema: EventSchema,
+ },
+]);
+
+
+@Module({
+ imports: [
+ EventModel,
+ ConfigModule,
+ ViewModule,
+ AbilityModule,
+ VerificationRequestModule,
+ SentenceModule,
+ TopicModule
+ ],
+ controllers: [EventsController],
+ providers: [EventsService],
+ exports: [EventsService],
+})
+export class EventsModule {}
diff --git a/server/events/event.service.spec.ts b/server/events/event.service.spec.ts
new file mode 100644
index 000000000..ac62004dc
--- /dev/null
+++ b/server/events/event.service.spec.ts
@@ -0,0 +1,365 @@
+import { Test, TestingModule } from "@nestjs/testing";
+import { getModelToken } from "@nestjs/mongoose";
+import {
+ BadRequestException,
+ ConflictException,
+ InternalServerErrorException,
+ NotFoundException,
+} from "@nestjs/common";
+import * as crypto from "crypto";
+import { EventsService } from "./event.service";
+import { Event } from "./schema/event.schema";
+import { VerificationRequestService } from "../verification-request/verification-request.service";
+import { SentenceService } from "../claim/types/sentence/sentence.service";
+import { TopicService } from "../topic/topic.service";
+import {
+ mockCreateEventDto,
+ mockEventModel,
+ mockSentenceService,
+ mockTopicService,
+ mockVerificationRequestService,
+} from "../mocks/EventMock";
+
+describe("EventsService (Unit)", () => {
+ let service: EventsService;
+
+ beforeAll(async () => {
+ const testingModule: TestingModule = await Test.createTestingModule({
+ providers: [
+ EventsService,
+ {
+ provide: getModelToken(Event.name),
+ useValue: mockEventModel,
+ },
+ {
+ provide: VerificationRequestService,
+ useValue: mockVerificationRequestService,
+ },
+ {
+ provide: SentenceService,
+ useValue: mockSentenceService,
+ },
+ {
+ provide: TopicService,
+ useValue: mockTopicService,
+ },
+ ],
+ }).compile();
+
+ service = testingModule.get(EventsService);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("create", () => {
+ it("should create an event with generated hash and resolved topics", async () => {
+ const mainTopic = { _id: "t1", name: "Climate", wikidataId: "Q1" };
+ const expectedHash = crypto
+ .createHash("md5")
+ .update(`${mockCreateEventDto.name}-${mockCreateEventDto.startDate}`)
+ .digest("hex");
+ const createdEvent = { _id: "e1", data_hash: expectedHash };
+
+ mockTopicService.findOrCreateTopic.mockResolvedValueOnce(mainTopic);
+ mockEventModel.create.mockResolvedValue(createdEvent);
+
+ const result = await service.create(mockCreateEventDto as any);
+
+ expect(mockTopicService.findOrCreateTopic).toHaveBeenCalledTimes(1);
+ expect(mockTopicService.findOrCreateTopic).toHaveBeenCalledWith({
+ ...mockCreateEventDto.mainTopic,
+ name: mockCreateEventDto.mainTopic.label,
+ });
+ expect(mockEventModel.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...mockCreateEventDto,
+ data_hash: expectedHash,
+ mainTopic: mainTopic._id,
+ })
+ );
+ expect(result).toEqual(createdEvent);
+ });
+
+ it("should throw BadRequestException for schema validation errors", async () => {
+ const validationError = {
+ name: "ValidationError",
+ errors: { name: {}, location: {} },
+ };
+
+ mockTopicService.findOrCreateTopic.mockResolvedValue({ _id: "topic-id" });
+ mockEventModel.create.mockRejectedValue(validationError);
+
+ await expect(service.create(mockCreateEventDto as any)).rejects.toThrow(
+ BadRequestException
+ );
+ });
+
+ it("should throw ConflictException for duplicate key errors", async () => {
+ const duplicateKeyError = {
+ code: 11000,
+ keyPattern: { name: 1 },
+ };
+
+ mockTopicService.findOrCreateTopic.mockResolvedValue({ _id: "topic-id" });
+ mockEventModel.create.mockRejectedValue(duplicateKeyError);
+
+ await expect(service.create(mockCreateEventDto as any)).rejects.toThrow(
+ ConflictException
+ );
+ });
+
+ it("should throw InternalServerErrorException for unknown errors", async () => {
+ mockTopicService.findOrCreateTopic.mockResolvedValue({ _id: "topic-id" });
+ mockEventModel.create.mockRejectedValue(new Error("unexpected"));
+
+ await expect(service.create(mockCreateEventDto as any)).rejects.toThrow(
+ InternalServerErrorException
+ );
+ });
+ });
+
+ describe("update", () => {
+ it("should throw BadRequestException when id is invalid", async () => {
+ await expect(service.update("invalid-id", { name: "Updated" } as any)).rejects.toThrow(
+ BadRequestException
+ );
+
+ expect(mockEventModel.findByIdAndUpdate).not.toHaveBeenCalled();
+ });
+
+ it("should update event and deduplicate filter topics by name", async () => {
+ const id = "507f1f77bcf86cd799439011";
+ const updateDto = {
+ name: "Updated Event",
+ mainTopic: { name: "Main", wikidataId: "Q10" },
+ filterTopics: [
+ { name: "A", wikidataId: "Q11" },
+ { name: "A", wikidataId: "Q11" },
+ { name: "B", wikidataId: "Q12" },
+ ],
+ } as any;
+
+ const resolvedMain = { _id: "tm", name: "Main" };
+ const resolvedFilterA = { _id: "ta", name: "A" };
+ const resolvedFilterB = { _id: "tb", name: "B" };
+ const updatedEvent = { _id: id, ...updateDto };
+
+ mockTopicService.findOrCreateTopic
+ .mockResolvedValueOnce(resolvedMain)
+ .mockResolvedValueOnce(resolvedFilterA)
+ .mockResolvedValueOnce(resolvedFilterB);
+ mockEventModel.findByIdAndUpdate.mockResolvedValue(updatedEvent);
+
+ const result = await service.update(id, updateDto);
+
+ expect(mockTopicService.findOrCreateTopic).toHaveBeenCalledTimes(3);
+ expect(mockEventModel.findByIdAndUpdate).toHaveBeenCalledWith(
+ id,
+ {
+ $set: expect.objectContaining({
+ name: "Updated Event",
+ mainTopic: resolvedMain,
+ filterTopics: [resolvedFilterA, resolvedFilterB],
+ }),
+ },
+ { new: true, runValidators: true }
+ );
+ expect(result).toEqual(updatedEvent);
+ });
+
+ it("should throw NotFoundException if event is not found", async () => {
+ const id = "507f1f77bcf86cd799439011";
+ mockEventModel.findByIdAndUpdate.mockResolvedValue(null);
+
+ await expect(service.update(id, { name: "Updated" } as any)).rejects.toThrow(
+ NotFoundException
+ );
+ });
+
+ it("should throw BadRequestException on cast errors", async () => {
+ const id = "507f1f77bcf86cd799439011";
+ mockEventModel.findByIdAndUpdate.mockRejectedValue({
+ name: "CastError",
+ path: "startDate",
+ });
+
+ await expect(service.update(id, { name: "Updated" } as any)).rejects.toThrow(
+ BadRequestException
+ );
+ });
+
+ it("should throw InternalServerErrorException on unknown failures", async () => {
+ const id = "507f1f77bcf86cd799439011";
+ mockEventModel.findByIdAndUpdate.mockRejectedValue(new Error("unexpected"));
+
+ await expect(service.update(id, { name: "Updated" } as any)).rejects.toThrow(
+ InternalServerErrorException
+ );
+ });
+ });
+
+ describe("findAll", () => {
+ it("should return events list with pagination and order", async () => {
+ const eventsResult = [{ _id: "e1" }, { _id: "e2" }];
+ const exec = jest.fn().mockResolvedValue(eventsResult);
+ const countExec = jest.fn().mockResolvedValue(eventsResult.length);
+ const lean = jest.fn().mockReturnValue({ exec: exec });
+ const sort = jest.fn().mockReturnValue({ lean });
+ const limit = jest.fn().mockReturnValue({ sort });
+ const skip = jest.fn().mockReturnValue({ limit });
+
+ mockEventModel.find.mockReturnValue({ skip });
+ mockEventModel.countDocuments = jest.fn().mockReturnValue({ exec: countExec });
+
+ const result = await service.findAll({
+ page: 1,
+ pageSize: 5,
+ order: "desc",
+ status: "happening",
+ });
+
+ expect(mockEventModel.find).toHaveBeenCalledWith(
+ expect.objectContaining({
+ startDate: expect.any(Object),
+ endDate: expect.any(Object),
+ })
+ );
+ expect(skip).toHaveBeenCalledWith(5);
+ expect(limit).toHaveBeenCalledWith(5);
+ expect(sort).toHaveBeenCalledWith({ _id: "desc" });
+ expect(result).toEqual({
+ events: eventsResult,
+ total: eventsResult.length,
+ });
+ });
+
+ it("should throw InternalServerErrorException when querying fails", async () => {
+ const exec = jest.fn().mockRejectedValue(new Error("db fail"));
+ const countExec = jest.fn().mockResolvedValue(0);
+ const lean = jest.fn().mockReturnValue({ exec: exec });
+ const sort = jest.fn().mockReturnValue({ lean });
+ const limit = jest.fn().mockReturnValue({ sort });
+ const skip = jest.fn().mockReturnValue({ limit });
+
+ mockEventModel.find.mockReturnValue({ skip });
+ mockEventModel.countDocuments = jest.fn().mockReturnValue({ exec: countExec });
+
+ await expect(
+ service.findAll({ page: 0, pageSize: 10, order: "asc", status: "upcoming" })
+ ).rejects.toThrow(InternalServerErrorException);
+ });
+ });
+
+ describe("findByHash", () => {
+ it("should return event when hash exists", async () => {
+ const event = { _id: "e1", data_hash: "hash123" };
+ const exec = jest.fn().mockResolvedValue(event);
+ const populateFilterTopics = jest.fn().mockReturnValue({ exec });
+ const populateMainTopic = jest
+ .fn()
+ .mockReturnValue({ populate: populateFilterTopics });
+
+ mockEventModel.findOne.mockReturnValue({ populate: populateMainTopic });
+
+ const result = await service.findByHash("hash123");
+
+ expect(mockEventModel.findOne).toHaveBeenCalledWith({ data_hash: "hash123" });
+ expect(result).toEqual(event);
+ });
+
+ it("should throw NotFoundException when hash does not exist", async () => {
+ const exec = jest.fn().mockResolvedValue(null);
+ const populateFilterTopics = jest.fn().mockReturnValue({ exec });
+ const populateMainTopic = jest
+ .fn()
+ .mockReturnValue({ populate: populateFilterTopics });
+
+ mockEventModel.findOne.mockReturnValue({ populate: populateMainTopic });
+
+ await expect(service.findByHash("missing-hash")).rejects.toThrow(
+ NotFoundException
+ );
+ });
+ });
+
+ describe("getFullEventByHash", () => {
+ it("should return event with empty arrays when mainTopic is missing", async () => {
+ const eventWithoutMainTopic = { _id: "e1", data_hash: "h1", mainTopic: null };
+ jest
+ .spyOn(service, "findByHash")
+ .mockResolvedValue(eventWithoutMainTopic as any);
+
+ const result = await service.getFullEventByHash("h1");
+
+ expect(result.sentences).toEqual([]);
+ expect(result.verificationRequests).toEqual([]);
+ expect(mockSentenceService.getSentencesByTopics).not.toHaveBeenCalled();
+ expect(mockVerificationRequestService.listAll).not.toHaveBeenCalled();
+ });
+
+ it("should return full event data with sentences and verification requests", async () => {
+ const eventDocument = {
+ _id: "e1",
+ data_hash: "h1",
+ mainTopic: { name: "Climate", wikidataId: "Q1" },
+ toObject: jest.fn().mockReturnValue({
+ _id: "e1",
+ data_hash: "h1",
+ mainTopic: { name: "Climate", wikidataId: "Q1" },
+ }),
+ };
+ const mockSentences = [{ _id: "s1" }];
+ const mockVerificationRequests = [{ _id: "vr1" }];
+
+ jest.spyOn(service, "findByHash").mockResolvedValue(eventDocument as any);
+ mockSentenceService.getSentencesByTopics.mockResolvedValue(mockSentences);
+ mockVerificationRequestService.listAll.mockResolvedValue(
+ mockVerificationRequests
+ );
+
+ const result = await service.getFullEventByHash("h1");
+
+ expect(mockSentenceService.getSentencesByTopics).toHaveBeenCalledWith(["Q1"]);
+ expect(mockVerificationRequestService.listAll).toHaveBeenCalledWith(
+ expect.objectContaining({
+ topics: ["Climate"],
+ })
+ );
+ expect(result).toEqual(
+ expect.objectContaining({
+ _id: "e1",
+ sentences: mockSentences,
+ verificationRequests: mockVerificationRequests,
+ })
+ );
+ });
+
+ it("should fallback to empty arrays when related queries fail", async () => {
+ const eventDocument = {
+ _id: "e1",
+ data_hash: "h1",
+ mainTopic: { name: "Climate", wikidataId: "Q1" },
+ toObject: jest.fn().mockReturnValue({
+ _id: "e1",
+ data_hash: "h1",
+ mainTopic: { name: "Climate", wikidataId: "Q1" },
+ }),
+ };
+
+ jest.spyOn(service, "findByHash").mockResolvedValue(eventDocument as any);
+ mockSentenceService.getSentencesByTopics.mockRejectedValue(
+ new Error("sentence fail")
+ );
+ mockVerificationRequestService.listAll.mockRejectedValue(
+ new Error("vr fail")
+ );
+
+ const result = await service.getFullEventByHash("h1");
+
+ expect(result.sentences).toEqual([]);
+ expect(result.verificationRequests).toEqual([]);
+ });
+ });
+});
diff --git a/server/events/event.service.ts b/server/events/event.service.ts
new file mode 100644
index 000000000..87f6bc5e3
--- /dev/null
+++ b/server/events/event.service.ts
@@ -0,0 +1,255 @@
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ InternalServerErrorException,
+ Logger,
+ NotFoundException
+} from "@nestjs/common";
+import { InjectModel } from "@nestjs/mongoose";
+import { FilterQuery, isValidObjectId, Model } from "mongoose";
+import { EventDocument, Event } from "./schema/event.schema";
+import { VerificationRequestService } from "../verification-request/verification-request.service";
+import { SentenceService } from "../claim/types/sentence/sentence.service";
+import { CreateEventDTO, UpdateEventDTO } from "./dto/event.dto";
+import { FilterEventsDTO } from "./dto/filter.dto";
+import { TopicService } from "../topic/topic.service";
+import { FindAllResponse, FullEventResponse } from "./types/event.interfaces";
+import * as crypto from "crypto";
+
+@Injectable()
+export class EventsService {
+ private readonly logger = new Logger(EventsService.name);
+
+ constructor(
+ @InjectModel(Event.name) private eventModel: Model,
+ private readonly verificationRequestService: VerificationRequestService,
+ private readonly sentenceService: SentenceService,
+ private readonly topicService: TopicService,
+ ) { }
+
+ /**
+ * Creates a new event, processes associated topics, and generates a data hash.
+ * * @param createEventDto - Data transfer object containing event details.
+ * @returns A promise that resolves to the created event document.
+ */
+ async create(createEventDto: CreateEventDTO): Promise {
+ try {
+ this.logger.debug("Creating event", { createEventDto });
+
+ const createdTopic = await this.topicService.findOrCreateTopic({
+ ...createEventDto.mainTopic,
+ name: createEventDto.mainTopic.label
+ })
+
+ const data_hash = crypto
+ .createHash("md5")
+ .update(`${createEventDto.name}-${createEventDto.startDate}`)
+ .digest("hex");
+
+ const newEvent = await this.eventModel.create({
+ ...createEventDto,
+ data_hash,
+ mainTopic: createdTopic._id
+ })
+
+ this.logger.log(`Event created successfully: ${newEvent._id}`);
+ return newEvent;
+ } catch (error) {
+ this.logger.error(`Failed to create event: "${error.name}"`, error.stack);
+
+ if (error.name === "ValidationError") {
+ const fields = Object.keys(error.errors).join(", ");
+ throw new BadRequestException(`Schema validation failed: missing or invalid fields [${fields}]`);
+ }
+
+ if (error.code === 11000) {
+ const duplicateField = Object.keys(error.keyPattern)[0];
+ throw new ConflictException(`Duplicate entry: an event with this ${duplicateField} already exists`);
+ }
+
+ throw new InternalServerErrorException("Unexpected error during event creation");
+ }
+ }
+
+ /**
+ * Partially updates an existing event and synchronizes related topics if provided.
+ * @param id - The unique identifier of the event.
+ * @param updateEventDto - Data transfer object with partial event updates.
+ * @returns The updated event document.
+ */
+ async update(id: string, updateEventDto: UpdateEventDTO): Promise {
+ try {
+ this.logger.debug(`Updating event ${id}`, { updateEventDto });
+
+ if (!isValidObjectId(id)) {
+ throw new BadRequestException(`Invalid event ID format: ${id}`);
+ }
+
+ const { mainTopic, filterTopics, ...otherFields } = updateEventDto;
+
+ const updateData: Partial = { ...otherFields };
+
+ if (mainTopic) {
+ updateData.mainTopic = await this.topicService.findOrCreateTopic(mainTopic);
+ }
+
+ if (filterTopics !== undefined) {
+ const uniqueTopics = Array.from(
+ new Map(filterTopics.map(topic => [topic.name, topic])).values()
+ );
+
+ updateData.filterTopics = await Promise.all(
+ uniqueTopics.map(topic => this.topicService.findOrCreateTopic(topic))
+ );
+ }
+
+ const updatedEvent = await this.eventModel.findByIdAndUpdate(
+ id,
+ { $set: updateData },
+ { new: true, runValidators: true }
+ );
+
+ if (!updatedEvent) {
+ throw new NotFoundException(`Event not found with ID: ${id}`);
+ }
+
+ return updatedEvent;
+ } catch (error) {
+ this.logger.error(`Failed to update event [${id}]`, error.stack);
+
+ if (error instanceof NotFoundException || error instanceof BadRequestException) {
+ throw error;
+ }
+
+ if (error.name === "CastError") {
+ throw new BadRequestException(`Invalid format for field: ${error.path}`);
+ }
+
+ throw new InternalServerErrorException("Unexpected error during event update");
+ }
+ }
+
+ /**
+ * Lists events with pagination and dynamic filters for status.
+ * Useful for public listings and SSR components.
+ * @param queryDto - DTO containing pagination and filter parameters.
+ * @returns A list of event documents.
+ */
+ async findAll(queryDto: FilterEventsDTO): Promise {
+ const { page, pageSize, order, status } = queryDto;
+ try {
+ const query = this.buildStatusQuery(status);
+
+ this.logger.debug(`Fetching events list. Page: ${page}, PageSize: ${pageSize}, Status: ${status}`);
+ this.logger.verbose(`Built MongoDB Query: ${JSON.stringify(query)}`);
+
+ const [events, total] = await Promise.all([
+ this.eventModel.find(query)
+ .skip(page * parseInt(`${pageSize}`, 10))
+ .limit(parseInt(`${pageSize}`, 10))
+ .sort({ _id: order })
+ .lean()
+ .exec(),
+
+ this.eventModel.countDocuments(query).exec()
+ ]);
+
+ return { events, total };
+ } catch (error) {
+ this.logger.error(`Failed to fetch events list: ${error.message}`, error.stack);
+ throw new InternalServerErrorException("An error occurred while fetching the events list.");
+ }
+ }
+
+ /**
+ * Fetches the "Full Content" of an event for the SSR view page.
+ * Aggregates the core Event data, related Sentences, and Verification Requests.
+ * @param data_hash - The unique hash identifier of the event.
+ * @returns A composite object containing the event and its related data.
+ * @throws NotFoundException if the event does not exist.
+ */
+ async getFullEventByHash(data_hash: string): Promise {
+ const currentEvent = await this.findByHash(data_hash);
+
+ if (!currentEvent.mainTopic) {
+ this.logger.error(`Event ${data_hash} exists but is missing mainTopic relation`);
+ return {
+ ...currentEvent,
+ sentences: [],
+ verificationRequests: []
+ };
+ }
+
+ const [eventSentences, eventVerificationRequest] = await Promise.all([
+ this.sentenceService.getSentencesByTopics([currentEvent.mainTopic.wikidataId])
+ .catch(err => {
+ this.logger.error(`Failed to fetch sentences for topic ${currentEvent.mainTopic.wikidataId}`, err.stack);
+ return [];
+ }),
+ this.verificationRequestService.listAll({
+ page: 0,
+ pageSize: 10,
+ order: "asc",
+ topics: [currentEvent.mainTopic.name]
+ }).catch(err => {
+ this.logger.error(`Failed to fetch verification requests for topic ${currentEvent.mainTopic.name}`, err.stack);
+ return [];
+ })
+ ]);
+
+ return {
+ ...currentEvent.toObject(),
+ sentences: eventSentences,
+ verificationRequests: eventVerificationRequest
+ };
+ }
+
+ /**
+ * Searches for an event by its hash.
+ * * @param data_hash - URL identifier hash.
+ * @returns Event document.
+ */
+ async findByHash(data_hash: string): Promise {
+ this.logger.debug(`Fetching public event with hash: ${data_hash}`);
+
+ const event = (await this.eventModel.findOne({ data_hash }).populate("mainTopic").populate("filterTopics").exec());
+
+ if (!event) {
+ this.logger.warn(`Public access attempt failed - Hash not found: ${data_hash}`);
+ throw new NotFoundException(`The requested event was not found.`);
+ }
+
+ return event;
+ }
+
+ /**
+ * Internal helper to build MongoDB date queries based on event status.
+ * @param status - The status string (finalized, upcoming, happening).
+ * @returns A MongoDB query object.
+ */
+ private buildStatusQuery(status?: string): FilterQuery {
+ const query: FilterQuery = {};
+ const now = new Date();
+ now.setHours(0, 0, 0, 0);
+
+ if (!status || status === "all") return query;
+
+ switch (status) {
+ case "finalized":
+ query.endDate = { $lt: now };
+ break;
+ case "upcoming":
+ query.startDate = { $gt: now };
+ break;
+ case "happening":
+ query.startDate = { $lte: now };
+ query.endDate = { $gte: now };
+ break;
+ default:
+ this.logger.warn(`Unknown status provided: ${status}`);
+ }
+
+ return query;
+ }
+}
diff --git a/server/events/schema/event.schema.ts b/server/events/schema/event.schema.ts
new file mode 100644
index 000000000..5e7cb19fe
--- /dev/null
+++ b/server/events/schema/event.schema.ts
@@ -0,0 +1,44 @@
+import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
+import { Document, Schema as MongooseSchema } from "mongoose";
+import { Topic } from "../../topic/schemas/topic.schema";
+
+export type EventDocument = Event & Document;
+
+@Schema({ toObject: { virtuals: true }, toJSON: { virtuals: true }, timestamps: true })
+export class Event {
+ @Prop({ required: true, unique: true })
+ data_hash: string;
+
+ @Prop({ required: true, trim: true })
+ badge: string;
+
+ @Prop({ required: true, trim: true })
+ name: string;
+
+ @Prop({ required: true })
+ description: string;
+
+ @Prop({ required: true })
+ location: string;
+
+ @Prop({ required: true, default: Date.now })
+ startDate: Date;
+
+ @Prop({ required: true, default: Date.now, index: true })
+ endDate: Date;
+
+ @Prop({
+ type: MongooseSchema.Types.ObjectId,
+ required: true,
+ ref: "Topic",
+ })
+ mainTopic: Topic;
+
+ @Prop({
+ type: [{ type: MongooseSchema.Types.ObjectId, ref: "Topic" }],
+ required: false,
+ })
+ filterTopics: Topic[];
+}
+
+export const EventSchema = SchemaFactory.createForClass(Event);
diff --git a/server/events/types/event.interfaces.ts b/server/events/types/event.interfaces.ts
new file mode 100644
index 000000000..5dc5f5b5d
--- /dev/null
+++ b/server/events/types/event.interfaces.ts
@@ -0,0 +1,13 @@
+import { TopicRelatedSentencesResponse } from "../../claim/types/sentence/types/sentence.interfaces";
+import { VerificationRequest } from "../../verification-request/schemas/verification-request.schema";
+import { Event } from "../schema/event.schema";
+
+export type FullEventResponse = Event & {
+ sentences: TopicRelatedSentencesResponse[];
+ verificationRequests: VerificationRequest[];
+};
+
+export interface FindAllResponse {
+ events: Event[];
+ total: number;
+}
diff --git a/server/mocks/EventMock.ts b/server/mocks/EventMock.ts
new file mode 100644
index 000000000..6384e042b
--- /dev/null
+++ b/server/mocks/EventMock.ts
@@ -0,0 +1,44 @@
+export const mockEventModel = {
+ create: jest.fn(),
+ findByIdAndUpdate: jest.fn(),
+ find: jest.fn(),
+ countDocuments: jest.fn(),
+ findOne: jest.fn(),
+};
+
+export const mockVerificationRequestService = {
+ listAll: jest.fn(),
+};
+
+export const mockSentenceService = {
+ getSentencesByTopics: jest.fn(),
+};
+
+export const mockTopicService = {
+ findOrCreateTopic: jest.fn(),
+};
+
+export const mockCreateEventDto = {
+ badge: "event-badge",
+ name: "My Event",
+ description: "My Event Description",
+ location: "NYC",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-10T00:00:00.000Z"),
+ mainTopic: { name: "Climate", label: "Climate", wikidataId: "Q1" },
+};
+
+export const mockEventsService = {
+ create: jest.fn(),
+ update: jest.fn(),
+ findAll: jest.fn(),
+ getFullEventByHash: jest.fn(),
+};
+
+export const mockConfigService = {
+ get: jest.fn().mockReturnValue("test-site-key"),
+};
+
+export const mockViewService = {
+ render: jest.fn(),
+};
diff --git a/server/tests/event.e2e.spec.ts b/server/tests/event.e2e.spec.ts
new file mode 100644
index 000000000..8cc7ded9f
--- /dev/null
+++ b/server/tests/event.e2e.spec.ts
@@ -0,0 +1,249 @@
+import * as request from "supertest";
+import { Test, TestingModule } from "@nestjs/testing";
+import { ValidationPipe } from "@nestjs/common";
+import { AppModule } from "../app.module";
+import { SessionGuard } from "../auth/session.guard";
+import { SessionGuardMock } from "./mocks/SessionGuardMock";
+import { SessionOrM2MGuard } from "../auth/m2m-or-session.guard";
+import { SessionOrM2MGuardMock } from "./mocks/SessionOrM2MGuardMock";
+import { M2MGuard } from "../auth/m2m.guard";
+import { M2MGuardMock } from "./mocks/M2MGuardMock";
+import { AbilitiesGuard } from "../auth/ability/abilities.guard";
+import { AbilitiesGuardMock } from "./mocks/AbilitiesGuardMock";
+import { TestConfigOptions } from "./utils/TestConfigOptions";
+import { SeedTestUser } from "./utils/SeedTestUser";
+import { CleanupDatabase } from "./utils/CleanupDatabase";
+
+jest.setTimeout(10000);
+
+describe("EventController (e2e)", () => {
+ let app: any;
+ let createdEventId: string;
+
+ const createEventPayload = (
+ name: string,
+ startDate: Date,
+ endDate: Date,
+ mainTopicName = "Climate"
+ ) => ({
+ badge: "event-badge",
+ name,
+ description: `${name} description`,
+ location: "New York",
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString(),
+ mainTopic: {
+ label: mainTopicName,
+ name: mainTopicName,
+ wikidataId: "Q125928",
+ },
+ });
+
+ beforeAll(async () => {
+ const mongoUri = process.env.MONGO_URI!;
+
+ await SeedTestUser(mongoUri);
+
+ const testConfig = {
+ ...TestConfigOptions.config,
+ db: {
+ ...TestConfigOptions.config.db,
+ connection_uri: mongoUri,
+ },
+ };
+
+ const moduleFixture: TestingModule = await Test.createTestingModule({
+ imports: [AppModule.register(testConfig)],
+ })
+ .overrideGuard(SessionGuard)
+ .useValue(SessionGuardMock)
+ .overrideGuard(SessionOrM2MGuard)
+ .useValue(SessionOrM2MGuardMock)
+ .overrideGuard(M2MGuard)
+ .useValue(M2MGuardMock)
+ .overrideGuard(AbilitiesGuard)
+ .useValue(AbilitiesGuardMock)
+ .compile();
+
+ app = moduleFixture.createNestApplication();
+ app.useGlobalPipes(
+ new ValidationPipe({
+ transform: true,
+ transformOptions: { enableImplicitConversion: true },
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ })
+ );
+
+ await app.init();
+ });
+
+ it("/api/event (GET) - should return empty list initially", () => {
+ return request(app.getHttpServer())
+ .get("/api/event")
+ .query({
+ page: 0,
+ pageSize: 10,
+ order: "asc",
+ })
+ .expect(200)
+ .expect(({ body }) => {
+ expect(body).toHaveProperty("events");
+ expect(body).toHaveProperty("total");
+ expect(Array.isArray(body.events)).toBe(true);
+ expect(body.events).toHaveLength(0);
+ expect(body.total).toBe(0);
+ });
+ });
+
+ it("/api/event (POST) - should create a new event", () => {
+ const now = new Date();
+ const start = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000);
+ const end = new Date(now.getTime() + 6 * 24 * 60 * 60 * 1000);
+
+ return request(app.getHttpServer())
+ .post("/api/event")
+ .send(createEventPayload("Future Event Alpha", start, end))
+ .expect(201)
+ .expect(({ body }) => {
+ createdEventId = body._id;
+
+ expect(body).toHaveProperty("_id");
+ expect(body).toHaveProperty("data_hash");
+ expect(body.name).toEqual("Future Event Alpha");
+ expect(body).toHaveProperty("mainTopic");
+ });
+ });
+
+ it("/api/event (POST) - should reject duplicate event by unique data_hash", async () => {
+ const now = new Date();
+ const start = new Date(now.getTime() + 8 * 24 * 60 * 60 * 1000);
+ const end = new Date(now.getTime() + 9 * 24 * 60 * 60 * 1000);
+ const payload = createEventPayload("Duplicate Event", start, end);
+
+ await request(app.getHttpServer())
+ .post("/api/event")
+ .send(payload)
+ .expect(201);
+
+ return request(app.getHttpServer())
+ .post("/api/event")
+ .send(payload)
+ .expect(409);
+ });
+
+ it("/api/event (GET) - should list created events", () => {
+ return request(app.getHttpServer())
+ .get("/api/event")
+ .query({
+ page: 0,
+ pageSize: 20,
+ order: "asc",
+ })
+ .expect(200)
+ .expect(({ body }) => {
+ expect(body).toHaveProperty("events");
+ expect(body).toHaveProperty("total");
+ expect(Array.isArray(body.events)).toBe(true);
+ expect(body.events.length).toBeGreaterThanOrEqual(2);
+ expect(body.total).toBeGreaterThanOrEqual(2);
+
+ const created = body.events.find((event) => event._id === createdEventId);
+ expect(created).toBeDefined();
+ expect(created.name).toEqual("Future Event Alpha");
+ });
+ });
+
+ it("/api/event/:id (PATCH) - should update event name", () => {
+ return request(app.getHttpServer())
+ .patch(`/api/event/${createdEventId}`)
+ .send({
+ name: "Future Event Alpha Updated",
+ })
+ .expect(200)
+ .expect(({ body }) => {
+ expect(body._id).toEqual(createdEventId);
+ expect(body.name).toEqual("Future Event Alpha Updated");
+ });
+ });
+
+ it("/api/event/:id (PATCH) - should return 400 for invalid ObjectId", () => {
+ return request(app.getHttpServer())
+ .patch("/api/event/invalid-id")
+ .send({ name: "No Update" })
+ .expect(400);
+ });
+
+ it("/api/event (GET) - should filter by status=upcoming", async () => {
+ const now = new Date();
+ const start = new Date(now.getTime() + 12 * 24 * 60 * 60 * 1000);
+ const end = new Date(now.getTime() + 13 * 24 * 60 * 60 * 1000);
+
+ await request(app.getHttpServer())
+ .post("/api/event")
+ .send(createEventPayload("Future Event Beta", start, end, "Politics"))
+ .expect(201);
+
+ return request(app.getHttpServer())
+ .get("/api/event")
+ .query({
+ page: 0,
+ pageSize: 20,
+ order: "asc",
+ status: "upcoming",
+ })
+ .expect(200)
+ .expect(({ body }) => {
+ expect(body).toHaveProperty("events");
+ expect(body).toHaveProperty("total");
+ expect(Array.isArray(body.events)).toBe(true);
+ expect(body.events.length).toBeGreaterThanOrEqual(1);
+ expect(body.total).toBeGreaterThanOrEqual(1);
+
+ for (const event of body.events) {
+ expect(new Date(event.startDate).getTime()).toBeGreaterThan(Date.now() - 24 * 60 * 60 * 1000);
+ }
+ });
+ });
+
+ it("/api/event (GET) - should accept unknown status and still return list", () => {
+ return request(app.getHttpServer())
+ .get("/api/event")
+ .query({
+ page: 0,
+ pageSize: 20,
+ order: "asc",
+ status: "unknown-status",
+ })
+ .expect(200)
+ .expect(({ body }) => {
+ expect(body).toHaveProperty("events");
+ expect(body).toHaveProperty("total");
+ expect(Array.isArray(body.events)).toBe(true);
+ expect(body.events.length).toBeGreaterThanOrEqual(1);
+ expect(body.total).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("/api/event (POST) - should validate required fields", () => {
+ return request(app.getHttpServer())
+ .post("/api/event")
+ .send({
+ name: "Invalid Event Payload",
+ })
+ .expect(400);
+ });
+
+ it("/api/event/:id (PATCH) - should return 404 for non-existent event id", () => {
+ return request(app.getHttpServer())
+ .patch("/api/event/507f1f77bcf86cd799439011")
+ .send({ name: "Not found update" })
+ .expect(404);
+ });
+
+ afterAll(async () => {
+ jest.restoreAllMocks();
+ await app.close();
+ await CleanupDatabase(process.env.MONGO_URI!);
+ });
+});
diff --git a/server/topic/topic.service.ts b/server/topic/topic.service.ts
index c00b62194..96732ab49 100644
--- a/server/topic/topic.service.ts
+++ b/server/topic/topic.service.ts
@@ -5,6 +5,7 @@ import { Topic, TopicDocument } from "./schemas/topic.schema";
import slugify from "slugify";
import { SentenceService } from "../claim/types/sentence/sentence.service";
import { ContentModelEnum } from "../types/enums";
+import { TopicData } from "../topic/types/topic.interfaces";
import { ImageService } from "../claim/types/image/image.service";
import { WikidataService } from "../wikidata/wikidata.service";
@@ -217,12 +218,7 @@ export class TopicService {
* @param topicData object with { slug?, name, wikidataId?, language?, description? }
* @returns the existing or newly created topic document
*/
- async findOrCreateTopic(topicData: {
- name: string;
- wikidataId?: string;
- language?: string;
- description?: string; // Future use
- }): Promise {
+ async findOrCreateTopic(topicData: TopicData): Promise {
try {
const slug = slugify(topicData.name, { lower: true, strict: true });
diff --git a/server/topic/types/topic.interfaces.ts b/server/topic/types/topic.interfaces.ts
new file mode 100644
index 000000000..002577e8a
--- /dev/null
+++ b/server/topic/types/topic.interfaces.ts
@@ -0,0 +1,7 @@
+export interface TopicData {
+ label?: string;
+ name: string;
+ wikidataId?: string;
+ language?: string;
+ description?: string; // Future use
+}
diff --git a/server/verification-request/verification-request.service.ts b/server/verification-request/verification-request.service.ts
index 65a350786..6486b9b21 100644
--- a/server/verification-request/verification-request.service.ts
+++ b/server/verification-request/verification-request.service.ts
@@ -370,7 +370,7 @@ export class VerificationRequestService {
await this.topicService.findOrCreateTopic(
topicData
);
- return (topic as any)._id;
+ return topic._id;
})
);
@@ -387,7 +387,7 @@ export class VerificationRequestService {
const topic = await this.topicService.findOrCreateTopic(
result
);
- valueToUpdate = (topic as any)._id;
+ valueToUpdate = topic._id;
this.logger.log(
`Impact area topic created/found with ID: ${valueToUpdate}`
);
diff --git a/src/api/eventApi.ts b/src/api/eventApi.ts
new file mode 100644
index 000000000..3c28b37e5
--- /dev/null
+++ b/src/api/eventApi.ts
@@ -0,0 +1,98 @@
+import axios from "axios";
+import { TFunction } from "i18next";
+import { MessageManager } from "../components/Messages";
+import { HEX24 } from "../types/History";
+import { EventPayload, ListEventsOptions } from "../types/event";
+import { NextRouter } from "next/router";
+import { NameSpaceEnum } from "../types/Namespace";
+
+const request = axios.create({
+ withCredentials: true,
+ baseURL: `/api/event`,
+});
+
+const createEvent = (
+ newEvent: EventPayload,
+ router: NextRouter,
+ t?: TFunction
+) => {
+ const { nameSpace = NameSpaceEnum.Main } = newEvent;
+
+ return request
+ .post("/", newEvent)
+ .then((response) => {
+ MessageManager.showMessage(
+ "success",
+ t("events:eventCreateSuccess")
+ );
+
+ router.push(
+ nameSpace === NameSpaceEnum.Main
+ ? "/event"
+ : `/${nameSpace}/event`
+ );
+
+ return response.data
+ })
+ .catch((err) => {
+ MessageManager.showMessage(
+ "error",
+ t("events:createError")
+ );
+ throw err;
+ });
+};
+
+const updateEvent = (eventId: string, updatedEvent: Partial, t?: TFunction) => {
+ if (!HEX24.test(eventId)) {
+ MessageManager.showMessage(
+ "error",
+ t("events:errorInvalidId")
+ );
+ return Promise.reject(new Error("Invalid ID"));
+ }
+
+ return request
+ .patch(`/${eventId}`, updatedEvent)
+ .then((response) => response.data)
+ .catch((err) => {
+ MessageManager.showMessage(
+ "error",
+ t("events:updateError")
+ );
+ throw err;
+ });
+};
+
+const getEvents = (options: ListEventsOptions = {}, t?: TFunction) => {
+ const params = {
+ page: options.page ? options.page - 1 : 0,
+ pageSize: options.pageSize ?? 10,
+ order: options.order ?? "asc",
+ status: options.status,
+ };
+
+ return request
+ .get("/", { params })
+ .then((response) => {
+ return {
+ events: response.data.events,
+ total: response.data.total
+ };
+ })
+ .catch((err) => {
+ MessageManager.showMessage(
+ "error",
+ t("events:fetchError")
+ );
+ throw err;
+ });
+};
+
+const EventApi = {
+ createEvent,
+ updateEvent,
+ getEvents,
+};
+
+export default EventApi;
diff --git a/src/components/AffixButton/AffixButton.tsx b/src/components/AffixButton/AffixButton.tsx
index 514dbb07a..9b2366edc 100644
--- a/src/components/AffixButton/AffixButton.tsx
+++ b/src/components/AffixButton/AffixButton.tsx
@@ -4,7 +4,8 @@ import {
AddOutlined,
PersonAddAlt1Outlined,
Source,
- Report
+ Report,
+ Event
} from "@mui/icons-material";
import { useAtom } from "jotai";
import Cookies from "js-cookie";
@@ -86,6 +87,15 @@ const AffixButton = ({ personalitySlug }: AffixButtonProps) => {
? `/${nameSpace}/verification-request/create`
: `/verification-request/create`,
dataCy: "testFloatButtonAddVerificationRequest",
+ },
+ {
+ icon: ,
+ tooltip: t("affix:affixButtonCreateVerificationRequest"),
+ href:
+ nameSpace !== NameSpaceEnum.Main
+ ? `/${nameSpace}/event/create`
+ : `/event/create`,
+ dataCy: "testFloatButtonAddEvent",
}
);
@@ -203,10 +213,10 @@ const AffixButton = ({ personalitySlug }: AffixButtonProps) => {
>
]}
+ components={[]}
/>
-
+
void;
- disableFutureDates?: boolean;
isLoading: boolean;
disclaimer?: string;
dateExtraText: string;
@@ -38,7 +37,6 @@ interface BaseClaimFormProps {
const BaseClaimForm = ({
content,
handleSubmit,
- disableFutureDates,
isLoading,
disclaimer,
dateExtraText,
@@ -57,10 +55,6 @@ const BaseClaimForm = ({
const router = useRouter();
const [disableSubmit, setDisableSubmit] = useState(true);
- const disabledDate = (current) => {
- return disableFutureDates && current && current > moment().endOf("day");
- };
-
const onChangeCaptcha = (captchaString) => {
setRecaptcha(captchaString);
const hasRecaptcha = !!captchaString;
@@ -127,8 +121,7 @@ const BaseClaimForm = ({
setDate(value);
clearError("date");
}}
- data-cy={"testSelectDate"}
- disabledDate={disabledDate}
+ dataCy="testSelectDate"
/>
{errors?.date && (
diff --git a/src/components/Claim/CreateClaim/ClaimCreate.tsx b/src/components/Claim/CreateClaim/ClaimCreate.tsx
index 05e180b8d..58d350bd5 100644
--- a/src/components/Claim/CreateClaim/ClaimCreate.tsx
+++ b/src/components/Claim/CreateClaim/ClaimCreate.tsx
@@ -11,7 +11,6 @@ const ClaimCreate = () => {
return (
{
);
};
-export default ClaimCreate;
\ No newline at end of file
+export default ClaimCreate;
diff --git a/src/components/Claim/CreateClaim/ClaimUploadImage.tsx b/src/components/Claim/CreateClaim/ClaimUploadImage.tsx
index 39503866e..b4d28d56e 100644
--- a/src/components/Claim/CreateClaim/ClaimUploadImage.tsx
+++ b/src/components/Claim/CreateClaim/ClaimUploadImage.tsx
@@ -104,7 +104,6 @@ const ClaimUploadImage = () => {
{
+ return (
+
+
+
+ {message}
+
+
+
+ );
+};
+
+export default ErrorState;
diff --git a/src/components/Event/EventForm/CreateEventForm.ts b/src/components/Event/EventForm/CreateEventForm.ts
new file mode 100644
index 000000000..34f78b548
--- /dev/null
+++ b/src/components/Event/EventForm/CreateEventForm.ts
@@ -0,0 +1,49 @@
+import { createFormField, FormField } from "../../Form/FormField";
+
+const lifecycleEventForm: FormField[] = [
+ createFormField({
+ fieldName: "badge",
+ type: "text",
+ defaultValue: "",
+ i18nNamespace: "events"
+ }),
+ createFormField({
+ fieldName: "name",
+ type: "text",
+ defaultValue: "",
+ i18nNamespace: "events"
+ }),
+ createFormField({
+ fieldName: "description",
+ type: "textArea",
+ defaultValue: "",
+ i18nNamespace: "events"
+ }),
+ createFormField({
+ fieldName: "location",
+ type: "text",
+ defaultValue: "",
+ i18nNamespace: "events"
+ }),
+ createFormField({
+ fieldName: "startDate",
+ type: "date",
+ defaultValue: "",
+ i18nNamespace: "events"
+ }),
+ createFormField({
+ fieldName: "endDate",
+ type: "date",
+ defaultValue: "",
+ i18nNamespace: "events"
+ }),
+ createFormField({
+ fieldName: "mainTopic",
+ type: "selectImpactArea", //standardize the type and component to a more generic name
+ defaultValue: "",
+ hasTooltip: true,
+ i18nNamespace: "events",
+ }),
+];
+
+export default lifecycleEventForm;
diff --git a/src/components/Event/EventForm/CreateEventView.tsx b/src/components/Event/EventForm/CreateEventView.tsx
new file mode 100644
index 000000000..337135d2a
--- /dev/null
+++ b/src/components/Event/EventForm/CreateEventView.tsx
@@ -0,0 +1,54 @@
+import React, { useState } from "react";
+import { Grid } from "@mui/material";
+import colors from "../../../styles/colors";
+import { useTranslation } from "next-i18next";
+import { useRouter } from "next/router";
+import { useAtom } from "jotai";
+import { currentNameSpace } from "../../../atoms/namespace";
+import EventApi from "../../../api/eventApi";
+import DynamicEventForm from "./DynamicEventForm";
+
+const CreateEventView = () => {
+ const router = useRouter();
+ const { t } = useTranslation();
+ const [nameSpace] = useAtom(currentNameSpace);
+ const [isLoading, setIsLoading] = useState(false);
+ const [recaptchaString, setRecaptchaString] = useState("");
+ const hasCaptcha = !!recaptchaString;
+
+ const onSubmit = (data) => {
+ const newEvent = {
+ nameSpace,
+ badge: data.badge,
+ name: data.name,
+ description: data.description,
+ location: data.location,
+ startDate: data.startDate,
+ endDate: data.endDate,
+ mainTopic: data.mainTopic,
+ recaptcha: recaptchaString,
+ };
+
+ EventApi
+ .createEvent(newEvent, router, t)
+ .then((s) => {
+ router.push(`/event/${s.data_hash}`);
+ setIsLoading(false);
+ });
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default CreateEventView;
diff --git a/src/components/Event/EventForm/DynamicEventForm.tsx b/src/components/Event/EventForm/DynamicEventForm.tsx
new file mode 100644
index 000000000..a5d19864a
--- /dev/null
+++ b/src/components/Event/EventForm/DynamicEventForm.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import { useForm } from "react-hook-form";
+import DynamicForm from "../../Form/DynamicForm";
+import SharedFormFooter from "../../SharedFormFooter";
+import { IDynamicEventForm } from "../../../types/event";
+import lifecycleEventForm from "./CreateEventForm";
+
+const DynamicEventForm = ({
+ data,
+ onSubmit,
+ isLoading,
+ setRecaptchaString,
+ hasCaptcha,
+ isDrawerOpen = false,
+ onClose = () => { },
+}: IDynamicEventForm) => {
+ const {
+ handleSubmit,
+ control,
+ formState: { errors },
+ } = useForm();
+
+ return (
+
+ );
+};
+
+export default DynamicEventForm;
diff --git a/src/components/Event/EventForm/EventDrawer.tsx b/src/components/Event/EventForm/EventDrawer.tsx
new file mode 100644
index 000000000..4080e0739
--- /dev/null
+++ b/src/components/Event/EventForm/EventDrawer.tsx
@@ -0,0 +1,10 @@
+
+
+const EventDrawer = () => {
+
+ return (
+ "EventDrawer"
+ )
+}
+
+export default EventDrawer
diff --git a/src/components/Event/EventList/EventCard.tsx b/src/components/Event/EventList/EventCard.tsx
new file mode 100644
index 000000000..753fb4bc2
--- /dev/null
+++ b/src/components/Event/EventList/EventCard.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { Grid } from "@mui/material";
+import CardBase from "../../CardBase";
+import { i18n } from "next-i18next";
+import { EventPayload } from "../../../types/event";
+import EventCardHeader from "./EventCardHeader";
+import EventCardTitle from "./EventCardTitle";
+import EventCardDateRange from "./EventCardDateRange";
+import EventCardAction from "./EventCardAction";
+
+interface EventCardProps {
+ event: EventPayload;
+ openEventLabel: string;
+}
+
+const EventCard = ({ event, openEventLabel }: EventCardProps) => {
+ const currentLocale = i18n.language || "pt";
+ return (
+
+
+
+
+
+
+ }
+ />
+ );
+};
+
+export default EventCard;
diff --git a/src/components/Event/EventList/EventCardAction.tsx b/src/components/Event/EventList/EventCardAction.tsx
new file mode 100644
index 000000000..52c9c2ddb
--- /dev/null
+++ b/src/components/Event/EventList/EventCardAction.tsx
@@ -0,0 +1,22 @@
+import React from "react";
+import AletheiaButton, { ButtonType } from "../../Button";
+
+interface EventCardActionProps {
+ label: string;
+ href: string;
+}
+
+const EventCardAction = ({ label, href }: EventCardActionProps) => {
+ return (
+
+ {label}
+
+ );
+};
+
+export default EventCardAction;
diff --git a/src/components/Event/EventList/EventCardDateRange.tsx b/src/components/Event/EventList/EventCardDateRange.tsx
new file mode 100644
index 000000000..bb5189c1f
--- /dev/null
+++ b/src/components/Event/EventList/EventCardDateRange.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { Grid, Typography } from "@mui/material";
+import CalendarMonthIcon from "@mui/icons-material/CalendarMonth";
+import colors from "../../../styles/colors";
+
+interface EventCardDateRangeProps {
+ startDate: Date;
+ endDate: Date;
+ locale: string;
+}
+
+const EventCardDateRange = ({ startDate, endDate, locale }: EventCardDateRangeProps) => {
+ return (
+
+
+
+ {`${new Date(startDate).toLocaleDateString(locale)} - ${new Date(endDate).toLocaleDateString(locale)}`}
+
+
+ );
+};
+
+export default EventCardDateRange;
diff --git a/src/components/Event/EventList/EventCardHeader.tsx b/src/components/Event/EventList/EventCardHeader.tsx
new file mode 100644
index 000000000..447715f0c
--- /dev/null
+++ b/src/components/Event/EventList/EventCardHeader.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { Grid, Typography } from "@mui/material";
+import colors from "../../../styles/colors";
+
+interface EventCardHeaderProps {
+ badge: string;
+ location: string;
+}
+
+const EventCardHeader = ({ badge, location }: EventCardHeaderProps) => {
+ return (
+
+
+ {badge}
+
+
+ {location}
+
+
+ );
+};
+
+export default EventCardHeader;
diff --git a/src/components/Event/EventList/EventCardTitle.tsx b/src/components/Event/EventList/EventCardTitle.tsx
new file mode 100644
index 000000000..52c319264
--- /dev/null
+++ b/src/components/Event/EventList/EventCardTitle.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import { Grid, Typography } from "@mui/material";
+import colors from "../../../styles/colors";
+
+interface EventCardTitleProps {
+ title: string;
+}
+
+const EventCardTitle = ({ title }: EventCardTitleProps) => {
+ return (
+
+
+ {title}
+
+
+ );
+};
+
+export default EventCardTitle;
diff --git a/src/components/Event/EventList/EventFilters.tsx b/src/components/Event/EventList/EventFilters.tsx
new file mode 100644
index 000000000..88e5168ab
--- /dev/null
+++ b/src/components/Event/EventList/EventFilters.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { Grid } from "@mui/material";
+import AletheiaButton, { ButtonType } from "../../Button";
+import { EventStatus } from "../../../types/event";
+
+interface EventFiltersProps {
+ selectedStatus: EventStatus;
+ onStatusChange: (status: EventStatus) => void;
+ t: (key: string) => string;
+}
+
+const EventFilters = ({ selectedStatus, onStatusChange, t }: EventFiltersProps) => {
+
+ const filterOptions = [
+ { status: "all" as const, label: t("events:filterAll") },
+ { status: "happening" as const, label: t("events:filterHappening") },
+ { status: "upcoming" as const, label: t("events:filterUpcoming") },
+ { status: "finalized" as const, label: t("events:filterFinished") },
+ ];
+
+ return (
+
+ {filterOptions.map(({ status, label }) => (
+ onStatusChange(status)}
+ rounded={"true"}
+ style={{ fontWeight: 600 }}
+ >
+ {label}
+
+ ))}
+
+ );
+};
+
+export default EventFilters;
diff --git a/src/components/Event/EventList/EventLoadMore.tsx b/src/components/Event/EventList/EventLoadMore.tsx
new file mode 100644
index 000000000..5a4af15a2
--- /dev/null
+++ b/src/components/Event/EventList/EventLoadMore.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { Grid } from "@mui/material";
+import AletheiaButton, { ButtonType } from "../../Button";
+
+interface EventLoadMoreProps {
+ visible: boolean;
+ onLoadMore: () => void;
+ label: string;
+}
+
+const EventLoadMore = ({ visible, onLoadMore, label }: EventLoadMoreProps) => {
+ if (!visible) {
+ return null;
+ }
+
+ return (
+
+
+ {label}
+
+
+ );
+};
+
+export default EventLoadMore;
diff --git a/src/components/Event/EventList/EventTitle.tsx b/src/components/Event/EventList/EventTitle.tsx
new file mode 100644
index 000000000..896ce3a52
--- /dev/null
+++ b/src/components/Event/EventList/EventTitle.tsx
@@ -0,0 +1,24 @@
+import { Grid, Typography } from "@mui/material";
+import colors from "../../../styles/colors";
+
+interface EventTitleProps {
+ total: number;
+ t: (key: string, options?: { total: number }) => string;
+}
+
+const EventTitle = ({ total, t }: EventTitleProps) => (
+
+ {t("events:eventsList")}
+
+ {t("events:totalItems", {
+ total: total,
+ })}
+
+
+)
+
+export default EventTitle
diff --git a/src/components/Event/EventList/EventsList.tsx b/src/components/Event/EventList/EventsList.tsx
new file mode 100644
index 000000000..2a3ec43c7
--- /dev/null
+++ b/src/components/Event/EventList/EventsList.tsx
@@ -0,0 +1,103 @@
+import React, { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Grid } from "@mui/material";
+import GridList from "../../GridList";
+import EventApi from "../../../api/eventApi";
+import Loading from "../../Loading";
+import { EventPayload, ListEventsOptions } from "../../../types/event";
+import EventCard from "./EventCard";
+import ErrorState from "../../ErrorState";
+import EventFilters from "./EventFilters";
+import EventLoadMore from "./EventLoadMore";
+import EventTitle from "./EventTitle";
+
+interface IData {
+ events: EventPayload[],
+ total: number
+}
+
+const EventsList = () => {
+ const { t } = useTranslation();
+ const [data, setData] = useState({ events: [], total: 0 });
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [query, setQuery] = useState({
+ page: 1,
+ pageSize: 10,
+ order: "asc",
+ status: "all",
+ })
+
+ const handleFetch = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const events = await EventApi.getEvents(query);
+
+ if (query.page === 1) {
+ setData(events)
+ } else {
+ setData(prev => ({
+ events: [...prev.events, ...events.events],
+ total: events.total
+ }))
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ handleFetch();
+ }, [query]);
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return
+ }
+
+ return (
+
+
+ setQuery(prev => ({ ...prev, status, page: 1 }))}
+ t={t}
+ />
+
+
+ }
+ dataSource={data.events}
+ loggedInMaxColumns={6}
+ disableSeeMoreButton={true}
+ hasDivider={true}
+ renderItem={(event) =>
+
+ }
+ />
+
+ data.events.length}
+ onLoadMore={() => setQuery(prev => ({ ...prev, page: prev.page + 1 }))}
+ label={t("events:loadMoreButton")}
+ />
+
+
+
+ )
+}
+
+export default EventsList
diff --git a/src/components/Form/DatePickerInput.tsx b/src/components/Form/DatePickerInput.tsx
index 78f37224d..100e0b43a 100644
--- a/src/components/Form/DatePickerInput.tsx
+++ b/src/components/Form/DatePickerInput.tsx
@@ -5,7 +5,7 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import colors from "../../styles/colors";
import styled from "styled-components";
import { useTranslation } from "next-i18next";
-import { useState } from "react";
+import { CSSProperties, useState } from "react";
import dayjs from "dayjs";
const StyledTextField = styled(TextField)`
@@ -39,36 +39,55 @@ const StyledTextField = styled(TextField)`
}
`;
-const DatePickerInput = (props) => {
- const { t } = useTranslation();
- const [value, setValue] = useState(props.defaultValue || null);
- const [open, setOpen] = useState(false);
+interface IDatePickerInput {
+ defaultValue?: any,
+ placeholder: string,
+ onChange: (value: any) => void,
+ disabledFuture?: boolean,
+ dataCy?: string,
+ disabled?: boolean,
+ style?: CSSProperties,
+}
- return (
-
- setOpen(false)}
- onChange={(newValue) => {
- setValue(newValue);
- props.onChange?.(newValue);
- }}
- maxDate={dayjs()}
- renderInput={(params) =>
- setOpen(true)}
- data-cy={props.dataCy}
- {...props}
- />}
- PopperProps={{ placement: 'bottom-start', }}
- desktopModeMediaQuery="@media (min-width: 0)"
- />
-
- );
+const DatePickerInput = ({
+ defaultValue,
+ placeholder,
+ onChange,
+ disabledFuture = true,
+ dataCy,
+ disabled,
+ style
+}: IDatePickerInput) => {
+ const { t } = useTranslation();
+ const [value, setValue] = useState(defaultValue || null);
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ setOpen(false)}
+ onChange={(newValue) => {
+ setValue(newValue);
+ onChange?.(newValue);
+ }}
+ disableFuture={disabledFuture}
+ disabled={disabled}
+ renderInput={(params) =>
+ setOpen(true)}
+ data-cy={dataCy}
+ style={{ ...style }}
+ />}
+ PopperProps={{ placement: 'bottom-start', }}
+ desktopModeMediaQuery="@media (min-width: 0)"
+ />
+
+ );
};
export default DatePickerInput;
diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx
index 9a84795b3..97cab4e0e 100644
--- a/src/components/Form/DynamicForm.tsx
+++ b/src/components/Form/DynamicForm.tsx
@@ -4,13 +4,15 @@ import DynamicInput from "./DynamicInput";
import React from "react";
import colors from "../../styles/colors";
import { useTranslation } from "next-i18next";
+import InfoTooltip from "../Claim/InfoTooltip";
+import { InfoOutlined } from "@mui/icons-material";
const DynamicForm = ({
currentForm,
control,
errors,
machineValues = {},
- disabledDate = {},
+ disabledFuture = true,
}) => {
const { t } = useTranslation();
return (
@@ -43,6 +45,15 @@ const DynamicForm = ({
*
}
{t(label)}
+ {fieldItem.hasTooltip ?
+
+ }
+ useCustomStyle={false}
+ /> : null
+ }
)}
diff --git a/src/components/Form/DynamicInput.tsx b/src/components/Form/DynamicInput.tsx
index 9596e57b4..d6b049c94 100644
--- a/src/components/Form/DynamicInput.tsx
+++ b/src/components/Form/DynamicInput.tsx
@@ -32,7 +32,7 @@ interface DynamicInputProps {
defaultValue: UnifiedDefaultValue;
"data-cy": string;
extraProps: any;
- disabledDate?: any;
+ disabledFuture: boolean;
disabled?: boolean;
}
@@ -171,8 +171,8 @@ const DynamicInput = (props: DynamicInputProps) => {
defaultValue={props.defaultValue}
placeholder={t(props.placeholder)}
onChange={(value) => props.onChange(value)}
- data-cy="testSelectDate"
- disabledDate={props.disabledDate}
+ dataCy="testSelectDate"
+ disabledFuture={props.disabledFuture}
disabled={props.disabled}
style={{ backgroundColor: props.disabled ? colors.lightNeutral : colors.white }}
/>
diff --git a/src/components/Form/FormField.ts b/src/components/Form/FormField.ts
index ac53c8d44..65f63b24d 100644
--- a/src/components/Form/FormField.ts
+++ b/src/components/Form/FormField.ts
@@ -17,6 +17,7 @@ export type FormField = {
defaultValue: string | [];
extraProps?: FormFieldExtraProps;
disabled?: boolean;
+ hasTooltip?: boolean;
};
// Use to add properties specific to one type of field
@@ -40,6 +41,7 @@ interface CreateFormFieldProps extends Partial {
required?: boolean;
isURLField?: boolean;
disabled?: boolean;
+ hasTooltip?: boolean;
}
const createFormField = (props: CreateFormFieldProps): FormField => {
@@ -53,6 +55,7 @@ const createFormField = (props: CreateFormFieldProps): FormField => {
required = true,
isURLField = false,
disabled = false,
+ hasTooltip = false,
} = props;
return {
@@ -62,6 +65,7 @@ const createFormField = (props: CreateFormFieldProps): FormField => {
placeholder: `${i18nNamespace}:${i18nKey}Placeholder`,
defaultValue,
disabled,
+ hasTooltip,
...props,
rules: {
required: !disabled && required && "common:requiredFieldError",
@@ -111,7 +115,7 @@ const fieldValidation = (value, validationFunction) => {
}
if (dayjs.isDayjs(value)) {
- return dayjs(value).isValid() && dayjs(value).isBefore(dayjs());
+ return dayjs(value).isValid()
}
if (Array.isArray(value) && value.length > 0 && (value[0].uid || value[0].originFileObj)) {
@@ -126,9 +130,14 @@ const fieldValidation = (value, validationFunction) => {
if (typeof value === "string") {
return validationFunction(value);
}
- if (typeof value === "object") {
+
+ if (typeof value === "object" && value !== null) {
+ if ("value" in value) {
+ return validationFunction(value.value);
+ }
return value.every((v) => validationFunction(v));
}
+
return false;
};
diff --git a/src/components/GridList.tsx b/src/components/GridList.tsx
index 9ff5e1c16..f20a78a55 100644
--- a/src/components/GridList.tsx
+++ b/src/components/GridList.tsx
@@ -13,10 +13,14 @@ const GridList = ({
dataCy = "",
seeMoreButtonLabel = "",
disableSeeMoreButton = false,
+ hasDivider = false,
}) => {
return (
<>
- {title}
+
{dataSource.map((item) => (
@@ -41,7 +45,7 @@ const GridList = ({
}}
>
)}
diff --git a/src/components/Personality/MorePersonalities.tsx b/src/components/Personality/MorePersonalities.tsx
index a0185efcb..fe6f94484 100644
--- a/src/components/Personality/MorePersonalities.tsx
+++ b/src/components/Personality/MorePersonalities.tsx
@@ -35,9 +35,9 @@ const MorePersonalities = ({ personalities, href, title }) => {
>
{!vw?.md && (
-
- {t("home:sectionTitle2")}
-
+
)}
diff --git a/src/components/SectionTitle.tsx b/src/components/SectionTitle.tsx
index aff8e1cb0..0dee33695 100644
--- a/src/components/SectionTitle.tsx
+++ b/src/components/SectionTitle.tsx
@@ -1,22 +1,35 @@
import React from "react";
import colors from "../styles/colors";
import Typography from "@mui/material/Typography"
+import { Divider, Grid } from "@mui/material";
-const SectionTitle = (props) => {
+interface SectionTitleProps {
+ children: string | React.ReactNode,
+ hasDivider?: boolean
+}
+
+const SectionTitle = ({ children, hasDivider }: SectionTitleProps) => {
return (
-
- {props.children}
-
+
+
+ {children}
+
+ {
+ hasDivider ?
+
+ : null
+ }
+
);
};
diff --git a/src/components/VerificationRequest/verificationRequestForms/DynamicVerificationRequestForm.tsx b/src/components/VerificationRequest/verificationRequestForms/DynamicVerificationRequestForm.tsx
index 0d53c759e..402ae34d6 100644
--- a/src/components/VerificationRequest/verificationRequestForms/DynamicVerificationRequestForm.tsx
+++ b/src/components/VerificationRequest/verificationRequestForms/DynamicVerificationRequestForm.tsx
@@ -1,7 +1,6 @@
import React from "react";
import { useForm } from "react-hook-form";
import createVerificationRequestForm from "./fieldLists/CreateVerificationRequestForm";
-import moment from "moment";
import DynamicForm from "../../Form/DynamicForm";
import SharedFormFooter from "../../SharedFormFooter";
import editVerificationRequestForm from "./fieldLists/EditVerificationRequestForm";
@@ -22,8 +21,6 @@ const DynamicVerificationRequestForm = ({
control,
formState: { errors },
} = useForm();
- const disabledDate = (current) =>
- current && current > moment().endOf("day");
return (
diff --git a/src/components/badges/DynamicBadgesForm.tsx b/src/components/badges/DynamicBadgesForm.tsx
index 1a22d08c5..ed97f2825 100644
--- a/src/components/badges/DynamicBadgesForm.tsx
+++ b/src/components/badges/DynamicBadgesForm.tsx
@@ -1,4 +1,3 @@
-import moment from "moment";
import { useForm } from "react-hook-form";
import DynamicForm from "../Form/DynamicForm";
import SharedFormFooter from "../SharedFormFooter";
@@ -19,8 +18,6 @@ const DynamicBadgesForm = ({
control,
formState: { errors },
} = useForm();
- const disabledDate = (current) =>
- current && current > moment().endOf("day");
const [recaptchaString, setRecaptchaString] = useState("");
const hasCaptcha = !!recaptchaString;
@@ -31,7 +28,6 @@ const DynamicBadgesForm = ({
currentForm={lifecycleBadgesForm}
control={control}
errors={errors}
- disabledDate={disabledDate}
machineValues={badges}
/>
diff --git a/src/components/namespace/DynamicNameSpaceForm.tsx b/src/components/namespace/DynamicNameSpaceForm.tsx
index c576cf3da..eac2d7c5f 100644
--- a/src/components/namespace/DynamicNameSpaceForm.tsx
+++ b/src/components/namespace/DynamicNameSpaceForm.tsx
@@ -1,4 +1,3 @@
-import moment from "moment";
import { useForm } from "react-hook-form";
import { IDynamicNameSpaceForm } from "../../types/Namespace";
import lifecycleNameSpaceForm from "./NameSpaceForm";
@@ -23,8 +22,6 @@ const DynamicNameSpaceForm = ({
control,
formState: { errors },
} = useForm();
- const disabledDate = (current) =>
- current && current > moment().endOf("day");
const [recaptchaString, setRecaptchaString] = useState("");
const hasCaptcha = !!recaptchaString;
@@ -50,7 +47,6 @@ const DynamicNameSpaceForm = ({
currentForm={lifecycleNameSpaceForm}
control={control}
errors={errors}
- disabledDate={disabledDate}
machineValues={nameSpace}
/>
diff --git a/src/pages/event-create.tsx b/src/pages/event-create.tsx
new file mode 100644
index 000000000..aaee12224
--- /dev/null
+++ b/src/pages/event-create.tsx
@@ -0,0 +1,47 @@
+import { useSetAtom } from "jotai";
+import { NextPage } from "next";
+import { useTranslation } from "next-i18next";
+import { serverSideTranslations } from "next-i18next/serverSideTranslations";
+import { useDispatch } from "react-redux";
+import Seo from "../components/Seo";
+import actions from "../store/actions";
+import { GetLocale } from "../utils/GetLocale";
+import { NameSpaceEnum } from "../types/Namespace";
+import { currentNameSpace } from "../atoms/namespace";
+import CreateEventView from "../components/Event/EventForm/CreateEventView";
+
+const CreateEventPage: NextPage<{
+ nameSpace: NameSpaceEnum;
+ sitekey: string;
+}> = ({ nameSpace, sitekey }) => {
+ const { t } = useTranslation();
+ const setCurrentNameSpace = useSetAtom(currentNameSpace);
+ setCurrentNameSpace(nameSpace);
+
+ const dispatch = useDispatch();
+ dispatch(actions.setSitekey(sitekey));
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export async function getServerSideProps({ query, locale, locales, req }) {
+ locale = GetLocale(req, locale, locales);
+ query = JSON.parse(query.props);
+
+ return {
+ props: {
+ ...(await serverSideTranslations(locale)),
+ sitekey: query.sitekey,
+ nameSpace: query.nameSpace ? query.nameSpace : NameSpaceEnum.Main,
+ },
+ };
+}
+export default CreateEventPage;
diff --git a/src/pages/event-page.tsx b/src/pages/event-page.tsx
new file mode 100644
index 000000000..69c22b6fc
--- /dev/null
+++ b/src/pages/event-page.tsx
@@ -0,0 +1,44 @@
+import { NextPage } from "next";
+import { serverSideTranslations } from "next-i18next/serverSideTranslations";
+import { NameSpaceEnum } from "../types/Namespace";
+import { useSetAtom } from "jotai";
+import { currentNameSpace } from "../atoms/namespace";
+import { GetLocale } from "../utils/GetLocale";
+import { useDispatch } from "react-redux";
+import actions from "../store/actions";
+import EventsList from "../components/Event/EventList/EventsList";
+import AffixButton from "../components/AffixButton/AffixButton";
+
+const EventPage: NextPage<{
+ nameSpace: NameSpaceEnum;
+ sitekey: string;
+}> = ({ nameSpace, sitekey }) => {
+ const setCurrentNameSpace = useSetAtom(currentNameSpace);
+ setCurrentNameSpace(nameSpace);
+
+ const dispatch = useDispatch();
+ dispatch(actions.setSitekey(sitekey));
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export async function getServerSideProps({ query, locale, locales, req }) {
+ locale = GetLocale(req, locale, locales);
+ query = JSON.parse(query.props);
+
+ return {
+ props: {
+ ...(await serverSideTranslations(locale)),
+ nameSpace: query.nameSpace ? query.nameSpace : NameSpaceEnum.Main,
+ sitekey: query.sitekey,
+ href: req.protocol + "://" + req.get("host") + req.originalUrl,
+ },
+ };
+}
+
+export default EventPage;
diff --git a/src/pages/event-view-page.tsx b/src/pages/event-view-page.tsx
new file mode 100644
index 000000000..30ecd6a1b
--- /dev/null
+++ b/src/pages/event-view-page.tsx
@@ -0,0 +1,50 @@
+import { NextPage } from "next";
+import { serverSideTranslations } from "next-i18next/serverSideTranslations";
+import { FullEventResponse } from "../types/event";
+import { NameSpaceEnum } from "../types/Namespace";
+import { useSetAtom } from "jotai";
+import { currentNameSpace } from "../atoms/namespace";
+import { GetLocale } from "../utils/GetLocale";
+import { useDispatch } from "react-redux";
+import actions from "../store/actions";
+
+interface EventPageProps {
+ fullEvent: FullEventResponse;
+ nameSpace: NameSpaceEnum;
+ sitekey: string;
+}
+
+const EventViewPage: NextPage = ({
+ fullEvent,
+ nameSpace,
+ sitekey
+}) => {
+ const setCurrentNameSpace = useSetAtom(currentNameSpace);
+ setCurrentNameSpace(nameSpace);
+
+ const dispatch = useDispatch();
+ dispatch(actions.setSitekey(sitekey));
+
+ return (
+
+ pagina do evento
+
+ );
+};
+
+export async function getServerSideProps({ query, locale, locales, req }) {
+ locale = GetLocale(req, locale, locales);
+ query = JSON.parse(query.props);
+
+ return {
+ props: {
+ ...(await serverSideTranslations(locale)),
+ fullEvent: query.fullEvent || null,
+ nameSpace: query.nameSpace ? query.nameSpace : NameSpaceEnum.Main,
+ sitekey: query.sitekey,
+ href: req.protocol + "://" + req.get("host") + req.originalUrl,
+ },
+ };
+}
+
+export default EventViewPage;
diff --git a/src/types/VerificationRequest.ts b/src/types/VerificationRequest.ts
index 78baf4cf5..312517622 100644
--- a/src/types/VerificationRequest.ts
+++ b/src/types/VerificationRequest.ts
@@ -144,8 +144,8 @@ interface IDynamicVerificationRequestForm {
setRecaptchaString: React.Dispatch>;
hasCaptcha: boolean;
isEdit: boolean;
- isDrawerOpen: boolean;
- onClose: () => void;
+ isDrawerOpen?: boolean;
+ onClose?: () => void;
}
export type {
diff --git a/src/types/event.ts b/src/types/event.ts
new file mode 100644
index 000000000..6249a2b37
--- /dev/null
+++ b/src/types/event.ts
@@ -0,0 +1,39 @@
+import { NameSpaceEnum } from "./Namespace";
+import { Topic } from "./Topic";
+
+export type EventOrder = "asc" | "desc";
+export type EventStatus = "happening" | "upcoming" | "finalized" | "all";
+export interface EventPayload {
+ nameSpace: NameSpaceEnum,
+ badge: string;
+ data_hash?: string;
+ name: string;
+ description: string;
+ location: string;
+ startDate: Date;
+ endDate: Date;
+ mainTopic: Topic;
+ filterTopics?: Topic[];
+ recaptcha?: string
+}
+
+export type FullEventResponse = Event & {
+ sentences: any[]; //to do: Improve Types
+ verificationRequests: any[]; //to do: Improve Types
+};
+export interface ListEventsOptions {
+ page?: number;
+ pageSize?: number;
+ order?: EventOrder;
+ status?: EventStatus;
+}
+
+export interface IDynamicEventForm {
+ data?: EventPayload;
+ onSubmit: (value: EventPayload) => void;
+ isLoading: boolean;
+ setRecaptchaString: React.Dispatch>;
+ hasCaptcha: boolean;
+ isDrawerOpen?: boolean;
+ onClose?: () => void;
+}