diff --git a/src/modules/api-depot/api-depot.service.ts b/src/modules/api-depot/api-depot.service.ts index c09a943..6e4f5ac 100644 --- a/src/modules/api-depot/api-depot.service.ts +++ b/src/modules/api-depot/api-depot.service.ts @@ -25,4 +25,12 @@ export class ApiDepotService { return revision; } + + public async getAllCurrentRevisions(): Promise { + const { data: revisions } = await firstValueFrom( + this.httpService.get('/current-revisions'), + ); + + return revisions; + } } diff --git a/src/modules/setting/commune-settings-cache.service.ts b/src/modules/setting/commune-settings-cache.service.ts new file mode 100644 index 0000000..c4e504d --- /dev/null +++ b/src/modules/setting/commune-settings-cache.service.ts @@ -0,0 +1,71 @@ +import { + forwardRef, + Inject, + Injectable, + Logger, + OnModuleInit, +} from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { SettingService } from './setting.service'; +import { SignalementSubmissionMode } from './setting.type'; + +export type CommuneSettingsMap = Record< + string, + { + disabled: boolean; + mode?: SignalementSubmissionMode; + filteredSources?: string[]; + } +>; + +@Injectable() +export class CommuneSettingsCacheService implements OnModuleInit { + private cachedSettings: CommuneSettingsMap | null = null; + private readonly logger = new Logger(CommuneSettingsCacheService.name); + + constructor( + @Inject(forwardRef(() => SettingService)) + private readonly settingService: SettingService, + ) {} + + async onModuleInit() { + try { + await this.buildCache(); + } catch (err) { + this.logger.error( + 'Failed to build commune status cache on init', + err.message, + ); + } + } + + @Cron(CronExpression.EVERY_HOUR) + async refreshCache() { + try { + await this.buildCache(); + } catch (err) { + this.logger.error('Failed to refresh commune status cache', err.message); + } + } + + private async buildCache() { + this.logger.log('Building commune status cache...'); + + const statuses = await this.settingService.computeAllCommuneStatuses(); + + const result: CommuneSettingsMap = {}; + for (const [codeCommune, status] of statuses) { + result[codeCommune] = status; + } + + this.cachedSettings = result; + + this.logger.log( + `Commune settings cache ready with ${Object.keys(result).length} communes`, + ); + } + + getCachedSettings(): CommuneSettingsMap | null { + return this.cachedSettings; + } +} diff --git a/src/modules/setting/dto/commune-settings.dto.ts b/src/modules/setting/dto/commune-settings.dto.ts index 9a478de..dc1e69f 100644 --- a/src/modules/setting/dto/commune-settings.dto.ts +++ b/src/modules/setting/dto/commune-settings.dto.ts @@ -1,7 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { CommuneStatusDTO } from './commune-status.dto'; +import { IsOptional } from 'class-validator'; export class CommuneSettingsDTO extends CommuneStatusDTO { @ApiProperty({ required: false, type: [String] }) + @IsOptional() filteredSources?: string[]; } diff --git a/src/modules/setting/dto/commune-status.dto.ts b/src/modules/setting/dto/commune-status.dto.ts index b80080b..6abf931 100644 --- a/src/modules/setting/dto/commune-status.dto.ts +++ b/src/modules/setting/dto/commune-status.dto.ts @@ -1,12 +1,14 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum } from 'class-validator'; +import { IsBoolean, IsEnum, IsOptional } from 'class-validator'; import { SignalementSubmissionMode } from '../setting.type'; export class CommuneStatusDTO { @ApiProperty({ required: true, nullable: false }) + @IsBoolean() disabled: boolean; @ApiProperty({ required: false }) + @IsOptional() message?: string; @ApiProperty({ @@ -15,5 +17,6 @@ export class CommuneStatusDTO { enum: SignalementSubmissionMode, }) @IsEnum(SignalementSubmissionMode) + @IsOptional() mode?: SignalementSubmissionMode; } diff --git a/src/modules/setting/setting.controller.ts b/src/modules/setting/setting.controller.ts index 192443b..0607059 100644 --- a/src/modules/setting/setting.controller.ts +++ b/src/modules/setting/setting.controller.ts @@ -26,11 +26,18 @@ import { CommuneStatusDTO } from './dto/commune-status.dto'; import { CommuneSettingsDTO } from './dto/commune-settings.dto'; import { EnabledListKeys } from './setting.type'; import { EnabledListDTO } from './dto/enabled-list.dto'; +import { + CommuneSettingsCacheService, + CommuneSettingsMap, +} from './commune-settings-cache.service'; @ApiTags('settings') @Controller('settings') export class SettingController { - constructor(private settingService: SettingService) {} + constructor( + private settingService: SettingService, + private communeSettingsCacheService: CommuneSettingsCacheService, + ) {} @Get('commune-status/:codeCommune') @ApiOperation({ @@ -116,6 +123,28 @@ export class SettingController { res.status(HttpStatus.OK).json(settings); } + @Get('commune-settings') + @ApiOperation({ + summary: 'Get the settings of all communes', + operationId: 'getAllCommuneSettings', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Map of commune codes to their settings', + schema: { + type: 'object', + additionalProperties: { + $ref: '#/components/schemas/CommuneSettingsDTO', + }, + }, + }) + async getAllCommuneSettings(@Res() res: Response) { + const settings: CommuneSettingsMap = + this.communeSettingsCacheService.getCachedSettings() || {}; + + res.status(HttpStatus.OK).json(settings); + } + @Get('enabled-list/:listKey/:id') @ApiOperation({ summary: diff --git a/src/modules/setting/setting.module.ts b/src/modules/setting/setting.module.ts index 68f85f1..385c6a9 100644 --- a/src/modules/setting/setting.module.ts +++ b/src/modules/setting/setting.module.ts @@ -5,6 +5,7 @@ import { SettingService } from './setting.service'; import { SettingController } from './setting.controller'; import { ApiDepotModule } from '../api-depot/api-depot.module'; import { SourceModule } from '../source/source.module'; +import { CommuneSettingsCacheService } from './commune-settings-cache.service'; @Module({ imports: [ @@ -13,7 +14,7 @@ import { SourceModule } from '../source/source.module'; forwardRef(() => SourceModule), ], controllers: [SettingController], - providers: [SettingService], - exports: [SettingService], + providers: [SettingService, CommuneSettingsCacheService], + exports: [SettingService, CommuneSettingsCacheService], }) export class SettingModule {} diff --git a/src/modules/setting/setting.service.ts b/src/modules/setting/setting.service.ts index f4c5a3b..85b175f 100644 --- a/src/modules/setting/setting.service.ts +++ b/src/modules/setting/setting.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { Repository } from 'typeorm'; +import { Like, Repository } from 'typeorm'; +import { Revision } from '../api-depot/api-depot.types'; import { InjectRepository } from '@nestjs/typeorm'; import { Setting } from './setting.entity'; import { ApiDepotService } from '../api-depot/api-depot.service'; @@ -8,6 +9,7 @@ import { CommuneSettingsDTO } from './dto/commune-settings.dto'; import { CommuneStatusDTO } from './dto/commune-status.dto'; import { SourceService } from '../source/source.service'; import { EnabledListDTO } from './dto/enabled-list.dto'; +import { CommuneSettingsCacheService } from './commune-settings-cache.service'; const ObjectIdRE = new RegExp('^[0-9a-fA-F]{24}$'); @@ -18,66 +20,45 @@ export class SettingService { private readonly settingsRepository: Repository, private readonly apiDepotService: ApiDepotService, private readonly sourceService: SourceService, + private readonly communeSettingsCacheService: CommuneSettingsCacheService, ) {} getCommuneSettingsKey(codeCommune: string): string { return `${codeCommune}-settings`; } - async getCommuneStatus( - codeCommune: string, - sourceId: string, - ): Promise { - // Check source id - await this.sourceService.findOneOrFail(sourceId); - - // First check if the commune is in the disabled list - const setting = await this.settingsRepository.findOne({ - where: { name: this.getCommuneSettingsKey(codeCommune) }, - }); - - const communesSettings = setting?.content as CommuneSettingsDTO; - - if (communesSettings) { - if (communesSettings.disabled) { - return { - disabled: true, - message: - communesSettings.message || - 'La commune a demandé la désactivation du dépôt de signalements. Nous vous recommandons de contacter directement la mairie.', - }; - } else if (communesSettings.filteredSources?.includes(sourceId)) { - return { - disabled: true, - message: - communesSettings.message || - 'La commune a demandé la désactivation de cette source de signalements. Nous vous recommandons de contacter directement la mairie.', - }; - } else { - return { - disabled: false, - mode: communesSettings.mode || SignalementSubmissionMode.FULL, - }; + private determineCommuneStatus( + communeSettings: CommuneSettingsDTO | null, + revision: Revision | null, + moissonneurWhitelistContent: string[], + apiDepotClientWhitelistContent: string[], + ): { + disabled: boolean; + mode?: SignalementSubmissionMode; + filteredSources?: string[]; + } { + // Settings override takes priority + if (communeSettings) { + if (communeSettings.disabled) { + return { disabled: true }; } - } - - // Then get current revision to know how commune is published - const currentRevision = - await this.apiDepotService.getCurrentRevision(codeCommune); - - // If the commune is not published, signalement is disabled - if (!currentRevision) { return { - disabled: true, - message: - "Les signalements ne peuvent pas être proposés sur cette commune car elle n'a pas publié sa Base Adresse Locale. Nous vous recommandons de contacter directement la mairie.", + disabled: false, + mode: communeSettings.mode || SignalementSubmissionMode.FULL, + ...(communeSettings.filteredSources?.length && { + filteredSources: communeSettings.filteredSources, + }), }; } - // If commune is published via mes-adresses, signalement is enabled + if (!revision) { + return { disabled: true }; + } + + // Published via mes-adresses if ( - currentRevision?.context?.extras?.balId && - ObjectIdRE.test(currentRevision.context.extras.balId) + revision.context?.extras?.balId && + ObjectIdRE.test(revision.context.extras.balId) ) { return { disabled: false, @@ -85,62 +66,98 @@ export class SettingService { }; } - // If the commune is published via moissonneur, check if the source is in the white list - if (currentRevision?.context?.extras?.sourceId) { - const moissonneurSourceWhiteList = await this.settingsRepository.findOne({ - where: { name: EnabledListKeys.SOURCES_MOISSONNEUR_ENABLED }, - }); - - const moissonneurSourceWhiteListContent = - moissonneurSourceWhiteList.content as string[]; - - const isSourceEnabled = moissonneurSourceWhiteListContent.includes( - currentRevision.context.extras.sourceId, + // Published via moissonneur + if (revision.context?.extras?.sourceId) { + const isEnabled = moissonneurWhitelistContent.includes( + revision.context.extras.sourceId, ); + return { + disabled: !isEnabled, + ...(isEnabled && { mode: SignalementSubmissionMode.LIGHT }), + }; + } - if (isSourceEnabled) { - return { - disabled: false, - mode: SignalementSubmissionMode.LIGHT, - }; - } else { - return { - disabled: true, - message: - 'Cette commune ne gère pas encore la prise en compte des signalements depuis notre site. Nous vous recommandons de contacter directement la mairie.', - }; - } + // Published via API depot client + if (revision.client?.id) { + const isEnabled = apiDepotClientWhitelistContent.includes( + revision.client.id, + ); + return { + disabled: !isEnabled, + ...(isEnabled && { mode: SignalementSubmissionMode.LIGHT }), + }; } - // If the commune is published via API depot, check if the client is in the white list - if (currentRevision?.client?.id) { - const apiDepotClientEnabled = await this.settingsRepository.findOne({ - where: { name: EnabledListKeys.API_DEPOT_CLIENTS_ENABLED }, - }); + return { disabled: true }; + } - const apiDepotClientEnabledContent = - apiDepotClientEnabled.content as string[]; + async getCommuneStatus( + codeCommune: string, + sourceId: string, + ): Promise { + // Check source id + await this.sourceService.findOneOrFail(sourceId); - const isClientEnabled = apiDepotClientEnabledContent.includes( - currentRevision.client.id, - ); + const [ + setting, + currentRevision, + moissonneurWhitelist, + apiDepotClientWhitelist, + ] = await Promise.all([ + this.settingsRepository.findOne({ + where: { name: this.getCommuneSettingsKey(codeCommune) }, + }), + this.apiDepotService.getCurrentRevision(codeCommune), + this.settingsRepository.findOne({ + where: { name: EnabledListKeys.SOURCES_MOISSONNEUR_ENABLED }, + }), + this.settingsRepository.findOne({ + where: { name: EnabledListKeys.API_DEPOT_CLIENTS_ENABLED }, + }), + ]); + + const communeSettings = (setting?.content as CommuneSettingsDTO) || null; + const moissonneurWhitelistContent = + (moissonneurWhitelist?.content as string[]) || []; + const apiDepotClientWhitelistContent = + (apiDepotClientWhitelist?.content as string[]) || []; + + const status = this.determineCommuneStatus( + communeSettings, + currentRevision, + moissonneurWhitelistContent, + apiDepotClientWhitelistContent, + ); + + // Handle sourceId-specific filtering + if (!status.disabled && status.filteredSources?.includes(sourceId)) { + return { + disabled: true, + message: + communeSettings?.message || + 'La commune a demandé la désactivation de cette source de signalements. Nous vous recommandons de contacter directement la mairie.', + }; + } - if (isClientEnabled) { - return { - disabled: false, - mode: SignalementSubmissionMode.LIGHT, - }; + if (status.disabled) { + let message: string | undefined; + if (communeSettings?.disabled) { + message = + communeSettings.message || + 'La commune a demandé la désactivation du dépôt de signalements. Nous vous recommandons de contacter directement la mairie.'; + } else if (!currentRevision) { + message = + "Les signalements ne peuvent pas être proposés sur cette commune car elle n'a pas publié sa Base Adresse Locale. Nous vous recommandons de contacter directement la mairie."; } else { - return { - disabled: true, - message: - 'Cette commune ne gère pas encore la prise en compte des signalements depuis notre site. Nous vous recommandons de contacter directement la mairie.', - }; + message = + 'Cette commune ne gère pas encore la prise en compte des signalements depuis notre site. Nous vous recommandons de contacter directement la mairie.'; } + return { disabled: true, message }; } return { - disabled: true, + disabled: false, + mode: status.mode, }; } @@ -186,6 +203,8 @@ export class SettingService { await this.settingsRepository.save(setting); + this.communeSettingsCacheService.refreshCache(); + return setting.content as CommuneSettingsDTO; } @@ -229,6 +248,89 @@ export class SettingService { await this.settingsRepository.save(setting); } + this.communeSettingsCacheService.refreshCache(); + return updatedEnabledList; } + + async computeAllCommuneStatuses(): Promise< + Map< + string, + { + disabled: boolean; + mode?: SignalementSubmissionMode; + filteredSources?: string[]; + } + > + > { + const [ + allRevisions, + communeSettingsList, + moissonneurWhitelist, + apiDepotClientWhitelist, + ] = await Promise.all([ + this.apiDepotService.getAllCurrentRevisions(), + this.settingsRepository.find({ + where: { name: Like('%-settings') }, + }), + this.settingsRepository.findOne({ + where: { name: EnabledListKeys.SOURCES_MOISSONNEUR_ENABLED }, + }), + this.settingsRepository.findOne({ + where: { name: EnabledListKeys.API_DEPOT_CLIENTS_ENABLED }, + }), + ]); + + const communeSettingsMap = new Map(); + for (const setting of communeSettingsList) { + const codeCommune = setting.name.replace('-settings', ''); + communeSettingsMap.set( + codeCommune, + setting.content as CommuneSettingsDTO, + ); + } + + const moissonneurWhitelistContent = + (moissonneurWhitelist?.content as string[]) || []; + const apiDepotClientWhitelistContent = + (apiDepotClientWhitelist?.content as string[]) || []; + + const result = new Map< + string, + { + disabled: boolean; + mode?: SignalementSubmissionMode; + filteredSources?: string[]; + } + >(); + + // Index revisions by codeCommune + const revisionsByCommune = new Map(); + for (const revision of allRevisions) { + revisionsByCommune.set(revision.codeCommune, revision); + } + + // Process all communes that have either a revision or custom settings + const allCodeCommunes = new Set([ + ...revisionsByCommune.keys(), + ...communeSettingsMap.keys(), + ]); + + for (const codeCommune of allCodeCommunes) { + const communeSettings = communeSettingsMap.get(codeCommune) || null; + const revision = revisionsByCommune.get(codeCommune) || null; + + result.set( + codeCommune, + this.determineCommuneStatus( + communeSettings, + revision, + moissonneurWhitelistContent, + apiDepotClientWhitelistContent, + ), + ); + } + + return result; + } } diff --git a/src/modules/tiles/tiles.controller.ts b/src/modules/tiles/tiles.controller.ts index 3e8878d..26caeca 100644 --- a/src/modules/tiles/tiles.controller.ts +++ b/src/modules/tiles/tiles.controller.ts @@ -48,7 +48,7 @@ export class TilesController { enum: TilesLayerEnum, isArray: true, description: - 'Layers to include in the tiles (defaults to both alerts and signalements)', + 'Layers to include in the tiles (defaults to alerts and signalements)', example: [TilesLayerEnum.ALERTS, TilesLayerEnum.SIGNALEMENTS], }) @ApiResponse({ diff --git a/src/tests/alert.spec.ts b/src/tests/alert.spec.ts index 4ef6745..d7de73a 100644 --- a/src/tests/alert.spec.ts +++ b/src/tests/alert.spec.ts @@ -72,6 +72,7 @@ const mockAPIDepotService = { extras: { balId: '614b3385e1d1f2602d7ad284' }, }, }), + getAllCurrentRevisions: jest.fn().mockResolvedValue([]), }; @Module({ providers: [ diff --git a/src/tests/report.spec.ts b/src/tests/report.spec.ts index aa2b691..3a42618 100644 --- a/src/tests/report.spec.ts +++ b/src/tests/report.spec.ts @@ -38,6 +38,7 @@ const mockAPIDepotService = { extras: { balId: '614b3385e1d1f2602d7ad284' }, }, }), + getAllCurrentRevisions: jest.fn().mockResolvedValue([]), }; @Module({ providers: [ diff --git a/src/tests/signalement.spec.ts b/src/tests/signalement.spec.ts index 8c5ed80..706cc3c 100644 --- a/src/tests/signalement.spec.ts +++ b/src/tests/signalement.spec.ts @@ -87,6 +87,7 @@ const mockAPIDepotService = { extras: { balId: '614b3385e1d1f2602d7ad284' }, }, }), + getAllCurrentRevisions: jest.fn().mockResolvedValue([]), }; @Module({ providers: [