Skip to content

Commit c69e933

Browse files
committed
feat(events): implement backend logic and core infrastructure for event views
1 parent a1ff9fb commit c69e933

19 files changed

+1553
-7
lines changed

server/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { M2MGuard } from "./auth/m2m.guard";
6565
import { CallbackDispatcherModule } from "./callback-dispatcher/callback-dispatcher.module";
6666
import { AiTaskModule } from "./ai-task/ai-task.module";
6767
import { TrackingModule } from "./tracking/tracking.module";
68+
import { EventsModule } from "./events/event.module";
6869

6970
@Module({})
7071
export class AppModule implements NestModule {
@@ -122,6 +123,7 @@ export class AppModule implements NestModule {
122123
ClaimRevisionModule,
123124
HistoryModule,
124125
TrackingModule,
126+
EventsModule,
125127
StateEventModule,
126128
SourceModule,
127129
SpeechModule,

server/claim/types/sentence/sentence.service.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import {
22
BadRequestException,
33
Injectable,
4+
InternalServerErrorException,
5+
Logger,
46
NotFoundException,
57
} from "@nestjs/common";
68
import { Model } from "mongoose";
79
import { SentenceDocument, Sentence } from "./schemas/sentence.schema";
810
import { InjectModel } from "@nestjs/mongoose";
911
import { ReportService } from "../../../report/report.service";
1012
import { UtilService } from "../../../util";
11-
import { allCop30WikiDataIds } from "../../../../src/constants/cop30Filters";
1213
import type { Cop30Sentence } from "../../../../src/types/Cop30Sentence";
14+
import { TopicRelatedSentencesResponse } from "./types/sentence.interfaces";
15+
import { allCop30WikiDataIds } from "../../../../src/constants/cop30Filters";
1316
import type { Cop30Stats } from "../../../../src/types/Cop30Stats";
1417
import { buildStats } from "../../../../src/components/Home/COP30/utils/classification";
1518

@@ -24,13 +27,73 @@ interface FindAllOptionsFilters {
2427

2528
@Injectable()
2629
export class SentenceService {
30+
private readonly logger = new Logger(SentenceService.name);
31+
2732
constructor(
2833
@InjectModel(Sentence.name)
2934
private SentenceModel: Model<SentenceDocument>,
3035
private reportService: ReportService,
3136
private util: UtilService
3237
) {}
3338

39+
/**
40+
* Fetches sentences based on topic values and enriches them with review classification.
41+
* Uses an aggregation pipeline for cross-collection lookup.
42+
* @param query - Topic wikidataId array to filter sentences.
43+
* @returns A list of sentences with their respective classification.
44+
*/
45+
async getSentencesByTopics(query: string[]): Promise<TopicRelatedSentencesResponse[]> {
46+
try {
47+
this.logger.debug(`Fetching sentences for topics: ${query.join(', ')}`);
48+
49+
const aggregation: any[] = [
50+
{ $match: { "topics.value": { $in: query } } },
51+
{
52+
$lookup: {
53+
from: "reviewtasks",
54+
let: { sentenceDataHash: "$data_hash" },
55+
pipeline: [
56+
{
57+
$match: {
58+
$expr: {
59+
$eq: [
60+
"$machine.context.reviewData.data_hash",
61+
"$$sentenceDataHash",
62+
],
63+
},
64+
},
65+
},
66+
{
67+
$project: {
68+
_id: 0,
69+
classification: "$machine.context.reviewData.classification",
70+
},
71+
},
72+
],
73+
as: "reviewInfo",
74+
},
75+
},
76+
{
77+
$addFields: {
78+
classification: {
79+
$arrayElemAt: ["$reviewInfo.classification", 0],
80+
},
81+
},
82+
},
83+
{ $project: { reviewInfo: 0 } },
84+
];
85+
86+
const result = await this.SentenceModel.aggregate(aggregation).exec();
87+
88+
this.logger.log(`Found ${result.length} sentences for the requested topics.`);
89+
90+
return result;
91+
} catch (error) {
92+
this.logger.error(`Error in getSentencesByTopics: ${error.message}`, error.stack);
93+
throw new InternalServerErrorException("Failed to aggregate sentences with review data.");
94+
}
95+
}
96+
3497
async getSentencesWithCop30Topics(): Promise<Cop30Sentence[]> {
3598
const aggregation = [
3699
{ $match: { "topics.value": { $in: allCop30WikiDataIds } } },
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Sentence } from "../schemas/sentence.schema";
2+
3+
export interface Review {
4+
personality: string;
5+
usersId: string;
6+
isPartialReview: boolean;
7+
}
8+
9+
export type TopicRelatedSentencesResponse = Sentence & {
10+
classification?: string;
11+
review?: Review;
12+
}

server/events/dto/event.dto.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {
2+
IsArray,
3+
IsDate,
4+
IsNotEmpty,
5+
IsObject,
6+
IsOptional,
7+
IsString
8+
} from "class-validator";
9+
import { ApiProperty, PartialType } from "@nestjs/swagger";
10+
import { Type } from "class-transformer";
11+
import type { TopicData } from "../../topic/types/topic.interfaces";
12+
13+
export class CreateEventDTO {
14+
@IsString()
15+
@IsNotEmpty()
16+
@ApiProperty()
17+
badge: string;
18+
19+
@IsString()
20+
@IsNotEmpty()
21+
@ApiProperty()
22+
name: string;
23+
24+
@IsString()
25+
@IsNotEmpty()
26+
@ApiProperty()
27+
description: string;
28+
29+
@IsString()
30+
@IsNotEmpty()
31+
@ApiProperty()
32+
location: string;
33+
34+
@IsDate()
35+
@IsNotEmpty()
36+
@Type(() => Date)
37+
@ApiProperty()
38+
startDate: Date;
39+
40+
@IsDate()
41+
@IsNotEmpty()
42+
@Type(() => Date)
43+
@ApiProperty()
44+
endDate: Date;
45+
46+
@IsObject()
47+
@IsNotEmpty()
48+
@ApiProperty()
49+
mainTopic: TopicData;
50+
51+
@IsArray()
52+
@IsOptional()
53+
@ApiProperty()
54+
filterTopics: TopicData[];
55+
}
56+
57+
export class UpdateEventDTO extends PartialType(CreateEventDTO) { }

server/events/dto/filter.dto.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {
2+
IsOptional,
3+
IsInt,
4+
Min,
5+
IsString
6+
} from "class-validator";
7+
import { Type } from "class-transformer";
8+
9+
export class FilterEventsDTO {
10+
@IsOptional()
11+
@Type(() => Number)
12+
@IsInt()
13+
@Min(0)
14+
page?: number = 0;
15+
16+
@IsOptional()
17+
@Type(() => Number)
18+
@IsInt()
19+
@Min(1)
20+
pageSize?: number = 10;
21+
22+
@IsOptional()
23+
@IsString()
24+
order?: "asc" | "desc";
25+
26+
@IsOptional()
27+
@IsString()
28+
status?: string;
29+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { Test, TestingModule } from "@nestjs/testing";
2+
import { NotFoundException } from "@nestjs/common";
3+
import { ConfigService } from "@nestjs/config";
4+
import { EventsController } from "./event.controller";
5+
import { EventsService } from "./event.service";
6+
import { ViewService } from "../view/view.service";
7+
import { AbilitiesGuard } from "../auth/ability/abilities.guard";
8+
import {
9+
mockConfigService,
10+
mockEventsService,
11+
mockViewService,
12+
} from "../mocks/EventMock";
13+
14+
describe("EventsController (Unit)", () => {
15+
let controller: EventsController;
16+
17+
beforeEach(async () => {
18+
const testingModule: TestingModule = await Test.createTestingModule({
19+
controllers: [EventsController],
20+
providers: [
21+
{ provide: ConfigService, useValue: mockConfigService },
22+
{ provide: EventsService, useValue: mockEventsService },
23+
{ provide: ViewService, useValue: mockViewService },
24+
],
25+
})
26+
.overrideGuard(AbilitiesGuard)
27+
.useValue({})
28+
.compile();
29+
30+
controller = testingModule.get<EventsController>(EventsController);
31+
});
32+
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
describe("create", () => {
38+
it("should delegate creation to eventsService", async () => {
39+
const dto = { name: "Event" };
40+
const created = { _id: "e1", ...dto };
41+
mockEventsService.create.mockResolvedValue(created);
42+
43+
const result = await controller.create(dto as any);
44+
45+
expect(mockEventsService.create).toHaveBeenCalledWith(dto);
46+
expect(result).toEqual(created);
47+
});
48+
});
49+
50+
describe("update", () => {
51+
it("should delegate update to eventsService", async () => {
52+
const id = "507f1f77bcf86cd799439011";
53+
const dto = { name: "Updated" };
54+
const updated = { _id: id, ...dto };
55+
mockEventsService.update.mockResolvedValue(updated);
56+
57+
const result = await controller.update(id, dto as any);
58+
59+
expect(mockEventsService.update).toHaveBeenCalledWith(id, dto);
60+
expect(result).toEqual(updated);
61+
});
62+
});
63+
64+
describe("findAll", () => {
65+
it("should delegate filtering to eventsService", async () => {
66+
const query = { page: 0, pageSize: 10, order: "asc", status: "upcoming" };
67+
const events = [{ _id: "e1" }];
68+
mockEventsService.findAll.mockResolvedValue(events);
69+
70+
const result = await controller.findAll(query as any);
71+
72+
expect(mockEventsService.findAll).toHaveBeenCalledWith(query);
73+
expect(result).toEqual(events);
74+
});
75+
});
76+
77+
describe("eventPage", () => {
78+
it("should render /event-page with parsed query, namespace and site key", async () => {
79+
const req = {
80+
url: "/event?foo=bar",
81+
params: { namespace: "main" },
82+
};
83+
const res = {};
84+
85+
await controller.eventPage(req as any, res as any);
86+
87+
expect(mockConfigService.get).toHaveBeenCalledWith("recaptcha_sitekey");
88+
expect(mockViewService.render).toHaveBeenCalledWith(
89+
req,
90+
res,
91+
"/event-page",
92+
expect.objectContaining({
93+
foo: "bar",
94+
nameSpace: "main",
95+
sitekey: "test-site-key",
96+
})
97+
);
98+
});
99+
});
100+
101+
describe("eventViewPage", () => {
102+
it("should render /event-view-page when event exists", async () => {
103+
const req = {
104+
url: "/event/hash-1?lang=en",
105+
params: { namespace: "main", data_hash: "hash-1" },
106+
};
107+
const res = {};
108+
const fullEvent = { _id: "e1", data_hash: "hash-1" };
109+
110+
mockEventsService.getFullEventByHash.mockResolvedValue(fullEvent);
111+
112+
await controller.eventViewPage(req as any, res as any);
113+
114+
expect(mockEventsService.getFullEventByHash).toHaveBeenCalledWith("hash-1");
115+
expect(mockConfigService.get).toHaveBeenCalledWith("recaptcha_sitekey");
116+
expect(mockViewService.render).toHaveBeenCalledWith(
117+
req,
118+
res,
119+
"/event-view-page",
120+
expect.objectContaining({
121+
lang: "en",
122+
fullEvent,
123+
namespace: "main",
124+
sitekey: "test-site-key",
125+
})
126+
);
127+
});
128+
129+
it("should throw NotFoundException when service returns empty event", async () => {
130+
const req = {
131+
url: "/event/hash-1",
132+
params: { namespace: "main", data_hash: "hash-1" },
133+
};
134+
const res = {};
135+
136+
mockEventsService.getFullEventByHash.mockResolvedValue(null);
137+
138+
await expect(controller.eventViewPage(req as any, res as any)).rejects.toThrow(
139+
NotFoundException
140+
);
141+
expect(mockViewService.render).not.toHaveBeenCalled();
142+
});
143+
144+
it("should rethrow service errors and log only non-NotFound errors", async () => {
145+
const req = {
146+
url: "/event/hash-1",
147+
params: { namespace: "main", data_hash: "hash-1" },
148+
};
149+
const res = {};
150+
const error = new Error("unexpected");
151+
const loggerErrorSpy = jest.spyOn((controller as any).logger, "error");
152+
153+
mockEventsService.getFullEventByHash.mockRejectedValue(error);
154+
155+
await expect(controller.eventViewPage(req as any, res as any)).rejects.toThrow(
156+
"unexpected"
157+
);
158+
expect(loggerErrorSpy).toHaveBeenCalled();
159+
});
160+
});
161+
});

0 commit comments

Comments
 (0)