Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion server/src/modules/agenda/agenda.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { IsNull, Repository } from "typeorm";
import {
InternalServerErrorException,
NotFoundException,
Expand Down Expand Up @@ -219,6 +219,29 @@ describe("AgendaService", () => {
expect(result).toEqual([mockEvent]);
});

it("matches end-less point events, scoped to the caller", async () => {
const from = new Date("2025-01-01T00:00:00.000Z");
const to = new Date("2025-01-08T00:00:00.000Z");
(mockRepo.find as jest.Mock).mockResolvedValue([]);

await service.listForRange("user-1", from, to);

// `end_at` is nullable and `NULL >= from` is never true in SQL, so an
// event with no end time needs its own branch or it silently vanishes
// from every range. It is a point at `start_at`, so that branch matches
// on the start falling inside the window.
const where = (mockRepo.find as jest.Mock).mock.calls[0][0].where;
expect(Array.isArray(where)).toBe(true);
expect(where).toHaveLength(2);
expect(where[1]).toMatchObject({
user_id: "user-1",
end_at: IsNull(),
});
expect(
where.every((w: { user_id: string }) => w.user_id === "user-1"),
).toBe(true);
});

it("should log and throw on error", async () => {
const from = new Date("2025-01-01T00:00:00.000Z");
const to = new Date("2025-01-08T00:00:00.000Z");
Expand Down
34 changes: 28 additions & 6 deletions server/src/modules/agenda/agenda.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import {
BadRequestException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository, LessThanOrEqual, MoreThanOrEqual } from "typeorm";
import {
Repository,
LessThanOrEqual,
MoreThanOrEqual,
Between,
IsNull,
} from "typeorm";

import { CreateEventDto } from "./dto/createEvent.dto";
import { UpdateEventDto } from "./dto/updateEvent.dto";
Expand Down Expand Up @@ -109,11 +115,27 @@ export class AgendaService {
async listForRange(user_id: string, from: Date, to: Date) {
try {
return await this.repo.find({
where: {
user_id,
start_at: LessThanOrEqual(to),
end_at: MoreThanOrEqual(from),
},
// A timed event overlaps the window when it starts before the window
// ends and has not already finished.
//
// `end_at` is nullable, and every SQL comparison against NULL is NULL
// (never true), so such rows can't satisfy the timed branch and need
// their own β€” without it they drop out of every range silently. An
// event with no end is a point in time at `start_at` (the calendar
// renders it that way, see web useCalendarStore `end: … : start_at`),
// so it belongs to the window when it *starts* inside it.
where: [
{
user_id,
start_at: LessThanOrEqual(to),
end_at: MoreThanOrEqual(from),
},
{
user_id,
start_at: Between(from, to),
end_at: IsNull(),
},
],
order: { start_at: "ASC" },
});
} catch (error) {
Expand Down
18 changes: 15 additions & 3 deletions server/src/modules/ai/ai.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,29 @@ describe("AiController", () => {
expect(res).toEqual(true);
});

it("chat should call service and return the reply", async () => {
it("chat should call service with the caller's id and return the reply", async () => {
const dto = { message: "How do I answer behavioral questions?" } as any;
const serviceResult = { reply: "Use the STAR method." };
(mockAiService.chat as jest.Mock).mockResolvedValueOnce(serviceResult);

const res = await controller.chat(dto);
const res = await controller.chat(dto, "user-1");

expect(mockAiService.chat).toHaveBeenCalledWith(dto);
expect(mockAiService.chat).toHaveBeenCalledWith(dto, "user-1");
expect(res).toEqual(serviceResult);
});

it("guards the chat route with the ThrottlerGuard so @Throttle fires", () => {
// @Throttle is a no-op without an explicit route ThrottlerGuard.
const guards = Reflect.getMetadata(
"__guards__",
AiController.prototype.chat,
) as unknown[] | undefined;
const names = (guards ?? []).map((g: any) =>
typeof g === "function" ? g.name : g?.constructor?.name,
);
expect(names).toContain("ThrottlerGuard");
});

it("addTranscripts should call service and return result", async () => {
const id = "i1";
const dto = {
Expand Down
12 changes: 10 additions & 2 deletions server/src/modules/ai/ai.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "@nestjs/swagger";

import { UsePipes } from "@nestjs/common/decorators/core/use-pipes.decorator";
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";

import { PostValidationPipe } from "@common/pipes/PostValidationPipe";
import { AccessTokenGuard } from "@common/guards/accessToken.guard";
Expand Down Expand Up @@ -68,9 +69,16 @@ export class AiController {
description: "The assistant is unavailable.",
})
@UsePipes(new PostValidationPipe())
// Class-level AccessTokenGuard runs first (sets req.userId); ThrottlerGuard
// then enforces the per-user budget on this LLM-cost endpoint.
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 20, ttl: 60000 } })
@Post("chat")
async chat(@Body() chatDto: ChatDto): Promise<ChatResponseDto> {
return this.aiService.chat(chatDto);
async chat(
@Body() chatDto: ChatDto,
@UserId() userId: string,
): Promise<ChatResponseDto> {
return this.aiService.chat(chatDto, userId);
}

@ApiCreatedResponse({
Expand Down
4 changes: 4 additions & 0 deletions server/src/modules/ai/ai.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ import { AiController } from "./ai.controller";
import { AiService } from "./ai.service";
import { SimulationModule } from "../simulation/simulation.module";
import { ApplicationsModule } from "../applications/applications.module";
import { AgendaModule } from "../agenda/agenda.module";
import { NotesModule } from "../notes/notes.module";

@Module({
imports: [
SimulationModule,
ApplicationsModule,
AgendaModule,
NotesModule,
TypeOrmModule.forFeature([ai_interview, ai_transcript, user]),
HttpModule.register({
timeout: 5000,
Expand Down
Loading
Loading