Skip to content
Open
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
10 changes: 10 additions & 0 deletions backend/src/api/_mobile/event/dto/get-my-events-counts.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { PickType } from '@nestjs/swagger';
import { GetMyEventsDto } from './get-my-events.dto';

// The segment badges take the list's search and filters, with no segment and no paging
export class GetMyEventsCountsDto extends PickType(GetMyEventsDto, [
'search',
'organizationId',
'startFrom',
'startTo',
] as const) {}
21 changes: 20 additions & 1 deletion backend/src/api/_mobile/event/event.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Controller } from '@nestjs/common';
import { ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { ApiBearerAuth, ApiBody, ApiOkResponse } from '@nestjs/swagger';
import { UuidValidationPipe } from 'src/infrastructure/pipes/uuid.pipe';
import { CreateEventRSVPUseCase } from 'src/usecases/event/RSVP/create-rsvp.usecase';
import { DeleteEventRSVPUseCase } from 'src/usecases/event/RSVP/delete-rsvp.usecase';
Expand All @@ -28,6 +28,9 @@ import { MobileEventListItemPresenter } from './presenters/mobile-event-list-ite
import { GetOneEventWithVolunteerStatusUsecase } from 'src/usecases/event/get-one-event-with-volunteer-status.usecase';
import { MobileEventPresenter } from './presenters/mobile-event.presenter';
import { GetEventsByOrganizationUsecase } from 'src/usecases/event/get-events-by-organization.usecase';
import { GetMyEventsCountsDto } from './dto/get-my-events-counts.dto';
import { GetMyEventsCountsUsecase } from 'src/usecases/event/get-my-events-counts.usecase';
import { MobileEventsCountsPresenter } from './presenters/mobile-events-counts.presenter';
import { MobileEventOptionPresenter } from './presenters/mobile-event-option.presenter';

@ApiBearerAuth()
Expand All @@ -40,6 +43,7 @@ export class MobileEventController {
private readonly getMyEventsUsecase: GetMyEventsUsecase,
private readonly getOneEventWithVolunteerStatus: GetOneEventWithVolunteerStatusUsecase,
private readonly getEventsByOrganization: GetEventsByOrganizationUsecase,
private readonly getMyEventsCountsUsecase: GetMyEventsCountsUsecase,
) {}

@Get()
Expand All @@ -61,6 +65,21 @@ export class MobileEventController {
});
}

// The badges of the three segments, for the same search and filters as the list
@Get('counts')
@ApiOkResponse({ type: MobileEventsCountsPresenter })
async getCounts(
@Query() filters: GetMyEventsCountsDto,
@ExtractUser() { id }: IRegularUserModel,
): Promise<MobileEventsCountsPresenter> {
const counts = await this.getMyEventsCountsUsecase.execute({
...filters,
userId: id,
});

return new MobileEventsCountsPresenter(counts);
}

// TODO: Volunteer Guard
@Get('organization/:organizationId')
async getEventsByOrganizationId(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IMyEventsCountsModel } from 'src/modules/event/models/event.model';

export class MobileEventsCountsPresenter {
constructor(counts: IMyEventsCountsModel) {
this.open = counts.open;
this.going = counts.going;
this.organizations = counts.organizations;
}

@ApiProperty({ description: 'Events in the Deschise (OPEN) list' })
open: number;

@ApiProperty({ description: 'Events in the Particip (GOING) list' })
going: number;

@ApiProperty({
description: 'Events in the Organizațiile mele (ORGANIZATIONS) list',
})
organizations: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
CreateEventOptions,
FindManyEventOptions,
FindMyEventsOptions,
CountMyEventsOptions,
IMyEventsCountsModel,
FindOneEventOptions,
FindOngoingAndFinishedEventOptions,
IEventModel,
Expand Down Expand Up @@ -43,6 +45,9 @@ export interface IEventRepository {
findGoingEvents(
findOptions: Omit<FindMyEventsOptions, 'eventFilter'>,
): Promise<Pagination<IEventsMobileListItemModel>>;
countMyEvents(
findOptions: CountMyEventsOptions,
): Promise<IMyEventsCountsModel>;
findOngoingAndFinishedEvents(
findOptions: FindOngoingAndFinishedEventOptions,
): Promise<IEventModel[]>;
Expand Down
12 changes: 12 additions & 0 deletions backend/src/modules/event/models/event.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,18 @@ export type FindMyEventsOptions = {
startTo?: Date;
} & IBasePaginationFilterModel;

// The mobile segment totals take the list's search and filters, but no segment nor paging
export type CountMyEventsOptions = Pick<
FindMyEventsOptions,
'userId' | 'organizationId' | 'startFrom' | 'startTo' | 'search'
>;

export interface IMyEventsCountsModel {
open: number;
going: number;
organizations: number;
}

export type FindOngoingAndFinishedEventOptions = {
organizationId: string;
userId: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Repository } from 'typeorm';
import { EventEntity } from '../entities/event.entity';
import { EventRepository } from './event.repository';

// The mobile segment badges (GET /mobile/event/counts) must be built with the very query of each
// list, so a badge never says something other than its list. This records every call made on the
// query builders and compares the list's to the count's.
type Call = { method: string; args: unknown[] };

const recordingBuilder = (calls: Call[]): unknown => {
const builder: unknown = new Proxy(
{},
{
get: (_target, method: string): unknown => {
if (method === 'then') return undefined;
if (method === 'getQuery') return (): string => '(subquery)';
if (method === 'getCount') return async (): Promise<number> => 7;
if (method === 'getManyAndCount')
return async (): Promise<[unknown[], number]> => [[], 7];
return (...args: unknown[]): unknown => {
calls.push({ method, args });
return builder;
};
},
},
);
return builder;
};

describe('EventRepository mobile segments', () => {
let calls: Call[][];
const repository = new EventRepository({
createQueryBuilder: () => {
const own: Call[] = [];
calls.push(own);
return recordingBuilder(own);
},
} as unknown as Repository<EventEntity>);

const filters = {
userId: 'user-1',
search: 'Mara',
organizationId: 'org-1',
startFrom: new Date('2030-05-01T00:00:00Z'),
startTo: new Date('2030-05-31T00:00:00Z'),
};

// the conditions and their parameters, without the "now" that differs between two calls
const conditions = (recorded: Call[]): unknown[] =>
recorded
.filter(({ method }) =>
['where', 'andWhere', 'from', 'select'].includes(method),
)
.map(({ method, args }) => [
method,
args.map((arg) =>
arg && typeof arg === 'object' && 'currentDate' in arg
? { ...arg, currentDate: 'now' }
: typeof arg === 'object' && arg?.constructor?.name === 'Brackets'
? 'brackets'
: arg,
),
]);

beforeEach(() => {
calls = [];
});

it.each([
['open', 'findOpenEvents'],
['going', 'findGoingEvents'],
['organizations', 'findMyOrganizationsEvents'],
] as const)(
'counts %s with the same conditions as its list',
async (segment, finder) => {
await repository[finder]({ ...filters, limit: 10, page: 1 });
const listCalls = calls[0];

calls = [];
const counts = await repository.countMyEvents(filters);
expect(counts).toEqual({ open: 7, going: 7, organizations: 7 });

// countMyEvents builds open, going, organizations in this order
const index = { open: 0, going: 1, organizations: 2 }[segment];
expect(conditions(calls[index])).toEqual(conditions(listCalls));
// the filters are part of it
expect(JSON.stringify(conditions(calls[index]))).toContain(
'filterOrganizationId',
);
// and a count takes no page
expect(calls[index].map(({ method }) => method)).not.toContain('take');
},
);
});
Loading
Loading