From 85096902b3d9adebc12f68e78ec4a23423746322 Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Fri, 13 Nov 2026 08:37:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20num=C4=83rul=20de=20nout=C4=83=C8=9Bi?= =?UTF-8?q?=20necitite=20=C8=99i=20bulina=20ro=C8=99ie=20pe=20clopo=C8=9Be?= =?UTF-8?q?lul=20din=20Acas=C4=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: coloana user.news_last_seen_at (migrarea NewsLastSeenAt1794450000000), GET /mobile/news/unread-count ({ unread }) și POST /mobile/news/seen (204). Numărul folosește aceleași evenimente și aceeași vizibilitate ca lista Noutăți. Mobil: bulina .badge-n pe clopoțel (ascunsă la 0, 9+ peste 9), etichetă „Noutăți, N necitite”, Noutăți marchează citirea la deschidere, Acasă reîncarcă numărul la fiecare focus. --- .../src/api/_mobile/news/news.controller.ts | 44 ++++- .../news/presenters/unread-news.presenter.ts | 15 ++ .../1794450000000-NewsLastSeenAt.ts | 19 ++ .../actions-archive/actions-archive.facade.ts | 6 + .../actions-archive/enums/news-type.enum.ts | 20 ++ .../actions-archive-repository.interface.ts | 3 + .../actions-archive.repository.ts | 38 +++- .../src/modules/user/entities/user.entity.ts | 8 + .../regular-user-repository.interface.ts | 1 + .../repositories/regular-user.repository.ts | 7 + .../user/services/user-facade.service.ts | 4 + .../actions-archive/get-many-news.usecase.ts | 26 +-- .../get-unread-news-count.usecase.ts | 18 ++ .../actions-archive/mark-news-seen.usecase.ts | 13 ++ .../actions-archive/news-unread.spec.ts | 63 +++++++ backend/src/usecases/use-case.module.ts | 6 + backend/test/mobile-news-unread.e2e-spec.ts | 176 ++++++++++++++++++ mobile/src/assets/locales/en/translation.json | 2 + mobile/src/assets/locales/ro/translation.json | 2 + .../src/design/components/CountBadge.test.tsx | 43 +++++ mobile/src/design/components/CountBadge.tsx | 71 +++++++ mobile/src/design/components/index.ts | 1 + mobile/src/screens/Home.tsx | 15 +- mobile/src/screens/News.tsx | 10 +- mobile/src/screens/home.test.tsx | 34 ++++ mobile/src/screens/home/HomeParts.tsx | 13 +- mobile/src/screens/news.test.tsx | 10 + mobile/src/services/news/news.api.ts | 10 + .../src/services/news/news.service.test.tsx | 59 ++++++ mobile/src/services/news/news.service.ts | 24 ++- 30 files changed, 722 insertions(+), 39 deletions(-) create mode 100644 backend/src/api/_mobile/news/presenters/unread-news.presenter.ts create mode 100644 backend/src/migrations/1794450000000-NewsLastSeenAt.ts create mode 100644 backend/src/usecases/actions-archive/get-unread-news-count.usecase.ts create mode 100644 backend/src/usecases/actions-archive/mark-news-seen.usecase.ts create mode 100644 backend/src/usecases/actions-archive/news-unread.spec.ts create mode 100644 backend/test/mobile-news-unread.e2e-spec.ts create mode 100644 mobile/src/design/components/CountBadge.test.tsx create mode 100644 mobile/src/design/components/CountBadge.tsx create mode 100644 mobile/src/services/news/news.service.test.tsx diff --git a/backend/src/api/_mobile/news/news.controller.ts b/backend/src/api/_mobile/news/news.controller.ts index 999c645a5..11800f2c9 100644 --- a/backend/src/api/_mobile/news/news.controller.ts +++ b/backend/src/api/_mobile/news/news.controller.ts @@ -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 { @@ -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 { + 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 { + await this.markNewsSeenUsecase.execute(user.id); + } @Get() @ApiPaginatedResponse(NewsPresenter) diff --git a/backend/src/api/_mobile/news/presenters/unread-news.presenter.ts b/backend/src/api/_mobile/news/presenters/unread-news.presenter.ts new file mode 100644 index 000000000..6c9004d75 --- /dev/null +++ b/backend/src/api/_mobile/news/presenters/unread-news.presenter.ts @@ -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; +} diff --git a/backend/src/migrations/1794450000000-NewsLastSeenAt.ts b/backend/src/migrations/1794450000000-NewsLastSeenAt.ts new file mode 100644 index 000000000..e2af118d7 --- /dev/null +++ b/backend/src/migrations/1794450000000-NewsLastSeenAt.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "news_last_seen_at"`, + ); + } +} diff --git a/backend/src/modules/actions-archive/actions-archive.facade.ts b/backend/src/modules/actions-archive/actions-archive.facade.ts index 09b3738cc..ac631f17f 100644 --- a/backend/src/modules/actions-archive/actions-archive.facade.ts +++ b/backend/src/modules/actions-archive/actions-archive.facade.ts @@ -68,4 +68,10 @@ export class ActionsArchiveFacade { ): Promise> { return this.actionsArchiveRepository.findNews(options); } + + async countUnreadNews( + options: Pick, + ): Promise { + return this.actionsArchiveRepository.countUnreadNews(options); + } } diff --git a/backend/src/modules/actions-archive/enums/news-type.enum.ts b/backend/src/modules/actions-archive/enums/news-type.enum.ts index 95a265893..21a3edd76 100644 --- a/backend/src/modules/actions-archive/enums/news-type.enum.ts +++ b/backend/src/modules/actions-archive/enums/news-type.enum.ts @@ -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.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(); diff --git a/backend/src/modules/actions-archive/interfaces/actions-archive-repository.interface.ts b/backend/src/modules/actions-archive/interfaces/actions-archive-repository.interface.ts index 32db954d1..d73fa1cf6 100644 --- a/backend/src/modules/actions-archive/interfaces/actions-archive-repository.interface.ts +++ b/backend/src/modules/actions-archive/interfaces/actions-archive-repository.interface.ts @@ -17,4 +17,7 @@ export interface IActionArchiveRepository { findNews( options: Omit, ): Promise>; + countUnreadNews( + options: Pick, + ): Promise; } diff --git a/backend/src/modules/actions-archive/repositories/actions-archive.repository.ts b/backend/src/modules/actions-archive/repositories/actions-archive.repository.ts index c18dbeeae..f6d4dd4d1 100644 --- a/backend/src/modules/actions-archive/repositories/actions-archive.repository.ts +++ b/backend/src/modules/actions-archive/repositories/actions-archive.repository.ts @@ -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, @@ -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, + ): Promise { + 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 { const query = this.actionsArchiveRepo .createQueryBuilder('archive') diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index e29de56c2..0ed23ef2c 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -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; } diff --git a/backend/src/modules/user/interfaces/regular-user-repository.interface.ts b/backend/src/modules/user/interfaces/regular-user-repository.interface.ts index 14e354cb1..832e66265 100644 --- a/backend/src/modules/user/interfaces/regular-user-repository.interface.ts +++ b/backend/src/modules/user/interfaces/regular-user-repository.interface.ts @@ -12,4 +12,5 @@ export interface IRegularUserRepository { id: string, updateUserModel: UpdateRegularUserOptions, ): Promise; + markNewsSeen(id: string): Promise; } diff --git a/backend/src/modules/user/repositories/regular-user.repository.ts b/backend/src/modules/user/repositories/regular-user.repository.ts index fc74e0296..68114a57f 100644 --- a/backend/src/modules/user/repositories/regular-user.repository.ts +++ b/backend/src/modules/user/repositories/regular-user.repository.ts @@ -59,6 +59,13 @@ export class RegularUserRepositoryService implements IRegularUserRepository { return userEntity ? RegularUserTransformer.fromEntity(userEntity) : null; } + async markNewsSeen(id: string): Promise { + // the database clock, the one that stamps the news (created_on) + await this.regularUserRepository.update(id, { + newsLastSeenAt: () => 'now()', + }); + } + async softDeleteAndAnonimize(id: string): Promise { const userToUpdate = await this.regularUserRepository.preload({ id, diff --git a/backend/src/modules/user/services/user-facade.service.ts b/backend/src/modules/user/services/user-facade.service.ts index 6970fa726..01db3c604 100644 --- a/backend/src/modules/user/services/user-facade.service.ts +++ b/backend/src/modules/user/services/user-facade.service.ts @@ -66,4 +66,8 @@ export class UserFacadeService { ): Promise { return this.regularUserRepository.softDeleteAndAnonimize(id); } + + public async markNewsSeen(id: string): Promise { + return this.regularUserRepository.markNewsSeen(id); + } } diff --git a/backend/src/usecases/actions-archive/get-many-news.usecase.ts b/backend/src/usecases/actions-archive/get-many-news.usecase.ts index d29d76739..719422da7 100644 --- a/backend/src/usecases/actions-archive/get-many-news.usecase.ts +++ b/backend/src/usecases/actions-archive/get-many-news.usecase.ts @@ -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, @@ -20,30 +19,9 @@ export class GetManyNewsUsecase ): Promise> { 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] ?? [], }); } } diff --git a/backend/src/usecases/actions-archive/get-unread-news-count.usecase.ts b/backend/src/usecases/actions-archive/get-unread-news-count.usecase.ts new file mode 100644 index 000000000..945e38e75 --- /dev/null +++ b/backend/src/usecases/actions-archive/get-unread-news-count.usecase.ts @@ -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 { + constructor(private readonly actionsArchiveFacade: ActionsArchiveFacade) {} + + public async execute(userId: string): Promise { + return this.actionsArchiveFacade.countUnreadNews({ + userId, + events: ALL_NEWS_EVENTS, + }); + } +} diff --git a/backend/src/usecases/actions-archive/mark-news-seen.usecase.ts b/backend/src/usecases/actions-archive/mark-news-seen.usecase.ts new file mode 100644 index 000000000..9cf0ace19 --- /dev/null +++ b/backend/src/usecases/actions-archive/mark-news-seen.usecase.ts @@ -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 { + constructor(private readonly userFacade: UserFacadeService) {} + + public async execute(userId: string): Promise { + await this.userFacade.markNewsSeen(userId); + } +} diff --git a/backend/src/usecases/actions-archive/news-unread.spec.ts b/backend/src/usecases/actions-archive/news-unread.spec.ts new file mode 100644 index 000000000..3c4c4cf62 --- /dev/null +++ b/backend/src/usecases/actions-archive/news-unread.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/usecases/use-case.module.ts b/backend/src/usecases/use-case.module.ts index f87c5824c..b1665b148 100644 --- a/backend/src/usecases/use-case.module.ts +++ b/backend/src/usecases/use-case.module.ts @@ -177,6 +177,8 @@ import { RejoinOrganizationUsecase } from './organization/rejoin-organization.us import { UpdateSettingsUsecase } from './notifications-settings/update-settings.usecase'; import { GetVolunteersUserDataForNotificationsUsecase } from './volunteer/get-volunteers-user-data-for-notifications.usecase'; import { GetManyNewsUsecase } from './actions-archive/get-many-news.usecase'; +import { GetUnreadNewsCountUsecase } from './actions-archive/get-unread-news-count.usecase'; +import { MarkNewsSeenUsecase } from './actions-archive/mark-news-seen.usecase'; import { GetVolunteerOrganizationStatusUsecase } from './volunteer/get-volunteer-organization-status.usecase'; import { GetOneRegularUserProfileUseCase } from './user/get-regule-user-profile.usecase'; import { SyncUserOrganizationsUsecase } from './user/sync-user-organizations.usecase'; @@ -357,6 +359,8 @@ import { DeleteAccountRegularUserUsecase } from './user/delete-account.usecase'; // Actions Archive GetManyActionsArchiveUseCase, GetManyNewsUsecase, + GetUnreadNewsCountUsecase, + MarkNewsSeenUsecase, // Dashboard GetDashboardVolunteerStatusTimeseriesUsecase, GetDashboardVolunteerGroupedUsecase, @@ -541,6 +545,8 @@ import { DeleteAccountRegularUserUsecase } from './user/delete-account.usecase'; // Actions Archive GetManyActionsArchiveUseCase, GetManyNewsUsecase, + GetUnreadNewsCountUsecase, + MarkNewsSeenUsecase, // Dashboard GetDashboardVolunteerStatusTimeseriesUsecase, GetDashboardVolunteerGroupedUsecase, diff --git a/backend/test/mobile-news-unread.e2e-spec.ts b/backend/test/mobile-news-unread.e2e-spec.ts new file mode 100644 index 000000000..f552c0955 --- /dev/null +++ b/backend/test/mobile-news-unread.e2e-spec.ts @@ -0,0 +1,176 @@ +import { + AuthIdentityStatus, + AuthRealm, +} from 'src/modules/auth/enums/auth.enums'; +import { IAuthIdentityModel } from 'src/modules/auth/models/auth-identity.model'; +import { AuthIdentityFacade } from 'src/modules/auth/services/auth-identity.facade'; +import * as request from 'supertest'; +import { createTestApp, TestApp } from './utils/test-app'; +import { truncate } from './utils/test-data-source'; + +// The red badge on the mobile Home bell: the volunteer's "Noutăți" newer than the last time they +// opened the screen; opening it (POST /mobile/news/seen) reads them all. +describe('Mobile unread news (e2e)', () => { + let testApp: TestApp; + let elena: IAuthIdentityModel; + let userId: string; + let otherUserId: string; + let adminId: string; + let activeVolunteer: string; + let inactiveVolunteer: string; + let otherVolunteer: string; + + beforeAll(async () => { + testApp = await createTestApp(); + }); + + afterAll(async () => { + await testApp?.close(); + }); + + const token = async (): Promise => + `Bearer ${await testApp.mintAccessToken(elena, AuthRealm.MOBILE)}`; + + const unread = async (): Promise => + ( + await request(testApp.app.getHttpServer()) + .get('/mobile/news/unread-count') + .set('Authorization', await token()) + .expect(200) + ).body.unread; + + const news = async ( + eventName: string, + eventData: Record, + createdOn = 'now()', + ): Promise => { + await testApp.dataSource.query( + `INSERT INTO actions_archive (event_name, event_data, author_id, created_on) + VALUES ($1, $2, $3, ${createdOn})`, + [eventName, JSON.stringify(eventData), adminId], + ); + }; + + beforeEach(async () => { + await truncate(testApp.dataSource, [ + 'auth_identity', + 'actions_archive', + 'volunteer', + 'user', + 'organization', + ]); + const orgs: string[] = []; + for (const name of ['crucea', 'salvati']) { + const [org] = await testApp.dataSource.query( + `INSERT INTO organization (name, email, phone, status, validated_at) VALUES ($1, $2, $3, 'active', now()) RETURNING id`, + [name, `contact@${name}.ro`, `+4070000${name.length}000`], + ); + orgs.push(org.id); + } + [{ id: adminId }] = await testApp.dataSource.query( + `INSERT INTO "user" (type, name, email, phone, organization_id) + VALUES ('admin', 'Admin', 'admin@crucea.ro', '+40711111111', $1) RETURNING id`, + [orgs[0]], + ); + [{ id: userId }] = await testApp.dataSource.query( + `INSERT INTO "user" (type, name, email, phone, first_name, last_name, news_last_seen_at) + VALUES ('regular', 'Elena Pop', 'elena@example.ro', '+40722000000', 'Elena', 'Pop', now() - interval '1 day') + RETURNING id`, + ); + [{ id: otherUserId }] = await testApp.dataSource.query( + `INSERT INTO "user" (type, name, email, phone, first_name, last_name, news_last_seen_at) + VALUES ('regular', 'Ion Pop', 'ion@example.ro', '+40722000001', 'Ion', 'Pop', now() - interval '1 day') + RETURNING id`, + ); + const volunteer = async ( + user: string, + org: string, + status: string, + ): Promise => + ( + await testApp.dataSource.query( + `INSERT INTO volunteer (user_id, organization_id, status) VALUES ($1, $2, $3) RETURNING id`, + [user, org, status], + ) + )[0].id; + activeVolunteer = await volunteer(userId, orgs[0], 'active'); + inactiveVolunteer = await volunteer(userId, orgs[1], 'inactive'); + otherVolunteer = await volunteer(otherUserId, orgs[0], 'active'); + elena = await testApp.app.get(AuthIdentityFacade).createIdentity({ + realm: AuthRealm.MOBILE, + email: 'elena@example.ro', + status: AuthIdentityStatus.ACTIVE, + userId, + }); + }); + + it('counts only the news the volunteer sees in Noutăți, newer than their last visit', async () => { + // unread: hours, a contract and an approved access request, for Elena + await news('CHANGE_ACTIVITY_LOG_STATUS', { + volunteerId: activeVolunteer, + newStatus: 'approved', + }); + await news('CREATE_CONTRACT', { volunteerId: activeVolunteer }); + await news('APPROVE_ACCESS_REQUEST', { userId }); + // already seen: before her last visit + await news( + 'CHANGE_ACTIVITY_LOG_STATUS', + { volunteerId: activeVolunteer, newStatus: 'rejected' }, + `now() - interval '2 days'`, + ); + // not hers, not shown in Noutăți, or about a profile that is not active + await news('CREATE_CONTRACT', { volunteerId: otherVolunteer }); + await news('REJECT_ACCESS_REQUEST', { userId: otherUserId }); + await news('UPDATE_ACTIVITY_LOG', { volunteerId: activeVolunteer }); + await news('CREATE_CONTRACT', { volunteerId: inactiveVolunteer }); + + expect(await unread()).toBe(3); + }); + + it('opening Noutăți reads everything; a newer update is unread again', async () => { + await news('CREATE_CONTRACT', { volunteerId: activeVolunteer }); + await news('APPROVE_ACCESS_REQUEST', { userId }); + expect(await unread()).toBe(2); + + await request(testApp.app.getHttpServer()) + .post('/mobile/news/seen') + .set('Authorization', await token()) + .expect(204); + expect(await unread()).toBe(0); + + await news('CHANGE_ACTIVITY_LOG_STATUS', { + volunteerId: activeVolunteer, + newStatus: 'approved', + }); + expect(await unread()).toBe(1); + + // another volunteer's read state is their own + const [other] = await testApp.dataSource.query( + `SELECT news_last_seen_at < now() - interval '12 hours' AS old FROM "user" WHERE id = $1`, + [otherUserId], + ); + expect(other.old).toBe(true); + }); + + it('a new account starts with nothing unread', async () => { + await testApp.dataSource.query( + `UPDATE "user" SET news_last_seen_at = DEFAULT WHERE id = $1`, + [userId], + ); + await news( + 'APPROVE_ACCESS_REQUEST', + { userId }, + `now() - interval '1 minute'`, + ); + expect(await unread()).toBe(0); + }); + + it('both routes need a mobile login', async () => { + await request(testApp.app.getHttpServer()) + .get('/mobile/news/unread-count') + .expect(401); + await request(testApp.app.getHttpServer()) + .post('/mobile/news/seen') + .expect(401); + }); +}); diff --git a/mobile/src/assets/locales/en/translation.json b/mobile/src/assets/locales/en/translation.json index 02061b610..ea1b4fa0f 100644 --- a/mobile/src/assets/locales/en/translation.json +++ b/mobile/src/assets/locales/en/translation.json @@ -758,6 +758,8 @@ }, "hello": "Hi, {{name}}", "news": "News", + "news_unread": "News, {{n}} unread", + "news_unread_one": "News, 1 unread", "join_ngo_short": "Find organizations", "see_all": "All", "next": { diff --git a/mobile/src/assets/locales/ro/translation.json b/mobile/src/assets/locales/ro/translation.json index 3a93efb64..4d1c527eb 100644 --- a/mobile/src/assets/locales/ro/translation.json +++ b/mobile/src/assets/locales/ro/translation.json @@ -787,6 +787,8 @@ }, "hello": "Bună, {{name}}", "news": "Noutăți", + "news_unread": "Noutăți, {{n}} necitite", + "news_unread_one": "Noutăți, 1 necitită", "join_ngo_short": "Caută organizații", "see_all": "Toate", "next": { diff --git a/mobile/src/design/components/CountBadge.test.tsx b/mobile/src/design/components/CountBadge.test.tsx new file mode 100644 index 000000000..58c7ae149 --- /dev/null +++ b/mobile/src/design/components/CountBadge.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { badgeLabel, CountBadge } from './CountBadge'; +import { palette } from '../tokens'; + +describe('CountBadge', () => { + it('hides at 0, shows the number up to 9 and „9+” above', () => { + expect(badgeLabel(0)).toBeNull(); + expect(badgeLabel(undefined)).toBeNull(); + expect(badgeLabel(-1)).toBeNull(); + expect(badgeLabel(1)).toBe('1'); + expect(badgeLabel(9)).toBe('9'); + expect(badgeLabel(10)).toBe('9+'); + expect(badgeLabel(120)).toBe('9+'); + }); + + it('renders nothing at 0', async () => { + await render(); + expect(screen.queryByTestId('badge', { includeHiddenElements: true })).toBeNull(); + }); + + it('is the prototype `.badge-n`: red, white Manrope 800 11, paper ring, hidden from screen readers', async () => { + await render(); + const badge = screen.getByTestId('badge', { includeHiddenElements: true }); + expect(badge).toHaveStyle({ + backgroundColor: palette.red, + borderColor: palette.paper, + borderWidth: 2, + position: 'absolute', + top: 0, + right: -2, + minWidth: 24, + height: 24, + }); + expect(screen.getByText('9+', { includeHiddenElements: true })).toHaveStyle({ + color: palette.white, + fontFamily: 'Manrope_800ExtraBold', + fontSize: 11, + }); + // the button around it says the count + expect(screen.queryByText('9+')).toBeNull(); + }); +}); diff --git a/mobile/src/design/components/CountBadge.tsx b/mobile/src/design/components/CountBadge.tsx new file mode 100644 index 000000000..219b95f70 --- /dev/null +++ b/mobile/src/design/components/CountBadge.tsx @@ -0,0 +1,71 @@ +// Count badge „Îndrăzneț” (prototype `.badge-n`): a red pill with a white number, ringed in paper, +// on the top-right corner of an icon button (the Home bell). Hidden at 0, „9+” above 9. +import React from 'react'; +import { Text, View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; + +export const MAX_BADGE_COUNT = 9; + +/** What the badge shows: nothing at 0, the number up to 9, „9+” above. */ +export const badgeLabel = (count?: number | null): string | null => { + if (!count || count <= 0) { + return null; + } + return count > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : `${count}`; +}; + +export interface CountBadgeProps { + count?: number | null; + testID?: string; +} + +// The parent (the button) says the count to screen readers; the badge itself stays silent. +export const CountBadge = ({ count, testID }: CountBadgeProps) => { + const label = badgeLabel(count); + if (!label) { + return null; + } + return ( + + + {label} + + + ); +}; + +// The prototype's 2px paper ring is a box-shadow outside the 20pt pill: here it is a 2pt border +// on a 24pt pill moved out by 2pt, so the red part stays 20pt at top 2, right 0. +const RING = 2; + +const styles = StyleSheet.create((theme) => ({ + badge: { + position: 'absolute', + top: 2 - RING, + right: -RING, + minWidth: 20 + RING * 2, + height: 20 + RING * 2, + borderRadius: 10 + RING, + borderWidth: RING, + borderColor: theme.colors.background, + paddingHorizontal: 5, + backgroundColor: theme.colors.danger, + alignItems: 'center', + justifyContent: 'center', + }, + text: { + fontFamily: theme.fontFamily.textHeavy, + fontSize: 11, + lineHeight: 14, + color: theme.colors.white, + includeFontPadding: false, + textAlign: 'center', + }, +})); diff --git a/mobile/src/design/components/index.ts b/mobile/src/design/components/index.ts index b4b6a027a..a42bdb8b5 100644 --- a/mobile/src/design/components/index.ts +++ b/mobile/src/design/components/index.ts @@ -2,3 +2,4 @@ export * from './groupA'; export * from './groupB'; export * from './ImpactHexagon'; +export * from './CountBadge'; diff --git a/mobile/src/screens/Home.tsx b/mobile/src/screens/Home.tsx index f3691e61c..f43a1f852 100644 --- a/mobile/src/screens/Home.tsx +++ b/mobile/src/screens/Home.tsx @@ -13,6 +13,7 @@ import { useEventsInfiniteQuery } from '../services/event/event.service'; import { toHours } from '../common/utils/hours'; import { useActivityLogsCounters } from '../services/activity-log/activity-log.service'; import { useActivateOrganization } from '../services/organization/organization.service'; +import { useUnreadNewsCountQuery } from '../services/news/news.service'; import { getUserProfile } from '../services/user/user.api'; import { useGetUserProfileQuery } from '../services/user/user.service'; import { OrderDirection } from '../common/enums/order-direction.enum'; @@ -93,11 +94,13 @@ const Home = ({ navigation }: any) => { const anouncements = useAnouncementsSnapshot(); const upcoming = useEventsInfiniteQuery(OrderDirection.ASC, '', EventType.GOING); const counters = useActivityLogsCounters(organization?.volunteerId); + const unreadNews = useUnreadNewsCountQuery(); const { refetch: refetchStatistics } = statistics; const { refetch: refetchAnouncements } = anouncements; const { refetch: refetchUpcoming } = upcoming; const { refetch: refetchCounters } = counters; + const { refetch: refetchUnreadNews } = unreadNews; useFocusEffect( useCallback(() => { @@ -107,7 +110,15 @@ const Home = ({ navigation }: any) => { refetchUpcoming(); refetchProfile(); refetchCounters(); - }, [refetchStatistics, refetchAnouncements, refetchUpcoming, refetchProfile, refetchCounters]), + refetchUnreadNews(); + }, [ + refetchStatistics, + refetchAnouncements, + refetchUpcoming, + refetchProfile, + refetchCounters, + refetchUnreadNews, + ]), ); const onRefresh = async () => { @@ -115,6 +126,7 @@ const Home = ({ navigation }: any) => { refetchAnouncements(); refetchUpcoming(); refetchCounters(); + refetchUnreadNews(); try { setUserProfile(await getUserProfile()); } catch { @@ -240,6 +252,7 @@ const Home = ({ navigation }: any) => { name={organization?.name} onSwitch={switchable.length > 1 ? () => orgSheet.current?.present() : undefined} onNews={() => navigation.navigate('news', { type: NewsType.LOGGED_HOURS })} + unread={unreadNews.data ?? 0} /> diff --git a/mobile/src/screens/News.tsx b/mobile/src/screens/News.tsx index bd6424268..5bada161b 100644 --- a/mobile/src/screens/News.tsx +++ b/mobile/src/screens/News.tsx @@ -1,9 +1,9 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, RefreshControl, StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; -import { useNewsInfiniteQuery } from '../services/news/news.service'; +import { useMarkNewsSeenMutation, useNewsInfiniteQuery } from '../services/news/news.service'; import { useActivateOrganization } from '../services/organization/organization.service'; import { NewsType } from '../common/enums/news-type.enum'; import { TrackedEventName } from '../common/enums/tracked-event-name.enum'; @@ -45,6 +45,12 @@ const News = ({ navigation, route }: any) => { useFocusedStatusBarStyle('dark'); const [type, setType] = useState(route.params?.type ?? NewsType.LOGGED_HOURS); + // opening „Noutăți” reads them all: the badge on the Home bell goes back to 0 + const { mutate: markSeen } = useMarkNewsSeenMutation(); + useEffect(() => { + markSeen(); + }, [markSeen]); + const { activate } = useActivateOrganization(); const { userProfile } = useUserProfile(); diff --git a/mobile/src/screens/home.test.tsx b/mobile/src/screens/home.test.tsx index 0d68a50de..c08caccd8 100644 --- a/mobile/src/screens/home.test.tsx +++ b/mobile/src/screens/home.test.tsx @@ -18,6 +18,8 @@ const mockCounterCalls: any[] = []; const mockRefetch = jest.fn(); const mockRefetchProfile = jest.fn(); const mockSwitch = jest.fn(); +let mockUnread = 0; +const mockRefetchUnread = jest.fn(); const query = (data: unknown) => ({ data, @@ -65,6 +67,9 @@ jest.mock('../services/event/event.service', () => ({ return query({ pages: [{ items: mockGoing, meta: { totalItems: mockGoing.length } }] }); }, })); +jest.mock('../services/news/news.service', () => ({ + useUnreadNewsCountQuery: () => ({ data: mockUnread, refetch: mockRefetchUnread }), +})); jest.mock('../services/user/user.api', () => ({ getUserProfile: jest.fn() })); jest.mock('../services/user/user.service', () => ({ useGetUserProfileQuery: () => ({ refetch: mockRefetchProfile }), @@ -127,6 +132,7 @@ beforeEach(() => { jest.clearAllMocks(); mockEventCalls.length = 0; mockCounterCalls.length = 0; + mockUnread = 0; mockGoing = []; mockCounters = { approved: 42, pending: 5, rejected: 1 }; mockStats = { @@ -158,6 +164,34 @@ describe('Acasă', () => { ).toBeTruthy(); }); + it('the bell has no badge when everything is read', async () => { + mockUnread = 0; + await renderHome(); + expect(screen.getByTestId('home-news').props.accessibilityLabel).toBe('Noutăți'); + expect(screen.queryByTestId('home-news-badge', { includeHiddenElements: true })).toBeNull(); + // the count is fetched again each time Acasă gets the focus + expect(mockRefetchUnread).toHaveBeenCalled(); + }); + + it('the bell shows the unread news in a red badge, „9+” above 9', async () => { + for (const [count, label, badge] of [ + [3, 'Noutăți, 3 necitite', '3'], + [1, 'Noutăți, 1 necitită', '1'], + [14, 'Noutăți, 14 necitite', '9+'], + ] as const) { + mockUnread = count; + const view = await renderHome(); + expect(screen.getByTestId('home-news').props.accessibilityLabel).toBe(label); + expect( + within(screen.getByTestId('home-news-badge', { includeHiddenElements: true })).getByText( + badge, + { includeHiddenElements: true }, + ), + ).toBeTruthy(); + await view.unmount(); + } + }); + it('opens logged hours news from the bell', async () => { await renderHome(); await fireEvent.press(screen.getByTestId('home-news')); diff --git a/mobile/src/screens/home/HomeParts.tsx b/mobile/src/screens/home/HomeParts.tsx index a9bf8b036..850bc056b 100644 --- a/mobile/src/screens/home/HomeParts.tsx +++ b/mobile/src/screens/home/HomeParts.tsx @@ -38,6 +38,7 @@ import { PressableScale } from '../../components/ui/PressableScale'; import { StatusChip } from '../../design/components/Chip'; import { HexAvatar } from '../../design/components/ListRow'; import { ImpactHexagon, useCountUp } from '../../design/components/ImpactHexagon'; +import { CountBadge } from '../../design/components/CountBadge'; // --------------------------------------------------------------------------------------------- // Large title + compact glass bar. The large title scrolls with the content (as in the @@ -117,13 +118,22 @@ export const OrgRow = ({ name, onSwitch, onNews, + unread = 0, }: { name?: string; /** set when there is another organization to switch to */ onSwitch?: () => void; onNews: () => void; + /** news newer than the last visit to „Noutăți”: the red badge on the bell */ + unread?: number; }) => { const { t } = useTranslation('home'); + const newsLabel = + unread > 0 + ? unread === 1 + ? `${t('news_unread_one')}` + : `${t('news_unread', { n: unread })}` + : `${t('news')}`; return ( {name ? ( @@ -157,10 +167,11 @@ export const OrgRow = ({ + ); diff --git a/mobile/src/screens/news.test.tsx b/mobile/src/screens/news.test.tsx index 75d638ba4..559b85c94 100644 --- a/mobile/src/screens/news.test.tsx +++ b/mobile/src/screens/news.test.tsx @@ -14,6 +14,7 @@ import { ActivityLogStatus } from '../common/enums/activity-log.status.enum'; const mockSwitch = jest.fn(); const mockNewsCalls: NewsType[] = []; +const mockMarkSeen = jest.fn(); const mockPage = (items: any[]) => ({ data: { pages: [{ items, meta: { totalItems: items.length } }] }, @@ -53,6 +54,7 @@ const mockNews: Record = { }; jest.mock('../services/news/news.service', () => ({ + useMarkNewsSeenMutation: () => ({ mutate: mockMarkSeen }), useNewsInfiniteQuery: (type: NewsType) => { mockNewsCalls.push(type); return mockPage(mockNews[type]); @@ -152,6 +154,14 @@ describe('Noutăți', () => { expect(await screen.findByText('Nicio noutate aici încă')).toBeTruthy(); }); + it('opening Noutăți marks the news seen once (the Home badge goes back to 0)', async () => { + await renderApp('news', { type: NewsType.LOGGED_HOURS }); + expect(await screen.findByText('Noutăți')).toBeTruthy(); + expect(mockMarkSeen).toHaveBeenCalledTimes(1); + await fireEvent.press(screen.getByText('Organizații')); + expect(mockMarkSeen).toHaveBeenCalledTimes(1); + }); + it('an update from another organization makes it active, then opens it', async () => { await renderApp('news', { type: NewsType.LOGGED_HOURS }); await fireEvent.press(await screen.findByTestId('news-row-n1')); diff --git a/mobile/src/services/news/news.api.ts b/mobile/src/services/news/news.api.ts index 50da7677e..891c25ba5 100644 --- a/mobile/src/services/news/news.api.ts +++ b/mobile/src/services/news/news.api.ts @@ -20,3 +20,13 @@ export const getNews = async ({ }, }).then((res) => res.data); }; + +/** The volunteer's news newer than their last visit to „Noutăți” (the badge on the Home bell). */ +export const getUnreadNewsCount = async (): Promise<{ unread: number }> => { + return API.get('/mobile/news/unread-count').then((res) => res.data); +}; + +/** The volunteer opened „Noutăți”: everything up to now is read. */ +export const markNewsSeen = async (): Promise => { + return API.post('/mobile/news/seen').then(() => undefined); +}; diff --git a/mobile/src/services/news/news.service.test.tsx b/mobile/src/services/news/news.service.test.tsx new file mode 100644 index 000000000..a3bb9b4f7 --- /dev/null +++ b/mobile/src/services/news/news.service.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import API from '../api'; +import { useMarkNewsSeenMutation, useUnreadNewsCountQuery } from './news.service'; + +jest.mock('../api', () => ({ + __esModule: true, + default: { get: jest.fn(), post: jest.fn() }, +})); + +let queryClient: QueryClient; +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +let mockUnread = 3; + +beforeEach(() => { + mockUnread = 3; + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + jest.mocked(API.get).mockReset(); + jest.mocked(API.get).mockImplementation(async () => ({ data: { unread: mockUnread } })); + jest.mocked(API.post).mockReset(); + jest.mocked(API.post).mockImplementation(async () => { + mockUnread = 0; + return { data: undefined, status: 204 }; + }); +}); + +afterEach(() => { + queryClient.clear(); +}); + +describe('unread news (the badge on the Home bell)', () => { + it('reads the count from GET /mobile/news/unread-count', async () => { + const { result } = await renderHook(() => useUnreadNewsCountQuery(), { wrapper }); + await waitFor(() => expect(result.current.data).toBe(3)); + expect(API.get).toHaveBeenCalledWith('/mobile/news/unread-count'); + }); + + it('marking „Noutăți” seen posts once and brings the count to 0', async () => { + const { result } = await renderHook( + () => ({ count: useUnreadNewsCountQuery(), seen: useMarkNewsSeenMutation() }), + { wrapper }, + ); + await waitFor(() => expect(result.current.count.data).toBe(3)); + + await act(async () => { + await result.current.seen.mutateAsync(); + }); + + expect(API.post).toHaveBeenCalledTimes(1); + expect(API.post).toHaveBeenCalledWith('/mobile/news/seen'); + await waitFor(() => expect(result.current.count.data).toBe(0)); + // the count was refetched from the server after the change + expect(jest.mocked(API.get).mock.calls.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/mobile/src/services/news/news.service.ts b/mobile/src/services/news/news.service.ts index a4ba440bd..5b78d8b0f 100644 --- a/mobile/src/services/news/news.service.ts +++ b/mobile/src/services/news/news.service.ts @@ -1,7 +1,9 @@ -import { useInfiniteQuery } from 'react-query'; -import { getNews } from './news.api'; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from 'react-query'; +import { getNews, getUnreadNewsCount, markNewsSeen } from './news.api'; import { NewsType } from '../../common/enums/news-type.enum'; +export const NEWS_UNREAD_COUNT_KEY = ['news-unread-count']; + export const useNewsInfiniteQuery = (type: NewsType) => { return useInfiniteQuery(['news', type], ({ pageParam }) => getNews({ pageParam, type }), { getNextPageParam: (lastPage) => { @@ -12,3 +14,21 @@ export const useNewsInfiniteQuery = (type: NewsType) => { enabled: !!type, }); }; + +/** The red badge on the Home bell. */ +export const useUnreadNewsCountQuery = () => { + return useQuery(NEWS_UNREAD_COUNT_KEY, getUnreadNewsCount, { + select: (data) => data?.unread ?? 0, + }); +}; + +/** Sent when „Noutăți” opens; the badge refetches (to 0) right after. */ +export const useMarkNewsSeenMutation = () => { + const queryClient = useQueryClient(); + return useMutation(markNewsSeen, { + onSuccess: () => { + queryClient.setQueryData(NEWS_UNREAD_COUNT_KEY, { unread: 0 }); + queryClient.invalidateQueries(NEWS_UNREAD_COUNT_KEY); + }, + }); +};