diff --git a/api/src/config/contacts.ts b/api/src/config/contacts.ts new file mode 100644 index 000000000..dd1aae865 --- /dev/null +++ b/api/src/config/contacts.ts @@ -0,0 +1,12 @@ +import { env_or_default } from "@/lib/env/index.ts"; + +export const support = { + email: env_or_default( + "APP_CONTACT_SUPPORT_EMAIL", + "technique@covoiturage.beta.gouv.fr", + ), + fullname: env_or_default( + "APP_CONTACT_SUPPORT_FULLNAME", + "Equipe technique covoiturage", + ), +}; diff --git a/api/src/ilos/connection-postgres/PostgresConnection.ts b/api/src/ilos/connection-postgres/PostgresConnection.ts index accb05e47..2a4bfa47f 100644 --- a/api/src/ilos/connection-postgres/PostgresConnection.ts +++ b/api/src/ilos/connection-postgres/PostgresConnection.ts @@ -42,7 +42,7 @@ export class PgPool extends Pool { this.on("remove", () => { const { totalCount, idleCount, waitingCount } = this; - logger.info( + logger.debug( `[pg] client removed ` + `(total: ${totalCount}, idle: ${idleCount}, waiting: ${waitingCount})`, ); diff --git a/api/src/pdc/middlewares/DefaultTimezoneMiddleware.ts b/api/src/pdc/middlewares/DefaultTimezoneMiddleware.ts new file mode 100644 index 000000000..7a5b89f38 --- /dev/null +++ b/api/src/pdc/middlewares/DefaultTimezoneMiddleware.ts @@ -0,0 +1,23 @@ +import { defaultTimezone } from "@/config/time.ts"; +import { NextFunction } from "@/deps.ts"; +import { ContextType, middleware } from "@/ilos/common/index.ts"; +import { Timezone } from "@/pdc/providers/validator/types.ts"; + +/** + * Set the params.tz property to the default time zone + * if it is not already set. + */ +@middleware() +export class DefaultTimezoneMiddleware { + async process( + params: TParams, + context: ContextType, + next: NextFunction, + ): Promise { + if (!params.tz) { + params.tz = defaultTimezone; + } + + return next(params, context); + } +} diff --git a/api/src/pdc/services/export/middlewares/ScopeToGroupMiddleware.integration.spec.ts b/api/src/pdc/middlewares/ScopeToGroupMiddleware.integration.spec.ts similarity index 100% rename from api/src/pdc/services/export/middlewares/ScopeToGroupMiddleware.integration.spec.ts rename to api/src/pdc/middlewares/ScopeToGroupMiddleware.integration.spec.ts diff --git a/api/src/pdc/services/export/middlewares/ScopeToGroupMiddleware.ts b/api/src/pdc/middlewares/ScopeToGroupMiddleware.ts similarity index 100% rename from api/src/pdc/services/export/middlewares/ScopeToGroupMiddleware.ts rename to api/src/pdc/middlewares/ScopeToGroupMiddleware.ts diff --git a/api/src/pdc/services/export/middlewares/groupPermissionMiddlewaresHelper.ts b/api/src/pdc/middlewares/groupPermissionMiddlewaresHelper.ts similarity index 100% rename from api/src/pdc/services/export/middlewares/groupPermissionMiddlewaresHelper.ts rename to api/src/pdc/middlewares/groupPermissionMiddlewaresHelper.ts diff --git a/api/src/pdc/providers/middleware/Cast/CastToArrayMiddleware.ts b/api/src/pdc/providers/middleware/Cast/CastToArrayMiddleware.ts index daf308493..7752fc05d 100644 --- a/api/src/pdc/providers/middleware/Cast/CastToArrayMiddleware.ts +++ b/api/src/pdc/providers/middleware/Cast/CastToArrayMiddleware.ts @@ -9,13 +9,6 @@ import { import { get, set } from "@/lib/object/index.ts"; import { ConfiguredMiddleware } from "../interfaces.ts"; -/* - * CastToArrayMiddleware middleware and its companion helper function - * castToArrayMiddleware are used to cast one or many properties - * to an array. - * - * Not found or undefined props are skipped. - */ @middleware() export class CastToArrayMiddleware implements MiddlewareInterface { async process( @@ -53,6 +46,16 @@ const alias = "cast.to_array"; export const castToArrayMiddlewareBinding = [alias, CastToArrayMiddleware]; +/** + * Cast one or many properties to an array. Not found or undefined props are skipped. + * + * @param properties - single or array of multiple properties to cast to an array + * + * @example + * middlewares: [ + * castToArrayMiddleware(["operator_id", "territory_id", "recipients"]), + * ], + */ export function castToArrayMiddleware( properties: HelperArgs, ): ConfiguredMiddleware { diff --git a/api/src/pdc/providers/middleware/CopyFromContext/CopyFromContextMiddleware.ts b/api/src/pdc/providers/middleware/CopyFromContext/CopyFromContextMiddleware.ts index 2071e5326..36e120d2d 100644 --- a/api/src/pdc/providers/middleware/CopyFromContext/CopyFromContextMiddleware.ts +++ b/api/src/pdc/providers/middleware/CopyFromContext/CopyFromContextMiddleware.ts @@ -45,6 +45,27 @@ export const copyFromContextMiddlewareBinding = [ CopyFromContextMiddleware, ]; +/** + * Copy context data to request params + + * + * @param fromPath - the path in the context to copy from + * @param toPath - the path in the request params to copy to + * @param preserve - whether to preserve the existing value + * in the request params or override it + * + * @example + * middlewares: [ + * // override the operator_id to scope the request to the owner if it is + * // an operator. + * copyFromContextMiddleware("call.user.operator_id", "operator_id", false), + * + * // copy the user id to the created_by field only if it is not already set + * // in the request params. + * copyFromContextMiddleware("call.user._id", "created_by", true), + * ], + * + */ export function copyFromContextMiddleware( fromPath: string, toPath: string, diff --git a/api/src/pdc/providers/middleware/HasPermission/HasPermissionMiddleware.ts b/api/src/pdc/providers/middleware/HasPermission/HasPermissionMiddleware.ts index e2d86385c..a1b2248af 100644 --- a/api/src/pdc/providers/middleware/HasPermission/HasPermissionMiddleware.ts +++ b/api/src/pdc/providers/middleware/HasPermission/HasPermissionMiddleware.ts @@ -1,3 +1,4 @@ +import { NextFunction } from "@/deps.ts"; import { ContextType, ForbiddenException, @@ -19,7 +20,7 @@ export class HasPermissionMiddleware async process( params: ParamsType, context: ContextType, - next: Function, + next: NextFunction, neededPermissions: HasPermissionMiddlewareParams, ): Promise { if (!Array.isArray(neededPermissions) || neededPermissions.length === 0) { @@ -51,6 +52,14 @@ const alias = "has_permission"; export const hasPermissionMiddlewareBinding = [alias, HasPermissionMiddleware]; +/** + * Define allowed permissions to access the handler. + * + * User's permissions are extracted from context.user.permissions. The list + * must contain permissions from the middleware configuration. + * + * @param params - list of allowed permissions + */ export function hasPermissionMiddleware( ...params: HasPermissionMiddlewareParams ): ConfiguredMiddleware { diff --git a/api/src/pdc/providers/middleware/ValidateDate/ValidateDateMiddleware.ts b/api/src/pdc/providers/middleware/ValidateDate/ValidateDateMiddleware.ts index ec85c58db..fe646a1e6 100644 --- a/api/src/pdc/providers/middleware/ValidateDate/ValidateDateMiddleware.ts +++ b/api/src/pdc/providers/middleware/ValidateDate/ValidateDateMiddleware.ts @@ -1,4 +1,4 @@ -import { endOfDay, startOfDay } from "@/deps.ts"; +import { endOfDay, NextFunction, startOfDay } from "@/deps.ts"; import { ContextType, InvalidParamsException, @@ -19,7 +19,7 @@ export class ValidateDateMiddleware async process( params: ParamsType, context: ContextType, - next: Function, + next: NextFunction, options: ValidateDateMiddlewareParams, ): Promise { if ( @@ -92,6 +92,32 @@ const alias = "validate.date"; export const validateDateMiddlewareBinding = [alias, ValidateDateMiddleware]; +/** + * Validate start and end date inputs. Make sure start is before end. + * Apply limits and defaults. + * + * @param startPath - Path to the start date in the params object + * @param endPath - Path to the end date in the params object + * @param minStart - Minimum start date. If the start date is before this date, an error is thrown + * @param maxEnd - Maximum end date. If the end date is after this date, an error is thrown + * @param applyDefault - If true, set the start date to minStart and end date to maxEnd if they are not provided + * + * @example + * middlewares: [ + * validateDateMiddleware({ startPath: "start_at", endPath: "end_at" }), + * ], + * + * @example + * middlewares: [ + * validateDateMiddleware({ + * startPath: "start_at", + * endPath: "end_at", + * minStart: () => new Date(new Date().getTime() - minStartDefault), + * maxEnd: () => new Date(new Date().getTime() - maxEndDefault), + * applyDefault: true, + * }), + * ], + */ export function validateDateMiddleware( params: ValidateDateMiddlewareParams, ): ConfiguredMiddleware { diff --git a/api/src/pdc/providers/notification/NotificationMailTransporter.ts b/api/src/pdc/providers/notification/NotificationMailTransporter.ts index 08ae4ed5c..6f6e07ba9 100644 --- a/api/src/pdc/providers/notification/NotificationMailTransporter.ts +++ b/api/src/pdc/providers/notification/NotificationMailTransporter.ts @@ -29,8 +29,8 @@ export class NotificationMailTransporter MailTemplateNotificationInterface, Partial > { - transporter: mailer.Transporter; - protected options: NotificationOptions; + transporter: mailer.Transporter | null = null; + protected options: NotificationOptions = {} as NotificationOptions; constructor( protected config: ConfigInterfaceResolver, @@ -51,29 +51,28 @@ export class NotificationMailTransporter } protected setOptionsFromConfig(): void { + const fromFullname = this.config.get("notification.mail.from.name"); + const fromEmail = this.config.get("notification.mail.from.email"); + const toFullname = this.config.get("notification.mail.to.name"); + const toEmail = this.config.get("notification.mail.to.email"); + const debug = this.config.get("notification.mail.debug", false); + this.options = { - from: `${this.config.get("notification.mail.from.name")} <${ - this.config.get("notification.mail.from.email") - }>`, - debug: this.config.get("notification.mail.debug", false), - debugToOverride: `${this.config.get("notification.mail.to.name")} <${ - this.config.get( - "notification.mail.to.email", - ) - }>`, + from: `${fromFullname} <${fromEmail}>`, + debugToOverride: `${toFullname} <${toEmail}>`, + debug, }; } protected async createTransport(verify = false): Promise { if (!this.transporter) { - this.transporter = mailer.createTransport( - this.config.get("notification.mail.smtp"), - ); + const smtp = this.config.get("notification.mail.smtp"); + this.transporter = mailer.createTransport(smtp); if (verify) { try { await this.transporter.verify(); } catch (e) { - logger.error("Failed to connect to SMTP server"); + logger.error("Failed to connect to SMTP server", e.message); exit(1); } } @@ -85,6 +84,12 @@ export class NotificationMailTransporter return !mjml ? result : mjml2html(result).html; } + protected moustache(str: string, data: Record): string { + return str.replace(/\{\{([^}]+)\}\}/g, (_, key) => { + return data[key.trim()] as string; + }); + } + async send( mail: MailTemplateNotificationInterface, options = {}, @@ -92,7 +97,25 @@ export class NotificationMailTransporter const mailCtor = mail .constructor as StaticMailTemplateNotificationInterface; - await this.transporter.sendMail({ + if ( + "message_html" in mail.data && typeof mail.data.message_html === "string" + ) { + mail.data.message_html = this.moustache( + mail.data.message_html, + mail.data, + ); + } + + if ( + "message_text" in mail.data && typeof mail.data.message_text === "string" + ) { + mail.data.message_text = this.moustache( + mail.data.message_text, + mail.data, + ); + } + + this.transporter && await this.transporter.sendMail({ ...options, from: this.options.from, to: this.options.debug ? this.options.debugToOverride : mail.to, @@ -102,7 +125,5 @@ export class NotificationMailTransporter : undefined, text: this.render(new mailCtor.templateText(mail.data)), }); - - return; } } diff --git a/api/src/pdc/services/apdf/commands/ExportCommand.ts b/api/src/pdc/services/apdf/commands/ExportCommand.ts index a548ac330..71147b625 100644 --- a/api/src/pdc/services/apdf/commands/ExportCommand.ts +++ b/api/src/pdc/services/apdf/commands/ExportCommand.ts @@ -61,7 +61,7 @@ export class ExportCommand implements CommandInterface { }, { signature: "--verbose", - description: "Display CLI specific console.info()", + description: "Display CLI specific logger.info()", }, ]; diff --git a/api/src/pdc/services/application/config/permissions.ts b/api/src/pdc/services/application/config/permissions.ts index 138271765..17d4dec1e 100644 --- a/api/src/pdc/services/application/config/permissions.ts +++ b/api/src/pdc/services/application/config/permissions.ts @@ -1,3 +1,3 @@ -import { operator } from "@/shared/user/permissions.config.ts"; +import { operator } from "@/pdc/services/user/config/permissions.ts"; export const application = [...operator.application.permissions]; diff --git a/api/src/pdc/services/export/ServiceProvider.ts b/api/src/pdc/services/export/ServiceProvider.ts index 9d3ff5328..43c01933a 100644 --- a/api/src/pdc/services/export/ServiceProvider.ts +++ b/api/src/pdc/services/export/ServiceProvider.ts @@ -5,6 +5,7 @@ import { serviceProvider, } from "@/ilos/common/index.ts"; import { ServiceProvider as AbstractServiceProvider } from "@/ilos/core/index.ts"; +import { DefaultTimezoneMiddleware } from "@/pdc/middlewares/DefaultTimezoneMiddleware.ts"; import { defaultMiddlewareBindings } from "@/pdc/providers/middleware/index.ts"; import { S3StorageProvider } from "@/pdc/providers/storage/index.ts"; import { @@ -21,7 +22,6 @@ import { CreateCommand } from "./commands/CreateCommand.ts"; import { DebugCommand } from "./commands/DebugCommand.ts"; import { ProcessCommand } from "./commands/ProcessCommand.ts"; import { config } from "./config/index.ts"; -import { DefaultTimezoneMiddleware } from "./middlewares/DefaultTimezoneMiddleware.ts"; import { CampaignRepository } from "./repositories/CampaignRepository.ts"; import { CarpoolRepository } from "./repositories/CarpoolRepository.ts"; import { ExportRepository } from "./repositories/ExportRepository.ts"; diff --git a/api/src/pdc/services/export/actions/CreateAction.integration.spec.ts b/api/src/pdc/services/export/actions/CreateAction.integration.spec.ts index 4f76ca1d7..7e7e01eb8 100644 --- a/api/src/pdc/services/export/actions/CreateAction.integration.spec.ts +++ b/api/src/pdc/services/export/actions/CreateAction.integration.spec.ts @@ -13,9 +13,12 @@ * - store: check response * - store: check existence in DB */ +import { faker } from "@/deps.ts"; import { afterAll, assertEquals, beforeAll, describe, it } from "@/dev_deps.ts"; import { ContextType } from "@/ilos/common/index.ts"; import { PostgresConnection } from "@/ilos/connection-postgres/index.ts"; +import { set } from "@/lib/object/index.ts"; +import { User, users } from "@/pdc/providers/seed/users.ts"; import { AJVParamsInterface, assertHandler, @@ -26,9 +29,11 @@ import { } from "@/pdc/providers/test/index.ts"; import { ServiceProvider as ExportSP } from "@/pdc/services/export/ServiceProvider.ts"; import { + Export, ExportStatus, ExportTarget, } from "@/pdc/services/export/models/Export.ts"; +import { ExportParams } from "@/pdc/services/export/models/ExportParams.ts"; import { ServiceProvider as UserSP } from "@/pdc/services/user/ServiceProvider.ts"; import { handlerConfigV3, @@ -42,19 +47,56 @@ const { before: kernelBefore, after: kernelAfter } = makeKernelBeforeAfter( ); const { before: dbBefore, after: dbAfter } = makeDbBeforeAfter(); +/** + * Simple Export fetcher to get all records and cast their values + */ +type FullExport = Export & { + recipients: Array<{ email: string; fullname: string; message: string }>; +}; +function fetcher(db: DbContext) { + return async (): Promise => { + const res = await db.connection.getClient().query(` + SELECT + ee.*, + array_agg(json_build_object( + 'email', er.email, + 'fullname', er.fullname, + 'message', er.message + )) as recipients + FROM export.exports ee + JOIN export.recipients er ON ee._id = er.export_id + GROUP BY ee._id + ORDER BY ee._id ASC + `); + + return res.rowCount + ? res.rows.map((r) => ({ ...r, params: ExportParams.fromJSON(r.params) })) + : []; + }; +} + describe("CreateAction V3", () => { + // --------------------------------------------------------------------------- + // SETUP + // --------------------------------------------------------------------------- + let db: DbContext; let kc: KernelContext; + let fetchExports: () => Promise; const defaultContext: ContextType = { call: { user: { permissions: ["common.export.create"] } }, channel: { service: "proxy" }, }; + type UserWithId = User & { _id: number }; + const adminUser: UserWithId = { _id: 1, ...users[0] }; + /** * - boot up postgresql connection * - create the kernel * - stop the existing kernel connection to replace it with the test one + * - setup the db macro with the connection */ beforeAll(async () => { db = await dbBefore(); @@ -64,6 +106,7 @@ describe("CreateAction V3", () => { .getContainer() .rebind(PostgresConnection) .toConstantValue(db.connection); + fetchExports = fetcher(db); }); afterAll(async () => { @@ -71,6 +114,10 @@ describe("CreateAction V3", () => { await dbAfter(db); }); + // --------------------------------------------------------------------------- + // TESTS + // --------------------------------------------------------------------------- + it("should create an export with a creator as recipient", async () => { const start_at = "2024-01-01T00:00:00+0100"; const end_at = "2024-01-02T00:00:00+0100"; @@ -82,7 +129,7 @@ describe("CreateAction V3", () => { tz: "Europe/Paris", start_at, end_at, - created_by: 1, + created_by: adminUser._id, operator_id: [1], }; @@ -100,17 +147,172 @@ describe("CreateAction V3", () => { defaultContext, handlerConfigV3, params, - (response: ResultInterfaceV3) => { + async (response: ResultInterfaceV3) => { + // assert the response const { uuid: _uuid, ...actual } = response; assertEquals(actual, expected); + + // assert the database record + const last = (await fetchExports()).pop(); + assertEquals(last?.target, ExportTarget.OPENDATA); + assertEquals(last?.status, ExportStatus.PENDING); + assertEquals(last?.progress, 0); + assertEquals(last?.params.get().start_at, new Date(params.start_at)); + assertEquals(last?.error, null); + assertEquals(last?.recipients[0].email, adminUser.email); + }, + ); + }); + + it("should create an export with multiple recipients", async () => { + const recipients: string[] = [ + faker.internet.email(), + faker.internet.email(), + faker.internet.email(), + ]; + + const params: AJVParamsInterface< + ParamsInterfaceV3, + "start_at" | "end_at" + > = { + tz: "Europe/Paris", + start_at: "2024-01-01T00:00:00+0100", + end_at: "2024-01-02T00:00:00+0100", + created_by: adminUser._id, + operator_id: [1], + recipients, + }; + + await assertHandler( + kc, + defaultContext, + handlerConfigV3, + params, + async () => { + const last = (await fetchExports()).pop(); + assertEquals(last?.recipients[0].email, recipients[0]); + assertEquals(last?.recipients[1].email, recipients[1]); + assertEquals(last?.recipients[2].email, recipients[2]); + }, + ); + }); + + it("should fallback to created_by on empty recipients", async () => { + const recipients: string[] = []; + + const params: AJVParamsInterface< + ParamsInterfaceV3, + "start_at" | "end_at" + > = { + tz: "Europe/Paris", + start_at: "2024-01-01T00:00:00+0100", + end_at: "2024-01-02T00:00:00+0100", + created_by: adminUser._id, + operator_id: [1], + recipients, + }; + + await assertHandler( + kc, + defaultContext, + handlerConfigV3, + params, + async () => { + const last = (await fetchExports()).pop(); + assertEquals(last?.recipients[0].email, adminUser.email); + }, + ); + }); + + it("should create a default export (opendata)", async () => { + const params: AJVParamsInterface< + ParamsInterfaceV3, + "start_at" | "end_at" + > = { + tz: "Europe/Paris", + start_at: "2024-01-01T00:00:00+0100", + end_at: "2024-01-02T00:00:00+0100", + created_by: adminUser._id, + operator_id: [1], + }; + + await assertHandler( + kc, + defaultContext, + handlerConfigV3, + params, + (response: ResultInterfaceV3) => { + assertEquals(response.target, ExportTarget.OPENDATA); + }, + ); + }); + + it("should create a territory export", async () => { + const params: AJVParamsInterface< + ParamsInterfaceV3, + "start_at" | "end_at" + > = { + tz: "Europe/Paris", + start_at: "2024-01-01T00:00:00+0100", + end_at: "2024-01-02T00:00:00+0100", + created_by: adminUser._id, + operator_id: [1], + }; + + await assertHandler( + kc, + set(defaultContext, "call.user.territory_id", 1), + handlerConfigV3, + params, + async (response: ResultInterfaceV3) => { + // assert response + assertEquals(response.target, ExportTarget.TERRITORY); + + // assert database record + const last = (await fetchExports()).pop(); + assertEquals(last?.target, ExportTarget.TERRITORY); + + // TODO + // assert territory_id has been injected and wrapped in an array ? + + // TODO: resolve the geo_selector from the territory_id + // and inject it in the CreateActionV3 + // assertEquals(last?.params.get().geo_selector, { aom: ["TODO"] }); }, ); }); - it("should create an export with multiple recipients", () => {}); - it("should create a default export (opendata)", () => {}); - it("should create a territory export", () => {}); - it("should create an operator export", () => {}); + it("should create an operator export", async () => { + const params: AJVParamsInterface< + ParamsInterfaceV3, + "start_at" | "end_at" + > = { + tz: "Europe/Paris", + start_at: "2024-01-01T00:00:00+0100", + end_at: "2024-01-02T00:00:00+0100", + created_by: adminUser._id, + operator_id: [1], + }; + + await assertHandler( + kc, + set(defaultContext, "call.user.operator_id", 2), + handlerConfigV3, + params, + async (response: ResultInterfaceV3) => { + // assert response + assertEquals(response.target, ExportTarget.OPERATOR); + + // assert database record + const last = (await fetchExports()).pop(); + assertEquals(last?.target, ExportTarget.OPERATOR); + + // assert that operator_id has been replaced by the operator's id + // from the context (2). + assertEquals(last?.params.get().operator_id, [2]); + }, + ); + }); }); describe("CreateAction V2", () => {}); diff --git a/api/src/pdc/services/export/actions/CreateActionV2.ts b/api/src/pdc/services/export/actions/CreateActionV2.ts index f4ecc20ed..928e15601 100644 --- a/api/src/pdc/services/export/actions/CreateActionV2.ts +++ b/api/src/pdc/services/export/actions/CreateActionV2.ts @@ -2,8 +2,10 @@ import { defaultTimezone } from "@/config/time.ts"; import { handler } from "@/ilos/common/Decorators.ts"; import { ContextType, KernelInterfaceResolver } from "@/ilos/common/index.ts"; import { Action as AbstractAction } from "@/ilos/core/index.ts"; +import { get } from "@/lib/object/index.ts"; +import { toISOString } from "@/pdc/helpers/dates.helper.ts"; +import { DefaultTimezoneMiddleware } from "@/pdc/middlewares/DefaultTimezoneMiddleware.ts"; import { copyFromContextMiddleware } from "@/pdc/providers/middleware/middlewares.ts"; -import { toISOString } from "@/pdc/services/export/helpers/index.ts"; import { handlerConfigV2, ParamsInterfaceV2, @@ -13,7 +15,6 @@ import { signatureV3, } from "@/shared/export/create.contract.ts"; import { aliasV2 } from "@/shared/export/create.schema.ts"; -import { DefaultTimezoneMiddleware } from "../middlewares/DefaultTimezoneMiddleware.ts"; /** * @deprecated @@ -23,8 +24,8 @@ import { DefaultTimezoneMiddleware } from "../middlewares/DefaultTimezoneMiddlew middlewares: [ ["validate", aliasV2], ["timezone", DefaultTimezoneMiddleware], - copyFromContextMiddleware(`call.user.operator_id`, "operator_id", true), - copyFromContextMiddleware(`call.user.territory_id`, "territory_id", true), + copyFromContextMiddleware(`call.user.operator_id`, "operator_id", false), + copyFromContextMiddleware(`call.user.territory_id`, "territory_id", false), ], }) export class CreateActionV2 extends AbstractAction { @@ -48,7 +49,7 @@ export class CreateActionV2 extends AbstractAction { start_at: toISOString(paramsV2.date.start), end_at: toISOString(paramsV2.date.end), operator_id: paramsV2.operator_id || [], - created_by: context.call.user._id, + created_by: get(context, "call.user._id", null), }; if (paramsV2.geo_selector) { diff --git a/api/src/pdc/services/export/actions/CreateActionV3.ts b/api/src/pdc/services/export/actions/CreateActionV3.ts index c01ad0930..e3ca5e45d 100644 --- a/api/src/pdc/services/export/actions/CreateActionV3.ts +++ b/api/src/pdc/services/export/actions/CreateActionV3.ts @@ -10,13 +10,13 @@ import { hasPermissionMiddleware, validateDateMiddleware, } from "@/pdc/providers/middleware/middlewares.ts"; -import { DefaultTimezoneMiddleware } from "@/pdc/services/export/middlewares/DefaultTimezoneMiddleware.ts"; import { handlerConfigV3, ParamsInterfaceV3, ResultInterfaceV3, } from "@/shared/export/create.contract.ts"; import { aliasV3 } from "@/shared/export/create.schema.ts"; +import { DefaultTimezoneMiddleware } from "@/pdc/middlewares/DefaultTimezoneMiddleware.ts"; import { maxEndDefault, minStartDefault } from "../config/export.ts"; import { Export } from "../models/Export.ts"; import { ExportParams } from "../models/ExportParams.ts"; @@ -29,11 +29,11 @@ import { TerritoryServiceInterfaceResolver } from "../services/TerritoryService. ...handlerConfigV3, middlewares: [ hasPermissionMiddleware("common.export.create"), - castToArrayMiddleware(["operator_id", "territory_id", "recipients"]), ["timezone", DefaultTimezoneMiddleware], copyFromContextMiddleware(`call.user._id`, "created_by", true), - copyFromContextMiddleware(`call.user.operator_id`, "operator_id", true), - copyFromContextMiddleware(`call.user.territory_id`, "territory_id", true), + copyFromContextMiddleware(`call.user.operator_id`, "operator_id", false), + copyFromContextMiddleware(`call.user.territory_id`, "territory_id", false), + castToArrayMiddleware(["operator_id", "territory_id", "recipients"]), validateDateMiddleware({ startPath: "start_at", endPath: "end_at", diff --git a/api/src/pdc/services/export/commands/ProcessCommand.ts b/api/src/pdc/services/export/commands/ProcessCommand.ts index 69edd20b3..2cceab05b 100644 --- a/api/src/pdc/services/export/commands/ProcessCommand.ts +++ b/api/src/pdc/services/export/commands/ProcessCommand.ts @@ -4,6 +4,8 @@ import { CommandOptionType, } from "@/ilos/common/index.ts"; import { getPerformanceTimer, logger } from "@/lib/logger/index.ts"; +import { NotificationService } from "@/pdc/services/export/services/NotificationService.ts"; +import { StorageService } from "@/pdc/services/export/services/StorageService.ts"; import { Export, ExportStatus } from "../models/Export.ts"; import { XLSXWriter } from "../models/XLSXWriter.ts"; import { ExportRepositoryInterfaceResolver } from "../repositories/ExportRepository.ts"; @@ -12,8 +14,6 @@ import { FileCreatorServiceInterfaceResolver } from "../services/FileCreatorServ import { LogServiceInterfaceResolver } from "../services/LogService.ts"; import { NameServiceInterfaceResolver } from "../services/NameService.ts"; -export type Options = {}; - @command() export class ProcessCommand implements CommandInterface { static readonly signature: string = "export:process"; @@ -26,9 +26,13 @@ export class ProcessCommand implements CommandInterface { protected fieldService: FieldServiceInterfaceResolver, protected nameService: NameServiceInterfaceResolver, protected logger: LogServiceInterfaceResolver, + protected storage: StorageService, + protected notify: NotificationService, ) {} - public async call(options: Options): Promise { + public async call(): Promise { + await this.storage.init(); + let counter = 50; // process pending exports until there are no more @@ -51,18 +55,36 @@ export class ProcessCommand implements CommandInterface { try { const timer = getPerformanceTimer(); - await this.exportRepository.status(_id, ExportStatus.RUNNING); - await this.fileCreatorService.write( + + // generate the file + const filepath = await this.fileCreatorService.write( params, new XLSXWriter(filename, { fields }), await this.exportRepository.progress(_id), ); - await this.exportRepository.status(_id, ExportStatus.SUCCESS); - logger.info(`Export finished processing ${uuid} in ${timer.stop()} ms`); + // upload to storage + await this.exportRepository.status(_id, ExportStatus.UPLOADING); + + const key = await this.storage.upload(filepath); + const url = await this.storage.getPublicUrl(key); + + await this.exportRepository.status(_id, ExportStatus.UPLOADED); + + // notify the user + await this.exportRepository.status(_id, ExportStatus.NOTIFY); + await this.notify.success(exp, url); + + // :tada: + await this.exportRepository.status(_id, ExportStatus.SUCCESS); + logger.info( + `Export finished processing ${uuid} in ${timer.stop()} ms`, + ); } catch (e) { await this.exportRepository.error(_id, e.message); + await this.notify.error(exp); + await this.notify.support(exp); } } } diff --git a/api/src/pdc/services/export/helpers/index.ts b/api/src/pdc/services/export/helpers/index.ts deleted file mode 100644 index 31d0155e8..000000000 --- a/api/src/pdc/services/export/helpers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@/pdc/helpers/dates.helper.ts"; diff --git a/api/src/pdc/services/export/middlewares/DefaultTimezoneMiddleware.ts b/api/src/pdc/services/export/middlewares/DefaultTimezoneMiddleware.ts deleted file mode 100644 index e3ca5acae..000000000 --- a/api/src/pdc/services/export/middlewares/DefaultTimezoneMiddleware.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextFunction } from "@/deps.ts"; -import { ContextType, middleware } from "@/ilos/common/index.ts"; -import { ParamsInterfaceV3 } from "@/shared/export/create.contract.ts"; - -@middleware() -export class DefaultTimezoneMiddleware { - async process( - params: ParamsInterfaceV3, - context: ContextType, - next: NextFunction, - ): Promise { - if (!params.tz) { - params.tz = "Europe/Paris"; - } - - return next(params, context); - } -} diff --git a/api/src/pdc/services/export/models/Campaign.ts b/api/src/pdc/services/export/models/Campaign.ts index e3552403a..bcc2c8687 100644 --- a/api/src/pdc/services/export/models/Campaign.ts +++ b/api/src/pdc/services/export/models/Campaign.ts @@ -1,8 +1,8 @@ import { ConfigInterfaceResolver } from "@/ilos/common/index.ts"; import { get } from "@/lib/object/index.ts"; +import { toTzString } from "@/pdc/helpers/dates.helper.ts"; import { Timezone } from "@/pdc/providers/validator/index.ts"; import { SingleResultInterface as RawCampaignInterface } from "@/shared/policy/list.contract.ts"; -import { toTzString } from "../helpers/index.ts"; export enum CampaignMode { Normal = "normal", diff --git a/api/src/pdc/services/export/models/Export.ts b/api/src/pdc/services/export/models/Export.ts index 909840cb1..835180b56 100644 --- a/api/src/pdc/services/export/models/Export.ts +++ b/api/src/pdc/services/export/models/Export.ts @@ -4,6 +4,9 @@ import { ExportParams } from "./ExportParams.ts"; export enum ExportStatus { PENDING = "pending", RUNNING = "running", + UPLOADING = "uploading", + UPLOADED = "uploaded", + NOTIFY = "notify", SUCCESS = "success", FAILURE = "failure", } @@ -13,6 +16,9 @@ export enum ExportTarget { TERRITORY = "territory", } +export type ExportError = string; +export type ExportStats = string; + export class Export { public _id: number; public uuid: string; @@ -23,8 +29,8 @@ export class Export { public download_url_expire_at: Date; public download_url: string; public params: ExportParams; - public error: string; // JSON object - public stats: string; // JSON object + public error: ExportError; // JSON object + public stats: ExportStats; // JSON object public static fromJSON(data: any): Export { const export_ = new Export(); diff --git a/api/src/pdc/services/export/models/ExportParams.ts b/api/src/pdc/services/export/models/ExportParams.ts index ee7f11812..85912893d 100644 --- a/api/src/pdc/services/export/models/ExportParams.ts +++ b/api/src/pdc/services/export/models/ExportParams.ts @@ -1,6 +1,6 @@ +import { subMonthsTz, today } from "@/pdc/helpers/dates.helper.ts"; import { Timezone } from "@/pdc/providers/validator/index.ts"; import { TerritorySelectorsInterface } from "@/shared/territory/common/interfaces/TerritoryCodeInterface.ts"; -import { subMonthsTz, today } from "../helpers/index.ts"; export type Config = Partial; @@ -47,7 +47,10 @@ export class ExportParams { * @returns {Params} */ protected normalize(config: Config): Params { - return { ...this.defaultConfig, ...config }; + const n = { ...this.defaultConfig, ...config }; + n.start_at = new Date(n.start_at); + n.end_at = new Date(n.end_at); + return n; } /** diff --git a/api/src/pdc/services/export/repositories/ExportRepository.ts b/api/src/pdc/services/export/repositories/ExportRepository.ts index f7b0bdd04..7f72dd8a4 100644 --- a/api/src/pdc/services/export/repositories/ExportRepository.ts +++ b/api/src/pdc/services/export/repositories/ExportRepository.ts @@ -23,6 +23,7 @@ export interface ExportRepositoryInterface { error(id: number, error: string): Promise; progress(id: number): Promise; pickPending(): Promise; + recipients(id: number): Promise; addRecipient(export_id: number, recipient: ExportRecipient): Promise; } @@ -145,6 +146,15 @@ export abstract class ExportRepositoryInterfaceResolver throw new Error("Not implemented"); } + /** + * Get the recipients of an export + * + * @param id Export _id + */ + public async recipients(id: number): Promise { + throw new Error("Not implemented"); + } + /** * Add a recipient to an export * @@ -291,6 +301,15 @@ export class ExportRepository implements ExportRepositoryInterface { return rowCount ? Export.fromJSON(rows[0]) : null; } + public async recipients(id: number): Promise { + const { rows } = await this.connection.getClient().query({ + text: `SELECT * FROM ${this.recipientsTable} WHERE export_id = $1`, + values: [id], + }); + + return rows.map(ExportRecipient.fromJSON); + } + public async addRecipient( export_id: number, recipient: ExportRecipient, diff --git a/api/src/pdc/services/export/services/FileCreatorService.ts b/api/src/pdc/services/export/services/FileCreatorService.ts index 8fc27c786..2264c71c7 100644 --- a/api/src/pdc/services/export/services/FileCreatorService.ts +++ b/api/src/pdc/services/export/services/FileCreatorService.ts @@ -11,7 +11,7 @@ export type FileCreatorServiceInterface = { params: ExportParams, fileWriter: XLSXWriter, progress?: ExportProgress, - ): Promise; + ): Promise; }; export abstract class FileCreatorServiceInterfaceResolver @@ -31,14 +31,11 @@ export abstract class FileCreatorServiceInterfaceResolver protected async help(): Promise { throw new Error("Not implemented"); } - protected async wrap(): Promise { - throw new Error("Not implemented"); - } public async write( params: ExportParams, fileWriter: XLSXWriter, progress?: ExportProgress, - ): Promise { + ): Promise { throw new Error("Not implemented"); } } @@ -90,26 +87,26 @@ export class FileCreatorService { await this.fileWriter.printHelp(); } - protected async wrap(e?: Error): Promise { - await this.fileWriter.close(); - if (e) throw e; - await this.fileWriter.compress(); - } - public async write( params: ExportParams, fileWriter: XLSXWriter, progress?: ExportProgress, - ): Promise { + ): Promise { try { await this.configure(params, fileWriter, progress); await this.initialize(); await this.data(); await this.help(); - await this.wrap(); + await this.fileWriter.close(); + await this.fileWriter.compress(); + logger.info(`File written to ${this.fileWriter.workbookPath}`); + + return this.fileWriter.workbookPath; } catch (e) { - await this.wrap(e); + logger.error("FileCreatorService", e.message); + await this.fileWriter.close(); + throw e; } } } diff --git a/api/src/pdc/services/export/services/LogService.ts b/api/src/pdc/services/export/services/LogService.ts index b8181fa93..2747cee64 100644 --- a/api/src/pdc/services/export/services/LogService.ts +++ b/api/src/pdc/services/export/services/LogService.ts @@ -109,6 +109,7 @@ export class LogService { message = "Export failed", ): Promise { logger.error(` ~ Export #${export_id} failed`); + logger.error(message); await this.log(export_id, ExportLogEvent.FAILURE, message); } diff --git a/api/src/pdc/services/export/services/NotificationService.ts b/api/src/pdc/services/export/services/NotificationService.ts new file mode 100644 index 000000000..64f2ebd35 --- /dev/null +++ b/api/src/pdc/services/export/services/NotificationService.ts @@ -0,0 +1,114 @@ +import { support } from "@/config/contacts.ts"; +import { provider } from "@/ilos/common/Decorators.ts"; +import { ContextType, KernelInterfaceResolver } from "@/ilos/common/index.ts"; +import { Export } from "@/pdc/services/export/models/Export.ts"; +import { ExportRecipient } from "@/pdc/services/export/models/ExportRecipient.ts"; +import { ExportRepositoryInterfaceResolver } from "@/pdc/services/export/repositories/ExportRepository.ts"; +import { ExportCSVSupportTemplateData } from "@/pdc/services/user/notifications/ExportCSVSupportNotification.ts"; +import { + ParamsInterface as NotifyParamsInterface, + signature as notifySignature, +} from "@/shared/user/notify.contract.ts"; + +export type NotificationProvider = { + success(exp: Export, url: string): Promise; + error(exp: Export): Promise; + support(exp: Export): Promise; +}; + +export abstract class NotificationProviderResolver + implements NotificationProvider { + public async success(exp: Export, url: string): Promise { + throw new Error("Not implemented"); + } + public async error(exp: Export): Promise { + throw new Error("Not implemented"); + } + public async support(exp: Export): Promise { + throw new Error("Not implemented"); + } +} + +@provider({ + identifier: NotificationProviderResolver, +}) +export class NotificationService { + protected defaultContext: ContextType = { + channel: { service: "export" }, + call: { user: {} }, + }; + + public constructor( + protected kernel: KernelInterfaceResolver, + protected exportRepository: ExportRepositoryInterfaceResolver, + ) {} + + /** + * Send the download link to the recipient + * + * @param exp + * @param url + */ + public async success(exp: Export, url: string): Promise { + const recipients = await this.recipients(exp); + for (const { email, fullname } of recipients) { + await this.notify({ + template: "ExportCSVNotification", + to: `${fullname} <${email}>`, + data: { fullname, action_href: url }, + }); + } + } + + /** + * Send an error message to the recipient + * + * @param exp + */ + public async error(exp: Export): Promise { + const recipients = await this.recipients(exp); + for (const { email, fullname } of recipients) { + await this.notify({ + template: "ExportCSVErrorNotification", + to: `${fullname} <${email}>`, + data: { fullname }, + }); + } + } + + /** + * Notify the technical support about an error + * + * @param exp + */ + public async support(exp: Export): Promise { + const { email, fullname } = support; + await this.notify({ + template: "ExportCSVSupportNotification", + to: `${fullname} <${email}>`, + data: { ...exp, error: exp.error }, + }); + } + + protected async recipients( + exp: Export, + ): Promise[]> { + const recipients = await this.exportRepository.recipients(exp._id); + return recipients.map((recipient: ExportRecipient) => { + return { + email: recipient.email, + fullname: recipient.fullname, + }; + }); + } + + protected async notify( + payload: NotifyParamsInterface, + ): Promise { + await this.kernel.call>( + notifySignature, + payload, + this.defaultContext, + ); + } +} diff --git a/api/src/pdc/services/export/services/StorageService.ts b/api/src/pdc/services/export/services/StorageService.ts new file mode 100644 index 000000000..f5dd4c52e --- /dev/null +++ b/api/src/pdc/services/export/services/StorageService.ts @@ -0,0 +1,57 @@ +import { provider } from "@/ilos/common/Decorators.ts"; +import { + BucketName, + S3ObjectList, + S3StorageProvider, +} from "@/pdc/providers/storage/index.ts"; + +export type StorageServiceInterface = { + init(): Promise; + list(): Promise; + upload(filepath: string): Promise; + getPublicUrl(filename: string): Promise; +}; + +export abstract class StorageServiceInterfaceResolver + implements StorageServiceInterface { + public async init(): Promise { + throw new Error("Not implemented"); + } + public async list(): Promise { + throw new Error("Not implemented"); + } + public async upload(filepath: string): Promise { + throw new Error("Not implemented"); + } + public async getPublicUrl(filename: string): Promise { + throw new Error("Not implemented"); + } +} + +@provider({ + identifier: StorageServiceInterfaceResolver, +}) +export class StorageService { + private readonly bucket: BucketName = BucketName.Export; + + public constructor( + protected s3StorageProvider: S3StorageProvider, + ) { + } + + public async init(): Promise { + await this.s3StorageProvider.init(); + } + + public async list(): Promise { + return await this.s3StorageProvider.list(this.bucket); + } + + public async upload(filepath: string): Promise { + return await this.s3StorageProvider.upload(this.bucket, filepath); + } + + public async getPublicUrl(filename: string): Promise { + return await this.s3StorageProvider.getPublicUrl(this.bucket, filename); + } +} diff --git a/api/src/pdc/services/user/actions/LoginUserAction.ts b/api/src/pdc/services/user/actions/LoginUserAction.ts index 5560d721a..b06ff6e8b 100644 --- a/api/src/pdc/services/user/actions/LoginUserAction.ts +++ b/api/src/pdc/services/user/actions/LoginUserAction.ts @@ -1,5 +1,5 @@ -import { Action as AbstractAction } from "@/ilos/core/index.ts"; import { handler, UnauthorizedException } from "@/ilos/common/index.ts"; +import { Action as AbstractAction } from "@/ilos/core/index.ts"; import { contentWhitelistMiddleware } from "@/pdc/providers/middleware/index.ts"; import { @@ -13,9 +13,6 @@ import { userWhiteListFilterOutput } from "../config/filterOutput.ts"; import { UserRepositoryProviderInterfaceResolver } from "../interfaces/UserRepositoryProviderInterface.ts"; import { challengePasswordMiddleware } from "../middlewares/ChallengePasswordMiddleware.ts"; -/* - * Authenticate user by email & pwd - else throws forbidden error - */ @handler({ ...handlerConfig, middlewares: [ @@ -34,6 +31,8 @@ export class LoginUserAction extends AbstractAction { public async handle(params: ParamsInterface): Promise { const user = await this.userRepository.findByEmail(params.email); + if (!user) throw new UnauthorizedException("Invalid email or password"); + switch (user.status) { case "pending": throw new UnauthorizedException("Account is pending validation"); diff --git a/api/src/pdc/services/user/actions/NotifyUserAction.ts b/api/src/pdc/services/user/actions/NotifyUserAction.ts index 875592fb5..5150e5f73 100644 --- a/api/src/pdc/services/user/actions/NotifyUserAction.ts +++ b/api/src/pdc/services/user/actions/NotifyUserAction.ts @@ -1,5 +1,5 @@ -import { Action as AbstractAction } from "@/ilos/core/index.ts"; import { handler } from "@/ilos/common/index.ts"; +import { Action as AbstractAction } from "@/ilos/core/index.ts"; import { internalOnlyMiddlewares } from "@/pdc/providers/middleware/index.ts"; import { @@ -15,7 +15,12 @@ import { UserNotificationProvider } from "../providers/UserNotificationProvider. @handler({ ...handlerConfig, middlewares: [ - ...internalOnlyMiddlewares(handlerConfig.service, "trip", "proxy"), + ...internalOnlyMiddlewares( + handlerConfig.service, + "export", + "trip", + "proxy", + ), ], }) export class NotifyUserAction extends AbstractAction { diff --git a/api/src/pdc/services/user/config/permissions.ts b/api/src/pdc/services/user/config/permissions.ts index 2d57760f6..3478a5631 100644 --- a/api/src/pdc/services/user/config/permissions.ts +++ b/api/src/pdc/services/user/config/permissions.ts @@ -1 +1,274 @@ -export * from "@/shared/user/permissions.config.ts"; +const permissions = { + "acquisition.create": ["operator.application", "operator.admin"], + "acquisition.cancel": ["operator.application", "operator.admin"], + "acquisition.status": [ + "operator.user", + "operator.application", + "operator.admin", + ], + "application.create": ["operator.admin"], + "application.find": ["operator.user", "operator.admin"], + "application.list": ["operator.user", "operator.admin"], + "application.revoke": ["operator.admin"], + "certificate.create": [ + "operator.application", + "operator.admin", + "registry.admin", + ], + "certificate.list": ["operator.admin", "registry.admin"], + "certificate.find": ["common"], + "certificate.download": [ + "operator.user", + "operator.application", + "operator.admin", + "registry.admin", + ], + "company.fetch": ["common"], + "company.find": ["common"], + "honor.save": ["common"], + "honor.stats": ["common"], + "monitoring.journeysstats": ["registry.admin"], + "observatory.stats": ["common"], + "operator.create": ["registry.admin"], + "operator.list": ["common"], + "operator.find": [ + "common", + "operator.user", + "operator.admin", + "registry.admin", + ], + "operator.delete": ["registry.admin"], + "operator.update": ["operator.admin", "registry.admin"], + "operator.patchContacts": ["operator.admin", "registry.admin"], + "operator.patchThumbnail": ["operator.admin", "registry.admin"], + "policy.create": ["territory.demo", "territory.admin", "registry.admin"], + "policy.delete": ["territory.demo", "territory.admin", "registry.admin"], + "policy.find": [ + "territory.demo", + "territory.user", + "territory.admin", + "registry.user", + "registry.admin", + "operator.admin", + "operator.user", + ], + "policy.launch": ["territory.admin"], + "policy.list": [ + "common", + "territory.demo", + "territory.user", + "territory.admin", + "registry.user", + "registry.admin", + ], + "policy.patch": ["territory.admin", "registry.admin"], + "policy.simulate.past": [ + "territory.admin", + "territory.demo", + "registry.admin", + ], + "policy.simulate.fake": ["territory.admin", "registry.admin"], + "policy.simulate.future": [ + "operator.application", + "operator.admin", + "territory.admin", + ], + "policy.list.templates": ["common"], + "territory.create": ["registry.admin"], + "territory.delete": ["registry.admin"], + "territory.find": [ + "common", + "territory.demo", + "territory.user", + "territory.admin", + "registry.user", + "registry.admin", + ], + "territory.update": ["territory.admin", "registry.admin"], + "territory.list": ["common"], + "territory.read": [ + "common", + "territory.user", + "territory.admin", + "registry.user", + "registry.admin", + ], + "territory.patchOperator": ["operator.admin"], + "territory.patchContacts": ["territory.admin", "registry.admin"], + "trip.stats": [ + "common", + "operator.user", + "operator.admin", + "territory.demo", + "territory.user", + "territory.admin", + "registry.user", + "registry.admin", + ], + "trip.export": [ + "operator.user", + "operator.admin", + "territory.user", + "territory.admin", + "registry.user", + "registry.admin", + ], + "apdf.list": [ + "operator.admin", + "territory.admin", + "registry.admin", + "operator.user", + ], + "apdf.listCurrentMonth": ["registry.admin"], + "apdf.export": ["registry.admin"], + "trip.list": [ + "operator.user", + "operator.admin", + "territory.demo", + "territory.user", + "territory.admin", + "registry.user", + "registry.admin", + ], + "user.update": [ + "common", + "operator.admin", + "territory.admin", + "registry.admin", + ], + "user.create": ["operator.admin", "territory.admin", "registry.admin"], + "user.delete": ["operator.admin", "territory.admin", "registry.admin"], + "user.find": [ + "common", + "operator.admin", + "territory.admin", + "registry.admin", + ], + "user.list": [ + "operator.user", + "operator.admin", + "territory.demo", + "territory.user", + "territory.admin", + "registry.user", + "registry.admin", + ], + "user.sendEmail": ["operator.admin", "territory.admin", "registry.admin"], + "user.policySimulate": ["common"], + + // export service + "export.create": ["common"], + "export.list": ["common"], + "export.read": ["common"], + "export.status": ["common"], + "export.cancel": ["common"], + "export.download": ["common"], +}; + +function scopeToGroup(permissionName: string, group: string) { + return `${group}.${permissionName}`; +} + +function dispatchPermissionsFromMatrix( + permissionsObject: Record, +) { + const permissionsByGroup: Record = { + common: [], + "territory.demo": [], + "territory.user": [], + "territory.admin": [], + "operator.user": [], + "operator.application": [], + "operator.admin": [], + "registry.user": [], + "registry.admin": [], + }; + + for (const permissionName of Reflect.ownKeys(permissionsObject) as string[]) { + const permissionRoles = permissionsObject[permissionName]; + + for (const permissionRole of permissionRoles) { + const group = permissionRole.split(".")[0]; + permissionsByGroup[permissionRole].push( + scopeToGroup(permissionName, group), + ); + } + } + // prefix common + return permissionsByGroup; +} + +const permissionsByRoles = dispatchPermissionsFromMatrix(permissions); + +export const territory = { + admin: { + slug: "admin", + name: "Admin", + permissions: [ + ...permissionsByRoles["common"], + ...permissionsByRoles["territory.admin"], + ], + }, + demo: { + slug: "demo", + name: "Demo", + permissions: [ + ...permissionsByRoles["common"], + ...permissionsByRoles["territory.demo"], + ], + }, + user: { + slug: "user", + name: "User", + permissions: [ + ...permissionsByRoles["common"], + ...permissionsByRoles["territory.user"], + ], + }, +}; + +export const operator = { + admin: { + slug: "admin", + name: "Admin", + permissions: [ + ...permissionsByRoles["common"], + ...permissionsByRoles["operator.admin"], + ], + }, + application: { + slug: "application", + name: "Application", + permissions: [ + ...permissionsByRoles["common"], + ...permissionsByRoles["operator.application"], + ], + }, + user: { + slug: "user", + name: "User", + permissions: [ + ...permissionsByRoles["common"], + ...permissionsByRoles["operator.user"], + ], + }, +}; + +export const registry = { + admin: { + slug: "admin", + name: "Admin", + permissions: [ + ...permissionsByRoles["common"], + ...permissionsByRoles["registry.admin"], + ], + }, + user: { + slug: "user", + name: "User", + permissions: [ + ...permissionsByRoles["common"], + ...permissionsByRoles["registry.user"], + ], + }, +}; diff --git a/api/src/pdc/services/user/notifications/ExportCSVSupportNotification.ts b/api/src/pdc/services/user/notifications/ExportCSVSupportNotification.ts new file mode 100644 index 000000000..a2dddf6b7 --- /dev/null +++ b/api/src/pdc/services/user/notifications/ExportCSVSupportNotification.ts @@ -0,0 +1,43 @@ +import { + DefaultNotification, + DefaultTemplateData, +} from "@/pdc/providers/notification/index.ts"; +import { Export, ExportError } from "@/pdc/services/export/models/Export.ts"; + +export interface ExportCSVSupportTemplateData + extends Pick { + error: ExportError; +} + +const defaultData: Partial = { + hero_alt: "Export des données", + hero_image_src: "https://x0zwu.mjt.lu/tplimg/x0zwu/b/x5zwm/vkxn4.png", + title: "Erreur d'export des données", + preview: "Une erreur s'est produite lors de l'export des données.", + message_html: ` +

Une erreur s'est produite lors de l'export des données.

+
    +
  • _id: {{ _id }}
  • +
  • uuid: {{ uuid }}
  • +
  • target: {{ target }}
  • +
  • status: {{ status }}
  • +
  • error: {{ error }}
  • +
+`, + message_text: ` +Une erreur s'est produite lors de l'export des données. + +_id: {{ _id }} +uuid: {{ uuid }} +target: {{ target }} +status: {{ status }} +error: {{ error }} + `, +}; + +export class ExportCSVSupportNotification extends DefaultNotification { + static readonly subject = "Erreur d'export"; + constructor(to: string, data: Partial) { + super(to, { ...defaultData, ...data }); + } +} diff --git a/api/src/pdc/services/user/notifications/index.ts b/api/src/pdc/services/user/notifications/index.ts index 06c48de8d..aa4d9013e 100644 --- a/api/src/pdc/services/user/notifications/index.ts +++ b/api/src/pdc/services/user/notifications/index.ts @@ -3,6 +3,7 @@ export { ContactFormNotification } from "./ContactFormNotification.ts"; export { EmailUpdatedNotification } from "./EmailUpdatedNotification.ts"; export { ExportCSVErrorNotification } from "./ExportCSVErrorNotification.ts"; export { ExportCSVNotification } from "./ExportCSVNotification.ts"; +export { ExportCSVSupportNotification } from "./ExportCSVSupportNotification.ts"; export { ForgottenPasswordNotification } from "./ForgottenPasswordNotification.ts"; export { InviteNotification } from "./InviteNotification.ts"; export { SimulatePolicyNotification } from "./SimulatePolicyNotification.ts"; diff --git a/api/src/pdc/services/user/providers/UserNotificationProvider.ts b/api/src/pdc/services/user/providers/UserNotificationProvider.ts index 6ec513228..a942fecf6 100644 --- a/api/src/pdc/services/user/providers/UserNotificationProvider.ts +++ b/api/src/pdc/services/user/providers/UserNotificationProvider.ts @@ -4,15 +4,16 @@ import { KernelInterfaceResolver, provider, } from "@/ilos/common/index.ts"; - +import { env } from "@/lib/env/index.ts"; +import { logger } from "@/lib/logger/index.ts"; import { MailTemplateNotificationInterface, NotificationTransporterInterfaceResolver, StaticMailTemplateNotificationInterface, } from "@/pdc/providers/notification/index.ts"; - import { PolicyTemplateDescriptions } from "@/shared/policy/common/classes/PolicyTemplateDescription.ts"; import { ResultInterface as SimulateOnPastResult } from "@/shared/policy/simulateOnPastGeo.contract.ts"; +import { ParamsInterface as SendMailParamsInterface } from "@/shared/user/notify.contract.ts"; import { ParamsInterface as SimulationPolicyParamsInterface } from "@/shared/user/simulatePolicyform.contract.ts"; import { ConfirmEmailNotification, @@ -20,15 +21,12 @@ import { EmailUpdatedNotification, ExportCSVErrorNotification, ExportCSVNotification, + ExportCSVSupportNotification, ForgottenPasswordNotification, InviteNotification, SimulatePolicyNotification, } from "../notifications/index.ts"; -import { env } from "@/lib/env/index.ts"; -import { logger } from "@/lib/logger/index.ts"; -import { ParamsInterface as SendMailParamsInterface } from "@/shared/user/notify.contract.ts"; - @provider() export class UserNotificationProvider { public readonly urlPathMap: Map = new Map([ @@ -56,6 +54,7 @@ export class UserNotificationProvider { EmailUpdatedNotification: EmailUpdatedNotification, ExportCSVErrorNotification: ExportCSVErrorNotification, ExportCSVNotification: ExportCSVNotification, + ExportCSVSupportNotification: ExportCSVSupportNotification, ForgottenPasswordNotification: ForgottenPasswordNotification, InviteNotification: InviteNotification, SimulatePolicyNotification: SimulatePolicyNotification, diff --git a/api/src/shared/user/notify.contract.ts b/api/src/shared/user/notify.contract.ts index 066f2977a..fa304336a 100644 --- a/api/src/shared/user/notify.contract.ts +++ b/api/src/shared/user/notify.contract.ts @@ -1,14 +1,15 @@ -export interface ParamsInterface { +export interface ParamsInterface { template: string; to: string; - data: { [k: string]: any }; + data: T; } export type ResultInterface = void; export const handlerConfig = { - service: 'user', - method: 'notify', + service: "user", + method: "notify", } as const; -export const signature = `${handlerConfig.service}:${handlerConfig.method}` as const; +export const signature = + `${handlerConfig.service}:${handlerConfig.method}` as const; diff --git a/flake.nix b/flake.nix index c3d90d404..a5f2b2269 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,8 @@ just openssl pm2 + jq + minio-client # rpc infra nodejs_20 diff --git a/shared/export/create.schema.ts b/shared/export/create.schema.ts index 1a0db5d81..5428aaf28 100644 --- a/shared/export/create.schema.ts +++ b/shared/export/create.schema.ts @@ -60,9 +60,7 @@ export const schemaV3 = { items: { macro: "serial" }, }, territory_id: { - type: "array", - minItems: 0, - items: { macro: "serial" }, + macro: "serial", }, recipients: { type: "array", diff --git a/shared/user/permissions.config.ts b/shared/user/permissions.config.ts deleted file mode 100644 index a83832baa..000000000 --- a/shared/user/permissions.config.ts +++ /dev/null @@ -1,192 +0,0 @@ -const permissions = { - 'acquisition.create': ['operator.application', 'operator.admin'], - 'acquisition.cancel': ['operator.application', 'operator.admin'], - 'acquisition.status': ['operator.user', 'operator.application', 'operator.admin'], - 'application.create': ['operator.admin'], - 'application.find': ['operator.user', 'operator.admin'], - 'application.list': ['operator.user', 'operator.admin'], - 'application.revoke': ['operator.admin'], - 'certificate.create': ['operator.application', 'operator.admin', 'registry.admin'], - 'certificate.list': ['operator.admin', 'registry.admin'], - 'certificate.find': ['common'], - 'certificate.download': ['operator.user', 'operator.application', 'operator.admin', 'registry.admin'], - 'company.fetch': ['common'], - 'company.find': ['common'], - 'honor.save': ['common'], - 'honor.stats': ['common'], - 'monitoring.journeysstats': ['registry.admin'], - 'observatory.stats': ['common'], - 'operator.create': ['registry.admin'], - 'operator.list': ['common'], - 'operator.find': ['common', 'operator.user', 'operator.admin', 'registry.admin'], - 'operator.delete': ['registry.admin'], - 'operator.update': ['operator.admin', 'registry.admin'], - 'operator.patchContacts': ['operator.admin', 'registry.admin'], - 'operator.patchThumbnail': ['operator.admin', 'registry.admin'], - 'policy.create': ['territory.demo', 'territory.admin', 'registry.admin'], - 'policy.delete': ['territory.demo', 'territory.admin', 'registry.admin'], - 'policy.find': [ - 'territory.demo', - 'territory.user', - 'territory.admin', - 'registry.user', - 'registry.admin', - 'operator.admin', - 'operator.user', - ], - 'policy.launch': ['territory.admin'], - 'policy.list': ['common', 'territory.demo', 'territory.user', 'territory.admin', 'registry.user', 'registry.admin'], - 'policy.patch': ['territory.admin', 'registry.admin'], - 'policy.simulate.past': ['territory.admin', 'territory.demo', 'registry.admin'], - 'policy.simulate.fake': ['territory.admin', 'registry.admin'], - 'policy.simulate.future': ['operator.application', 'operator.admin', 'territory.admin'], - 'policy.list.templates': ['common'], - 'territory.create': ['registry.admin'], - 'territory.delete': ['registry.admin'], - 'territory.find': [ - 'common', - 'territory.demo', - 'territory.user', - 'territory.admin', - 'registry.user', - 'registry.admin', - ], - 'territory.update': ['territory.admin', 'registry.admin'], - 'territory.list': ['common'], - 'territory.read': ['common', 'territory.user', 'territory.admin', 'registry.user', 'registry.admin'], - 'territory.patchOperator': ['operator.admin'], - 'territory.patchContacts': ['territory.admin', 'registry.admin'], - 'trip.stats': [ - 'common', - 'operator.user', - 'operator.admin', - 'territory.demo', - 'territory.user', - 'territory.admin', - 'registry.user', - 'registry.admin', - ], - 'trip.export': [ - 'operator.user', - 'operator.admin', - 'territory.user', - 'territory.admin', - 'registry.user', - 'registry.admin', - ], - 'apdf.list': ['operator.admin', 'territory.admin', 'registry.admin', 'operator.user'], - 'apdf.listCurrentMonth': ['registry.admin'], - 'apdf.export': ['registry.admin'], - 'trip.list': [ - 'operator.user', - 'operator.admin', - 'territory.demo', - 'territory.user', - 'territory.admin', - 'registry.user', - 'registry.admin', - ], - 'user.update': ['common', 'operator.admin', 'territory.admin', 'registry.admin'], - 'user.create': ['operator.admin', 'territory.admin', 'registry.admin'], - 'user.delete': ['operator.admin', 'territory.admin', 'registry.admin'], - 'user.find': ['common', 'operator.admin', 'territory.admin', 'registry.admin'], - 'user.list': [ - 'operator.user', - 'operator.admin', - 'territory.demo', - 'territory.user', - 'territory.admin', - 'registry.user', - 'registry.admin', - ], - 'user.sendEmail': ['operator.admin', 'territory.admin', 'registry.admin'], - 'user.policySimulate': ['common'], - - // export service - 'export.create': ['common'], - 'export.list': ['common'], - 'export.read': ['common'], - 'export.status': ['common'], - 'export.cancel': ['common'], - 'export.download': ['common'], -}; - -function scopeToGroup(permissionName, group) { - return `${group}.${permissionName}`; -} - -function dispatchPermissionsFromMatrix(permissionsObject) { - const permissionsByGroup = { - common: [], - 'territory.demo': [], - 'territory.user': [], - 'territory.admin': [], - 'operator.user': [], - 'operator.application': [], - 'operator.admin': [], - 'registry.user': [], - 'registry.admin': [], - }; - - for (const permissionName of Reflect.ownKeys(permissionsObject)) { - const permissionRoles = permissionsObject[permissionName]; - - for (const permissionRole of permissionRoles) { - const group = permissionRole.split('.')[0]; - permissionsByGroup[permissionRole].push(scopeToGroup(permissionName, group)); - } - } - // prefix common - return permissionsByGroup; -} - -const permissionsByRoles = dispatchPermissionsFromMatrix(permissions); - -export const territory = { - admin: { - slug: 'admin', - name: 'Admin', - permissions: [...permissionsByRoles['common'], ...permissionsByRoles['territory.admin']], - }, - demo: { - slug: 'demo', - name: 'Demo', - permissions: [...permissionsByRoles['common'], ...permissionsByRoles['territory.demo']], - }, - user: { - slug: 'user', - name: 'User', - permissions: [...permissionsByRoles['common'], ...permissionsByRoles['territory.user']], - }, -}; - -export const operator = { - admin: { - slug: 'admin', - name: 'Admin', - permissions: [...permissionsByRoles['common'], ...permissionsByRoles['operator.admin']], - }, - application: { - slug: 'application', - name: 'Application', - permissions: [...permissionsByRoles['common'], ...permissionsByRoles['operator.application']], - }, - user: { - slug: 'user', - name: 'User', - permissions: [...permissionsByRoles['common'], ...permissionsByRoles['operator.user']], - }, -}; - -export const registry = { - admin: { - slug: 'admin', - name: 'Admin', - permissions: [...permissionsByRoles['common'], ...permissionsByRoles['registry.admin']], - }, - user: { - slug: 'user', - name: 'User', - permissions: [...permissionsByRoles['common'], ...permissionsByRoles['registry.user']], - }, -};