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
44 changes: 41 additions & 3 deletions backend/src/api/_mobile/news/news.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth } from '@nestjs/swagger';
import {
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
} from '@nestjs/swagger';
import { MobileJwtAuthGuard } from 'src/modules/auth/guards/jwt-mobile.guard';
import { MobileProfileGuard } from '../common/guards/mobile-profile.guard';
import {
Expand All @@ -11,12 +23,38 @@ import { ExtractUser } from 'src/common/decorators/extract-user.decorator';
import { IRegularUserModel } from 'src/modules/user/models/regular-user.model';
import { NewsPresenter } from './presenters/news.presenter';
import { GetNewsDto } from './dto/get-news.dto';
import { UnreadNewsPresenter } from './presenters/unread-news.presenter';
import { GetUnreadNewsCountUsecase } from 'src/usecases/actions-archive/get-unread-news-count.usecase';
import { MarkNewsSeenUsecase } from 'src/usecases/actions-archive/mark-news-seen.usecase';

@ApiBearerAuth()
@UseGuards(MobileJwtAuthGuard, MobileProfileGuard)
@Controller('/mobile/news')
export class MobileNewsController {
constructor(private readonly getManyNewsUsecase: GetManyNewsUsecase) {}
constructor(
private readonly getManyNewsUsecase: GetManyNewsUsecase,
private readonly getUnreadNewsCountUsecase: GetUnreadNewsCountUsecase,
private readonly markNewsSeenUsecase: MarkNewsSeenUsecase,
) {}

// The badge on the Home bell
@Get('unread-count')
@ApiOkResponse({ type: UnreadNewsPresenter })
async getUnreadCount(
@ExtractUser() user: IRegularUserModel,
): Promise<UnreadNewsPresenter> {
return new UnreadNewsPresenter(
await this.getUnreadNewsCountUsecase.execute(user.id),
);
}

// Sent when the volunteer opens "Noutăți" (explicit, not a side effect of the paginated, per-tab GET)
@Post('seen')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse()
async markSeen(@ExtractUser() user: IRegularUserModel): Promise<void> {
await this.markNewsSeenUsecase.execute(user.id);
}

@Get()
@ApiPaginatedResponse(NewsPresenter)
Expand Down
15 changes: 15 additions & 0 deletions backend/src/api/_mobile/news/presenters/unread-news.presenter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Expose } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';

export class UnreadNewsPresenter {
constructor(unread: number) {
this.unread = unread;
}

@Expose()
@ApiProperty({
description: 'News newer than the last time the volunteer opened them',
example: 3,
})
unread: number;
}
19 changes: 19 additions & 0 deletions backend/src/migrations/1794450000000-NewsLastSeenAt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

// The unread count on the mobile Home bell: news newer than the last time the volunteer opened
// "Noutăți". Existing users start at the migration time, so nobody wakes up to a badge for old news.
export class NewsLastSeenAt1794450000000 implements MigrationInterface {
name = 'NewsLastSeenAt1794450000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ADD "news_last_seen_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" DROP COLUMN "news_last_seen_at"`,
);
}
}
6 changes: 6 additions & 0 deletions backend/src/modules/actions-archive/actions-archive.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,10 @@ export class ActionsArchiveFacade {
): Promise<Pagination<IActionArchiveModel>> {
return this.actionsArchiveRepository.findNews(options);
}

async countUnreadNews(
options: Pick<FindManyNewsOptions, 'userId' | 'events'>,
): Promise<number> {
return this.actionsArchiveRepository.countUnreadNews(options);
}
}
20 changes: 20 additions & 0 deletions backend/src/modules/actions-archive/enums/news-type.enum.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
import { TrackedEventName } from './action-resource-types.enum';

export enum NewsType {
LOGGED_HOURS = 'LOGGED_HOURS',
CONTRACTS = 'CONTRACTS',
ORGANIZATIONS = 'ORGANIZATIONS',
}

// The tracked events the mobile "Noutăți" shows, by tab.
export const NEWS_EVENTS: Record<NewsType, TrackedEventName[]> = {
[NewsType.ORGANIZATIONS]: [
TrackedEventName.APPROVE_ACCESS_REQUEST,
TrackedEventName.REJECT_ACCESS_REQUEST,
],
[NewsType.CONTRACTS]: [
TrackedEventName.CREATE_CONTRACT,
TrackedEventName.APPROVE_CONTRACT,
TrackedEventName.REJECT_CONTRACT,
],
[NewsType.LOGGED_HOURS]: [TrackedEventName.CHANGE_ACTIVITY_LOG_STATUS],
};

// Every event of every tab: what the unread count on the Home bell counts.
export const ALL_NEWS_EVENTS: TrackedEventName[] =
Object.values(NEWS_EVENTS).flat();
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ export interface IActionArchiveRepository {
findNews(
options: Omit<FindManyNewsOptions, 'type'>,
): Promise<Pagination<IActionArchiveModel>>;
countUnreadNews(
options: Pick<FindManyNewsOptions, 'userId' | 'events'>,
): Promise<number>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,10 @@ export class ActionsArchiveRepository
.where('actionsArchive.eventName in (:...eventNames)', {
eventNames: options.events,
})
.andWhere(
`(("actionsArchive".event_data->>'volunteerId')::uuid IN (select "v".id from "volunteer" "v"
INNER JOIN "user" "u" ON "v".user_id = "u".id
where "u".id = :userId AND "v".status = :volunteerStatus) OR ("actionsArchive".event_data->>'userId')::uuid = :userId)`,
{ userId: options.userId, volunteerStatus: VolunteerStatus.ACTIVE },
)
.andWhere(this.newsOfUserCondition, {
userId: options.userId,
volunteerStatus: VolunteerStatus.ACTIVE,
})
.orderBy(
this.buildOrderByQuery('createdOn', 'actionsArchive'),
OrderDirection.DESC,
Expand All @@ -133,6 +131,34 @@ export class ActionsArchiveRepository
);
}

// The volunteer's news: about one of their active volunteer profiles, or about them.
private readonly newsOfUserCondition = `(("actionsArchive".event_data->>'volunteerId')::uuid IN (select "v".id from "volunteer" "v"
INNER JOIN "user" "u" ON "v".user_id = "u".id
where "u".id = :userId AND "v".status = :volunteerStatus) OR ("actionsArchive".event_data->>'userId')::uuid = :userId)`;

// The same news as findNews, only those newer than the last time the user opened "Noutăți".
public async countUnreadNews(
options: Pick<FindManyNewsOptions, 'userId' | 'events'>,
): Promise<number> {
if (!options.events.length) {
return 0;
}

return this.actionsArchiveRepo
.createQueryBuilder('actionsArchive')
.where('actionsArchive.eventName in (:...eventNames)', {
eventNames: options.events,
})
.andWhere(this.newsOfUserCondition, {
userId: options.userId,
volunteerStatus: VolunteerStatus.ACTIVE,
})
.andWhere(
`actionsArchive.createdOn > (select "seen".news_last_seen_at from "user" "seen" where "seen".id = :userId)`,
)
.getCount();
}

async countActivityLogBetweenDates(volunteerIds: string[]): Promise<number> {
const query = this.actionsArchiveRepo
.createQueryBuilder('archive')
Expand Down
8 changes: 8 additions & 0 deletions backend/src/modules/user/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,12 @@ export class RegularUserEntity extends UserEntity {
@ManyToOne(() => OrganizationEntity)
@JoinColumn({ name: 'active_organization_id' })
activeOrganization: OrganizationEntity;

// The last time the volunteer opened "Noutăți": newer news are unread (the badge on the Home bell).
@Column({
type: 'timestamptz',
name: 'news_last_seen_at',
default: () => 'now()',
})
newsLastSeenAt: Date;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export interface IRegularUserRepository {
id: string,
updateUserModel: UpdateRegularUserOptions,
): Promise<IRegularUserModel>;
markNewsSeen(id: string): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ export class RegularUserRepositoryService implements IRegularUserRepository {
return userEntity ? RegularUserTransformer.fromEntity(userEntity) : null;
}

async markNewsSeen(id: string): Promise<void> {
// the database clock, the one that stamps the news (created_on)
await this.regularUserRepository.update(id, {
newsLastSeenAt: () => 'now()',
});
}

async softDeleteAndAnonimize(id: string): Promise<IRegularUserModel> {
const userToUpdate = await this.regularUserRepository.preload({
id,
Expand Down
4 changes: 4 additions & 0 deletions backend/src/modules/user/services/user-facade.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,8 @@ export class UserFacadeService {
): Promise<IRegularUserModel> {
return this.regularUserRepository.softDeleteAndAnonimize(id);
}

public async markNewsSeen(id: string): Promise<void> {
return this.regularUserRepository.markNewsSeen(id);
}
}
26 changes: 2 additions & 24 deletions backend/src/usecases/actions-archive/get-many-news.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { Injectable } from '@nestjs/common';
import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface';
import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class';
import { ActionsArchiveFacade } from 'src/modules/actions-archive/actions-archive.facade';
import { TrackedEventName } from 'src/modules/actions-archive/enums/action-resource-types.enum';
import { NewsType } from 'src/modules/actions-archive/enums/news-type.enum';
import { NEWS_EVENTS } from 'src/modules/actions-archive/enums/news-type.enum';
import {
FindManyNewsOptions,
IActionArchiveModel,
Expand All @@ -20,30 +19,9 @@ export class GetManyNewsUsecase
): Promise<Pagination<IActionArchiveModel>> {
const { type, ...findOptions } = options;

let events = [];

switch (type) {
case NewsType.ORGANIZATIONS:
events = [
TrackedEventName.APPROVE_ACCESS_REQUEST,
TrackedEventName.REJECT_ACCESS_REQUEST,
];
break;
case NewsType.CONTRACTS:
events = [
TrackedEventName.CREATE_CONTRACT,
TrackedEventName.APPROVE_CONTRACT,
TrackedEventName.REJECT_CONTRACT,
];
break;
case NewsType.LOGGED_HOURS:
events = [TrackedEventName.CHANGE_ACTIVITY_LOG_STATUS];
break;
}

return this.actionsArchiveFacade.findNews({
...findOptions,
events,
events: NEWS_EVENTS[type] ?? [],
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface';
import { ActionsArchiveFacade } from 'src/modules/actions-archive/actions-archive.facade';
import { ALL_NEWS_EVENTS } from 'src/modules/actions-archive/enums/news-type.enum';

// The red badge on the Home bell: the volunteer's "Noutăți" (every tab) newer than the last time
// they opened the screen.
@Injectable()
export class GetUnreadNewsCountUsecase implements IUseCaseService<number> {
constructor(private readonly actionsArchiveFacade: ActionsArchiveFacade) {}

public async execute(userId: string): Promise<number> {
return this.actionsArchiveFacade.countUnreadNews({
userId,
events: ALL_NEWS_EVENTS,
});
}
}
13 changes: 13 additions & 0 deletions backend/src/usecases/actions-archive/mark-news-seen.usecase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface';
import { UserFacadeService } from 'src/modules/user/services/user-facade.service';

// The volunteer opened "Noutăți": everything up to now is read.
@Injectable()
export class MarkNewsSeenUsecase implements IUseCaseService<void> {
constructor(private readonly userFacade: UserFacadeService) {}

public async execute(userId: string): Promise<void> {
await this.userFacade.markNewsSeen(userId);
}
}
63 changes: 63 additions & 0 deletions backend/src/usecases/actions-archive/news-unread.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ActionsArchiveFacade } from 'src/modules/actions-archive/actions-archive.facade';
import { TrackedEventName } from 'src/modules/actions-archive/enums/action-resource-types.enum';
import {
ALL_NEWS_EVENTS,
NewsType,
} from 'src/modules/actions-archive/enums/news-type.enum';
import { UserFacadeService } from 'src/modules/user/services/user-facade.service';
import { GetManyNewsUsecase } from './get-many-news.usecase';
import { GetUnreadNewsCountUsecase } from './get-unread-news-count.usecase';
import { MarkNewsSeenUsecase } from './mark-news-seen.usecase';

// The badge on the mobile Home bell: unread "Noutăți" across every tab, read when the screen opens.
describe('unread news', () => {
it('counts the events of every Noutăți tab, for the user', async () => {
const facade = { countUnreadNews: jest.fn().mockResolvedValue(4) };
const useCase = new GetUnreadNewsCountUsecase(
facade as unknown as ActionsArchiveFacade,
);

expect(await useCase.execute('u')).toBe(4);
expect(facade.countUnreadNews).toHaveBeenCalledWith({
userId: 'u',
events: ALL_NEWS_EVENTS,
});
expect([...ALL_NEWS_EVENTS].sort()).toEqual(
[
TrackedEventName.APPROVE_ACCESS_REQUEST,
TrackedEventName.REJECT_ACCESS_REQUEST,
TrackedEventName.CREATE_CONTRACT,
TrackedEventName.APPROVE_CONTRACT,
TrackedEventName.REJECT_CONTRACT,
TrackedEventName.CHANGE_ACTIVITY_LOG_STATUS,
].sort(),
);
});

it('the list still asks for the events of the chosen tab only', async () => {
const facade = { findNews: jest.fn().mockResolvedValue({ items: [] }) };
const useCase = new GetManyNewsUsecase(
facade as unknown as ActionsArchiveFacade,
);

await useCase.execute({
userId: 'u',
events: [],
type: NewsType.LOGGED_HOURS,
} as never);
expect(facade.findNews).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'u',
events: [TrackedEventName.CHANGE_ACTIVITY_LOG_STATUS],
}),
);
});

it('opening Noutăți marks the user’s news as seen', async () => {
const users = { markNewsSeen: jest.fn().mockResolvedValue(undefined) };
await new MarkNewsSeenUsecase(
users as unknown as UserFacadeService,
).execute('u');
expect(users.markNewsSeen).toHaveBeenCalledWith('u');
});
});
Loading
Loading