diff --git a/CHANGELOG.md b/CHANGELOG.md index e88e28170..21408f1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,21 +6,18 @@ # [2025.7.0](https://github.com/proconnect-gouv/hyyypertool/compare/2025.6.2...2025.7.0) (2025-07-24) - ### Bug Fixes -* **typo:** add accent to vérifier ([#947](https://github.com/proconnect-gouv/hyyypertool/issues/947)) ([e2db914](https://github.com/proconnect-gouv/hyyypertool/commit/e2db914f3d6a081a0fcd6e2a703daca4414fc1a2)) - +- **typo:** add accent to vérifier ([#947](https://github.com/proconnect-gouv/hyyypertool/issues/947)) ([e2db914](https://github.com/proconnect-gouv/hyyypertool/commit/e2db914f3d6a081a0fcd6e2a703daca4414fc1a2)) ### Features -* migrate from MonComptePro to Identité ProConnect ([#971](https://github.com/proconnect-gouv/hyyypertool/issues/971)) ([48bce06](https://github.com/proconnect-gouv/hyyypertool/commit/48bce0683e771d7aea71f46a0de19871afbe21b8)) -* **moderation:** update agent_comcom_comaglo ([#961](https://github.com/proconnect-gouv/hyyypertool/issues/961)) ([734c5f5](https://github.com/proconnect-gouv/hyyypertool/commit/734c5f5cab9e45a3d3763c11425d5921805b4048)) - +- migrate from MonComptePro to Identité ProConnect ([#971](https://github.com/proconnect-gouv/hyyypertool/issues/971)) ([48bce06](https://github.com/proconnect-gouv/hyyypertool/commit/48bce0683e771d7aea71f46a0de19871afbe21b8)) +- **moderation:** update agent_comcom_comaglo ([#961](https://github.com/proconnect-gouv/hyyypertool/issues/961)) ([734c5f5](https://github.com/proconnect-gouv/hyyypertool/commit/734c5f5cab9e45a3d3763c11425d5921805b4048)) ### Reverts -* Revert "🔧 improve dependabot config for bun monorepo support (#975)" (#990) ([8df2db2](https://github.com/proconnect-gouv/hyyypertool/commit/8df2db2121173fa8aa87085fa92ee1d4781c6922)), closes [#975](https://github.com/proconnect-gouv/hyyypertool/issues/975) [#990](https://github.com/proconnect-gouv/hyyypertool/issues/990) +- Revert "🔧 improve dependabot config for bun monorepo support (#975)" (#990) ([8df2db2](https://github.com/proconnect-gouv/hyyypertool/commit/8df2db2121173fa8aa87085fa92ee1d4781c6922)), closes [#975](https://github.com/proconnect-gouv/hyyypertool/issues/975) [#990](https://github.com/proconnect-gouv/hyyypertool/issues/990) ## [2025.6.2](https://github.com/proconnect-gouv/hyyypertool/compare/2025.6.1...2025.6.2) (2025-06-17) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd14b5da8..9ee3b9a64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,16 +8,16 @@ Thank you for your interest in contributing! We welcome improvements, bug fixes, ### Main branch -* The default development branch is `main`. +- The default development branch is `main`. ### Branch naming -* Use a descriptive name that recalls the purpose, for example: +- Use a descriptive name that recalls the purpose, for example: + - `feature-add-authentication` + - `fix-update-dependencies` - * `feature-add-authentication` - * `fix-update-dependencies` -* Avoid generic names like `patch-1` or `branch123`. -* Feel free to choose any clear, concise format. +- Avoid generic names like `patch-1` or `branch123`. +- Feel free to choose any clear, concise format. --- @@ -25,20 +25,19 @@ Thank you for your interest in contributing! We welcome improvements, bug fixes, ### Scope -* Make small, focused commits (micro commits). -* Avoid touching too many files in a single commit. +- Make small, focused commits (micro commits). +- Avoid touching too many files in a single commit. ### Message format -* Use [Gitmoji](https://gitmoji.dev/) for an emoji prefix (e.g., `✨`, `🐛`, `💄`). -* Write a short subject that clearly describes the change; it will appear in the changelog. - - * Example: `💄 change duplicate icon` +- Use [Gitmoji](https://gitmoji.dev/) for an emoji prefix (e.g., `✨`, `🐛`, `💄`). +- Write a short subject that clearly describes the change; it will appear in the changelog. + - Example: `💄 change duplicate icon` ### Commit body -* Include detailed context or rationale here. -* Do **not** post PR details or discussion in external chat channels; document them in the commit instead. +- Include detailed context or rationale here. +- Do **not** post PR details or discussion in external chat channels; document them in the commit instead. --- @@ -46,28 +45,272 @@ Thank you for your interest in contributing! We welcome improvements, bug fixes, ### Labels -* GitHub will apply labels automatically. +- GitHub will apply labels automatically. ### Content -* Keep PRs small and focused; ideally one commit per PR. -* PRs are merged into `main` using a **merge commit**. -* Clean up or squash commits before pushing (on macOS, you can use [GitUp](https://github.com/git-up/GitUp)). +- Keep PRs small and focused; ideally one commit per PR. +- PRs are merged into `main` using a **merge commit**. +- Clean up or squash commits before pushing (on macOS, you can use [GitUp](https://github.com/git-up/GitUp)). ### Title -* **Single-commit PR**: use the default commit message as the PR title. -* **Multi-commit PR**: craft a title following the commit message guidelines above. +- **Single-commit PR**: use the default commit message as the PR title. +- **Multi-commit PR**: craft a title following the commit message guidelines above. ### Description -* **Single-commit PR**: the default description is usually sufficient. -* **When needed**, add context, for example: +- **Single-commit PR**: the default description is usually sufficient. +- **When needed**, add context, for example: + - Database migrations to run: `npm run migrate` + - This PR reverts #123. + +- Link related Trello cards using the Trello Power-Up. +- Feel free to include illustrative GIFs to demonstrate changes. + +--- + +## UseCase Convention + +UseCases follow a consistent factory pattern for dependency injection and type safety. + +### Structure + +```typescript +// Factory function that takes dependencies +export function UseCaseName({ dependency1, dependency2 }: DependencyCradle) { + // Return the actual usecase function + return async function use_case_name(params: InputType) { + // Implementation + return result; + }; +} + +// Export handler type for dependency injection +export type UseCaseNameHandler = ReturnType; + +// Optional: Export output type if needed elsewhere +export type UseCaseNameOutput = Awaited>; +``` + +### Conventions + +1. **Factory Pattern**: Each usecase is a factory function that accepts dependencies and returns the actual usecase function +2. **Naming**: + - Factory function: `PascalCase` (e.g., `GetUserInfo`) + - Returned function: `snake_case` (e.g., `get_user_info`) +3. **Dependencies**: Use typed cradles (e.g., `IdentiteProconnectDatabaseCradle`) for dependency injection +4. **Types**: Export `Handler` type for the returned function, optionally export `Output` type +5. **Comments**: Use `//` comment blocks to separate sections +6. **Error Handling**: + - Throw appropriate errors (e.g., `NotFoundError`) with descriptive messages + - Prefer `import { to } from "await-to-js"` over try/catch blocks for cleaner error handling + - Always try to track error cause when instantiating new errors using the `cause` option +7. **Async**: All usecase functions should be async + +### Example + +```typescript +// + +import { NotFoundError } from "@~/app.core/error"; +import type { IdentiteProconnectDatabaseCradle } from "@~/identite-proconnect.database"; +import { to } from "await-to-js"; + +// + +export function GetUserInfo({ pg }: IdentiteProconnectDatabaseCradle) { + return async function get_user_info(id: number) { + const user = await pg.query.users.findFirst({ + where: (table, { eq }) => eq(table.id, id), + }); + + if (!user) throw new NotFoundError(`User ${id} not found.`); + return user; + }; +} + +export type GetUserInfoHandler = ReturnType; +export type GetUserInfoOutput = Awaited>; +``` + +**Error Handling with await-to-js:** + +```typescript +export function ProcessUserData({ external_service }: Dependencies) { + return async function process_user_data(id: number) { + // Prefer this pattern over try/catch + const [error, result] = await to(external_service.fetchData(id)); + + if (error) { + // Handle specific error types and track the original cause + if (error instanceof NetworkError) { + throw new ServiceUnavailableError("External service unavailable", { + cause: error, + }); + } + + // Always preserve the original error as cause when wrapping + throw new ProcessingError("Failed to process user data", { + cause: error, + }); + } + + return result; + }; +} +``` + +--- + +## Testing Convention + +Write comprehensive tests following these guidelines: + +### Repository Layer Tests + +1. **Use Real Database**: Import `pg` from `@~/identite-proconnect.database/testing` instead of mocking +2. **Database Setup**: Always include `beforeAll(migrate)` and `beforeEach(empty_database)` +3. **Seed Data**: Use unicorn seed functions (e.g., `create_adora_pony_user`, `create_pink_diamond_user`, `create_red_diamond_user`) +4. **Time Control**: Use `setSystemTime()` for deterministic timestamps in tests +5. **Snapshots Over Multiple Expects**: Prefer `toMatchInlineSnapshot()` for complex object assertions over multiple individual expects +6. **Auto-generated Snapshots**: When using `toMatchInlineSnapshot()`, let Bun test write the string value automatically by running the test first with an empty string or no parameter +7. **Comprehensive Coverage**: Test all major functionality including: + - Basic operations (CRUD) + - Edge cases (empty results, not found) + - Filtering and search functionality + - Pagination behavior + - Data ordering + +### Example + +```typescript +// + +import { schema } from "@~/identite-proconnect.database"; +import { + create_adora_pony_user, + create_pink_diamond_user, +} from "@~/identite-proconnect.database/seed/unicorn"; +import { + empty_database, + migrate, + pg, +} from "@~/identite-proconnect.database/testing"; +import { beforeAll, beforeEach, expect, setSystemTime, test } from "bun:test"; +import { GetUsersList } from "./GetUsersList"; + +// + +beforeAll(migrate); +beforeEach(empty_database); + +beforeAll(() => { + setSystemTime(new Date("2222-01-01T00:00:00.000Z")); +}); + +test("returns paginated users list with default pagination", async () => { + await create_adora_pony_user(pg); + + const get_users_list = GetUsersList(pg); + const result = await get_users_list({}); + + // Let Bun auto-generate the snapshot - comprehensive validation + expect(result).toMatchInlineSnapshot(` + { + "count": 1, + "users": [ + { + "created_at": "2222-01-01 00:00:00+00", + "email": "adora.pony@unicorn.xyz", + "email_verified_at": null, + "family_name": "Pony", + "given_name": "Adora", + "id": 1, + "last_sign_in_at": null, + }, + ], + } + `); +}); + +test("filters users by search term", async () => { + await create_adora_pony_user(pg); + await create_pink_diamond_user(pg); + + const get_users_list = GetUsersList(pg); + const result = await get_users_list({ search: "pony" }); + + expect(result).toMatchInlineSnapshot(` + { + "count": 1, + "users": [ + { + "created_at": "2222-01-01 00:00:00+00", + "email": "adora.pony@unicorn.xyz", + "email_verified_at": null, + "family_name": "Pony", + "given_name": "Adora", + "id": 1, + "last_sign_in_at": null, + }, + ], + } + `); +}); +``` + +--- + +## Context Usage Convention + +### Server vs Client Context + +- **Pages/Routes**: Use `useRequestContext` for server data access +- **UI Components**: Use `createContext` for client state management + +This separation ensures clear architectural boundaries between server-side data concerns and client-side UI state. + +### Page Variables Pattern + +Use `loadPageVariables` functions for consistent data loading: + +```typescript +export async function loadDomainPageVariables( + pg: IdentiteProconnect_PgDatabase, + { id }: { id: number }, +) { + // Data loading logic + return { data1, data2, ... }; +} + +export interface ContextVariablesType extends Env { + Variables: Awaited>; +} +``` + +#### Helper Function + +Use `set_variables` helper for bulk context variable assignment: + +```typescript +import { set_variables } from "@~/app.middleware/context/set_variables"; + +async function set_variables_middleware( + { req, set, var: { identite_pg } }, + next, +) { + const { id } = req.valid("param"); + const variables = await loadPageVariables(identite_pg, { id }); + set_variables(set, variables); + return next(); +} +``` - * Database migrations to run: `npm run migrate` - * This PR reverts #123. -* Link related Trello cards using the Trello Power-Up. -* Feel free to include illustrative GIFs to demonstrate changes. +- **Naming**: `loadDomainPageVariables` (e.g., `loadUserPageVariables`) +- **Type inference**: Use `Awaited>` for automatic typing +- **Single source**: Consolidate all page data loading in one function +- **Helper usage**: Use `set_variables(set, variables)` for bulk assignment --- diff --git a/e2e/features/moderations/manage_internal_domain.feature b/e2e/features/moderations/manage_internal_domain.feature index 4a662a56a..7a1c45d32 100644 --- a/e2e/features/moderations/manage_internal_domain.feature +++ b/e2e/features/moderations/manage_internal_domain.feature @@ -18,17 +18,21 @@ Fonctionnalité: Gérer un domaine interne lors de la modération | Domain | Status | | yopmail.com | ❓ | + Quand je vais à l'intérieur de la rangée nommée "Domaine yopmail.com (null)" Quand je clique sur "Menu" Et je clique sur "✅ Domaine autorisé" + Et je réinitialise le contexte Alors je dois voir un tableau nommé "🌐 1 domaine connu dans l’organisation" et contenant | Domain | Status | | yopmail.com | ✅ | + Quand je vais à l'intérieur de la rangée nommée "Domaine yopmail.com (verified)" Quand je clique sur "Menu" Et je clique sur le bouton "🚫 Domaine refusé" - Quand je vais à l'intérieur du tableau nommé "🌐 1 domaine connu dans l’organisation" - Alors je vois "🚫" - Et je vois "refused" + Et je réinitialise le contexte + Alors je dois voir un tableau nommé "🌐 1 domaine connu dans l’organisation" et contenant + | Domain | Status | Type | + | yopmail.com | 🚫 | refused | Et je réinitialise le contexte Quand je clique sur "Ajouter un domain" diff --git a/hyyypertool.code-workspace b/hyyypertool.code-workspace index 1e2a3af77..ef168dd8c 100644 --- a/hyyypertool.code-workspace +++ b/hyyypertool.code-workspace @@ -103,7 +103,7 @@ "cSpell.language": "en,fr", "cucumber.features": ["features/**/*.feature"], "cucumber.glue": ["cypress/support/step_definitions/**/*.ts"], - "cSpell.words": ["identite", "moderations", "proconnect"], + "cSpell.words": ["identite", "moderations", "proconnect", "zammad"], }, "tasks": { "version": "2.0.0", diff --git a/sources/app/middleware/src/context/index.ts b/sources/app/middleware/src/context/index.ts new file mode 100644 index 000000000..390f711b4 --- /dev/null +++ b/sources/app/middleware/src/context/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./set_variables"; diff --git a/sources/app/middleware/src/context/set_variables.test.ts b/sources/app/middleware/src/context/set_variables.test.ts new file mode 100644 index 000000000..ae81f0ac2 --- /dev/null +++ b/sources/app/middleware/src/context/set_variables.test.ts @@ -0,0 +1,55 @@ +// + +import { expect, test } from "bun:test"; +import { Hono } from "hono"; +import { set_variables } from "./set_variables"; + +// + +test("set_variables calls set function for each key-value pair", () => { + const mockSet = new Map(); + const setFn = mockSet.set.bind(mockSet); + + set_variables(setFn, { + user: { id: 1, name: "Test User" }, + organization: { id: 2, title: "Test Org" }, + }); + + expect(mockSet).toMatchInlineSnapshot(` + Map { + "user" => { + "id": 1, + "name": "Test User", + }, + "organization" => { + "id": 2, + "title": "Test Org", + }, + } + `); +}); + +test("should be compatible the Hono Context#set function", async () => { + const app = new Hono<{}>().get( + "/", + function set_variables_middleware({ set }, next) { + set_variables(set, { foo: "bar" }); + + return next(); + }, + function GET({ json, var: variables }) { + return json({ variables }); + }, + ); + + const res = await app.request("/"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchInlineSnapshot(` + { + "variables": { + "foo": "bar", + }, + } + `); +}); diff --git a/sources/app/middleware/src/context/set_variables.ts b/sources/app/middleware/src/context/set_variables.ts new file mode 100644 index 000000000..7c4a9606c --- /dev/null +++ b/sources/app/middleware/src/context/set_variables.ts @@ -0,0 +1,5 @@ +// + +export function set_variables(set: any, variables: object) { + for (const [key, value] of Object.entries(variables)) set(key, value); +} diff --git a/sources/app/middleware/src/set_context_variables.test.ts b/sources/app/middleware/src/set_context_variables.test.ts deleted file mode 100644 index 499523f5b..000000000 --- a/sources/app/middleware/src/set_context_variables.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// - -import { expect, test } from "bun:test"; -import { Hono, type Env } from "hono"; -import { contextStorage, getContext } from "hono/context-storage"; -import { set_context_variables } from "./set_context_variables"; - -// - -test("set direct variables", async () => { - const app = new Hono().get( - "/", - set_context_variables(() => ({ cloud: "☁️" })), - async ({ json, var: { cloud } }) => { - return json({ cloud }); - }, - ); - - const res = await app.request("/"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ cloud: "☁️" }); -}); - -test("set multiple direct variables", async () => { - const app = new Hono().get( - "/", - set_context_variables(() => ({ cloud: "☁️" })), - set_context_variables(() => ({ sun: "🌞" })), - async ({ json, var: { cloud, sun } }) => { - return json({ cloud, sun }); - }, - ); - - const res = await app.request("/"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ cloud: "☁️", sun: "🌞" }); -}); - -test("set variables using parent context", async () => { - const app = new Hono().get( - "/", - contextStorage(), - set_context_variables(() => { - const { - var: { sun }, - } = getContext(); - return { sun: sun ?? "🌞" }; - }), - set_context_variables(() => { - const { - req, - var: { sun }, - } = getContext(); - const cloud = req.query("search") === "sun" ? sun : "☁️"; - return { cloud }; - }), - async ({ json, var: { cloud, sun } }) => { - return json({ cloud, sun }); - }, - ); - { - const res = await app.request("/"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ cloud: "☁️", sun: "🌞" }); - } - { - const res = await app.request("/?search=sun"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ cloud: "🌞", sun: "🌞" }); - } -}); - -// - -interface CloudEnv extends Env { - Variables: { - cloud: string; - }; -} - -interface SunEnv extends Env { - Variables: { - sun: string; - }; -} diff --git a/sources/app/middleware/src/set_context_variables.ts b/sources/app/middleware/src/set_context_variables.ts deleted file mode 100644 index f20ac8077..000000000 --- a/sources/app/middleware/src/set_context_variables.ts +++ /dev/null @@ -1,24 +0,0 @@ -// - -import type { Env } from "hono"; -import { createMiddleware } from "hono/factory"; -import type { Input } from "hono/types"; - -// - -export function set_context_variables< - TEnv extends Env = Env, - TPath extends string = string, - TInput extends Input = {}, ->( - fn: () => - | NonNullable - | PromiseLike>, -) { - return createMiddleware(async (ctx, next) => { - const context_variables = await fn(); - for (const [key, value] of Object.entries(context_variables)) - ctx.set(key as keyof TEnv["Variables"], value); - return next(); - }); -} diff --git a/sources/infra/crisp/lib/src/index.ts b/sources/infra/crisp/lib/src/index.ts index 88744dd75..365a77792 100644 --- a/sources/infra/crisp/lib/src/index.ts +++ b/sources/infra/crisp/lib/src/index.ts @@ -2,18 +2,10 @@ import { fetch_crisp } from "@gouvfr-lasuite/proconnect.crisp/client"; import type { - CreateConversationRoute, GetConversationRoute, GetMessagesInAConversationRoute, - OperatorsRouter, - SendMessageInAConversationRoute, - UpdateConversationMetaRoute, } from "@gouvfr-lasuite/proconnect.crisp/router"; -import type { - Config, - ConversationMeta, - User, -} from "@gouvfr-lasuite/proconnect.crisp/types"; +import type { Config } from "@gouvfr-lasuite/proconnect.crisp/types"; import { z } from "zod"; import type { CrispApi } from "./api"; @@ -48,81 +40,4 @@ export async function get_crisp_mail( return { conversation, messages }; } -/** - * @deprecated Use `crisp.send_message` instead. - */ -export async function send_message( - config: Config, - { - content, - session_id, - user, - }: { content: string; user: Partial; session_id: string }, -) { - return fetch_crisp(config, { - endpoint: `/v1/website/${config.website_id}/conversation/${session_id}/message`, - method: "POST", - searchParams: {}, - body: { - content, - from: "operator", - origin: config.plugin_urn as `urn:${string}`, - type: "text", - user, - }, - }); -} - -/** - * @deprecated Use `crisp.get_user` instead. - */ -export async function get_user(config: Config, { email }: { email: string }) { - const operators = await fetch_crisp(config, { - endpoint: `/v1/website/${config.website_id}/operators/list`, - method: "GET", - searchParams: {}, - }); - - const operator = operators.find(({ details }) => details.email === email); - - if (!operator) throw new Error(`Operator "${email}" not found.`); - - return { - user_id: operator.details.user_id, - nickname: `${operator.details.first_name} ${operator.details.last_name}`, - } as User; -} - -/** - * @deprecated Use `crisp.create_conversation` instead. - */ -export async function create_conversation( - config: Config, - { - email, - nickname, - subject, - }: Pick, -) { - const { session_id } = await fetch_crisp(config, { - endpoint: `/v1/website/${config.website_id}/conversation`, - method: "POST", - searchParams: {}, - }); - - await fetch_crisp(config, { - endpoint: `/v1/website/${config.website_id}/conversation/${session_id}/meta`, - method: "PATCH", - searchParams: {}, - body: { - email, - nickname, - segments: ["email", "moderation"], - subject, - }, - }); - - return { session_id }; -} - export type get_crisp_mail_dto = Awaited>; diff --git a/sources/moderations/api/src/:id/$procedures/rejected.ts b/sources/moderations/api/src/:id/$procedures/rejected.ts index 701b91787..b95f30bbf 100644 --- a/sources/moderations/api/src/:id/$procedures/rejected.ts +++ b/sources/moderations/api/src/:id/$procedures/rejected.ts @@ -3,13 +3,15 @@ import { zValidator } from "@hono/zod-validator"; import type { Htmx_Header } from "@~/app.core/htmx"; import { Entity_Schema } from "@~/app.core/schema"; +import { CrispApi } from "@~/crisp.lib/api"; import { set_crisp_config } from "@~/crisp.middleware"; import { type RejectedModeration_Context } from "@~/moderations.lib/context/rejected"; import { MODERATION_EVENTS } from "@~/moderations.lib/event"; import { reject_form_schema } from "@~/moderations.lib/schema/rejected.form"; import { mark_moderation_as } from "@~/moderations.lib/usecase/mark_moderation_as"; -import { send_rejected_message_to_user } from "@~/moderations.lib/usecase/send_rejected_message_to_user"; -import { GetModeration } from "@~/moderations.repository"; +import { RespondToTicket } from "@~/moderations.lib/usecase/RespondToTicket"; +import { SendRejectedMessageToUser } from "@~/moderations.lib/usecase/SendRejectedMessageToUser"; +import { GetModeration, UpdateModerationById } from "@~/moderations.repository"; import { Hono } from "hono"; import type { ContextType } from "./context"; @@ -30,8 +32,11 @@ export default new Hono().patch( const get_moderation = GetModeration(identite_pg); const moderation = await get_moderation(moderation_id); + const crisp = CrispApi(crisp_config); + const update_moderation_by_id = UpdateModerationById({ pg: identite_pg }); + const respond_to_ticket = RespondToTicket(); const context: RejectedModeration_Context = { - crisp_config, + crisp, moderation, pg: identite_pg, resolve_delay: config.CRISP_RESOLVE_DELAY, @@ -39,6 +44,10 @@ export default new Hono().patch( subject, userinfo, }; + const send_rejected_message_to_user = SendRejectedMessageToUser({ + respond_to_ticket, + update_moderation_by_id, + }); await send_rejected_message_to_user(context, { message, reason, diff --git a/sources/moderations/api/src/:id/context.ts b/sources/moderations/api/src/:id/context.ts index cd340a8ca..7a3e5d3c6 100644 --- a/sources/moderations/api/src/:id/context.ts +++ b/sources/moderations/api/src/:id/context.ts @@ -1,33 +1,104 @@ // +import { z_email_domain } from "@~/app.core/schema/z_email_domain"; import type { App_Context } from "@~/app.middleware/context"; import { urls } from "@~/app.urls"; -import type { GetModerationWithDetailsDto } from "@~/moderations.repository"; -import type { GetFicheOrganizationByIdHandler } from "@~/organizations.lib/usecase"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; import { - type GetDomainCountDto, - type GetOrganizationMemberDto, - type GetOrganizationMembersCountDto, + GetModerationWithDetails, + type GetModerationWithDetailsDto, +} from "@~/moderations.repository"; +import { + GetDomainCount, + GetOrganizationById, + GetOrganizationMember, + GetOrganizationMembersCount, } from "@~/organizations.repository"; +import { to } from "await-to-js"; import type { Env, InferRequestType } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; // +export async function loadModerationPageVariables( + pg: IdentiteProconnect_PgDatabase, + { id }: { id: number }, +) { + const get_moderation_with_details = GetModerationWithDetails(pg); + const [moderation_error, moderation] = await to( + get_moderation_with_details(id), + ); + + if (moderation_error) { + throw moderation_error; + } + + // + + const domain = z_email_domain.parse(moderation.user.email, { + path: ["moderation.users.email"], + }); + + // + + const get_organization_member = GetOrganizationMember(pg); + const organization_member = await get_organization_member({ + organization_id: moderation.organization_id, + user_id: moderation.user.id, + }); + + // + + const get_organization_by_id = GetOrganizationById(pg, { + columns: { + cached_activite_principale: true, + cached_adresse: true, + cached_categorie_juridique: true, + cached_code_officiel_geographique: true, + cached_code_postal: true, + cached_enseigne: true, + cached_est_active: true, + cached_etat_administratif: true, + cached_libelle_activite_principale: true, + cached_libelle_categorie_juridique: true, + cached_libelle_tranche_effectif: true, + cached_libelle: true, + cached_nom_complet: true, + cached_tranche_effectifs: true, + created_at: true, + id: true, + siret: true, + updated_at: true, + }, + }); + const organization_fiche = await get_organization_by_id( + moderation.organization_id, + ); + + const get_organization_members_count = GetOrganizationMembersCount(pg); + const get_domain_count = GetDomainCount(pg); + + // + + return { + domain, + moderation, + organization_fiche, + organization_member, + query_domain_count: get_domain_count(moderation.organization_id), + query_organization_members_count: get_organization_members_count( + moderation.organization_id, + ), + }; +} + export interface ModerationContext extends Env { Variables: { moderation: GetModerationWithDetailsDto; }; } export interface ContextVariablesType extends Env { - Variables: { - domain: string; - moderation: GetModerationWithDetailsDto; - organization_member: GetOrganizationMemberDto; - organization_fiche: Awaited>; - query_organization_members_count: Promise; - query_domain_count: Promise; - }; + Variables: Awaited>; } export type ContextType = App_Context & ContextVariablesType; diff --git a/sources/moderations/api/src/:id/duplicate_warning/Duplicate_Warning.tsx b/sources/moderations/api/src/:id/duplicate_warning/Duplicate_Warning.tsx index 9327b7b04..b5a83a1be 100644 --- a/sources/moderations/api/src/:id/duplicate_warning/Duplicate_Warning.tsx +++ b/sources/moderations/api/src/:id/duplicate_warning/Duplicate_Warning.tsx @@ -2,67 +2,70 @@ import { NotFoundError } from "@~/app.core/error"; import { Htmx_Events } from "@~/app.core/htmx"; -import type { IdentiteProconnect_Pg_Context } from "@~/app.middleware/set_identite_pg"; import { button } from "@~/app.ui/button"; import { fieldset } from "@~/app.ui/form"; import { OpenInZammad, SearchInZammad } from "@~/app.ui/zammad/components"; import { hx_urls, urls } from "@~/app.urls"; -import { schema } from "@~/identite-proconnect.database"; +import { + schema, + type IdentiteProconnect_PgDatabase, +} from "@~/identite-proconnect.database"; import { GetDuplicateModerations, type GetDuplicateModerationsDto, } from "@~/moderations.repository"; -import { GetUserById, type GetUserByIdDto } from "@~/users.repository"; +import { GetUserById } from "@~/users.repository"; import { get_zammad_mail } from "@~/zammad.lib/get_zammad_mail"; +import { usePageRequestContext } from "./context"; import { to } from "await-to-js"; import { and, asc, eq, ilike, not, or } from "drizzle-orm"; import { raw } from "hono/html"; import { createContext, useContext } from "hono/jsx"; -import { useRequestContext } from "hono/jsx-renderer"; // -export async function Duplicate_Warning({ - moderation_id, - organization_id, - user_id, -}: { - moderation_id: number; - organization_id: number; - user_id: number; -}) { - const { - var: { identite_pg }, - } = useRequestContext(); - const get_duplicate_moderations = GetDuplicateModerations(identite_pg); - const moderations = await get_duplicate_moderations({ - organization_id, - user_id, - }); - - const get_user_by_id = GetUserById(identite_pg); - const user = await get_user_by_id(user_id); - if (!user) return

Utilisateur introuvable

; - +export async function Duplicate_Warning() { return ( - + <> - + ); } -Duplicate_Warning.Context = createContext({ - moderation_id: NaN, - moderations: {} as GetDuplicateModerationsDto, - user: {} as NonNullable, -}); +async function createDuplicateWarningContextValues( + pg: IdentiteProconnect_PgDatabase, + { + organization_id, + user_id, + moderation_id, + }: { organization_id: number; user_id: number; moderation_id: number }, +) { + const get_duplicate_moderations = GetDuplicateModerations(pg); + + const get_user_by_id = GetUserById(pg, { + columns: { + id: true, + email: true, + given_name: true, + family_name: true, + }, + }); + + return { + moderation_id, + moderations: await get_duplicate_moderations({ + organization_id, + user_id, + }), + user: await get_user_by_id(user_id), + }; +} + +Duplicate_Warning.queryContextValues = createDuplicateWarningContextValues; +Duplicate_Warning.Context = createContext( + {} as Awaited>, +); // @@ -206,7 +209,7 @@ function get_moderation_tickets(moderations: GetDuplicateModerationsDto) { async function get_moderation(moderation_id: number) { const { var: { identite_pg }, - } = useRequestContext(); + } = usePageRequestContext(); const moderation = await identite_pg.query.moderations.findFirst({ columns: { moderated_at: true }, @@ -219,7 +222,7 @@ async function get_moderation(moderation_id: number) { async function get_duplicate_users(moderation_id: number) { const { var: { identite_pg }, - } = useRequestContext(); + } = usePageRequestContext(); const moderation = await identite_pg.query.moderations.findFirst({ columns: { organization_id: true }, diff --git a/sources/moderations/api/src/:id/duplicate_warning/context.ts b/sources/moderations/api/src/:id/duplicate_warning/context.ts new file mode 100644 index 000000000..90865553b --- /dev/null +++ b/sources/moderations/api/src/:id/duplicate_warning/context.ts @@ -0,0 +1,63 @@ +// + +import { Entity_Schema } from "@~/app.core/schema"; +import type { App_Context } from "@~/app.middleware/context"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; +import type { Env } from "hono"; +import { useRequestContext } from "hono/jsx-renderer"; +import { z } from "zod"; +import { Duplicate_Warning } from "./Duplicate_Warning"; + +// + +export async function loadDuplicateWarningPageVariables( + pg: IdentiteProconnect_PgDatabase, + { + moderation_id, + organization_id, + user_id, + }: { + moderation_id: number; + organization_id: number; + user_id: number; + }, +) { + const value = await Duplicate_Warning.queryContextValues(pg, { + moderation_id, + organization_id, + user_id, + }); + + return value; +} + +// + +export interface ContextVariablesType extends Env { + Variables: Awaited>; +} +export type ContextType = App_Context & ContextVariablesType; + +// + +export const QuerySchema = z.object({ + organization_id: z.string().pipe(z.coerce.number().int().nonnegative()), + user_id: z.string().pipe(z.coerce.number().int().nonnegative()), +}); + +export const ParamSchema = Entity_Schema; + +// + +type PageInputType = { + out: { + param: z.input; + query: z.input; + }; +}; + +export const usePageRequestContext = useRequestContext< + ContextType, + any, + PageInputType +>; diff --git a/sources/moderations/api/src/:id/duplicate_warning/index.e2e.test.ts b/sources/moderations/api/src/:id/duplicate_warning/index.e2e.test.ts index 31a9935ce..6ffee2c74 100644 --- a/sources/moderations/api/src/:id/duplicate_warning/index.e2e.test.ts +++ b/sources/moderations/api/src/:id/duplicate_warning/index.e2e.test.ts @@ -16,6 +16,7 @@ import { } from "@~/identite-proconnect.database/testing"; import { beforeAll, beforeEach, expect, test } from "bun:test"; import { Hono } from "hono"; +import { format } from "prettier"; import app from "./index"; // @@ -29,7 +30,8 @@ test("GET /moderations/:id/duplicate_warning", async () => { const organization_id = await create_unicorn_organization(pg); const user_id = await create_adora_pony_user(pg); - const moderation_id = await create_adora_pony_moderation(pg, { type: "" }); + await create_adora_pony_moderation(pg, { type: "0️⃣" }); + const moderation_id = await create_adora_pony_moderation(pg, { type: "1️⃣" }); const query_params = new URLSearchParams({ organization_id: organization_id.toString(), user_id: user_id.toString(), @@ -53,4 +55,45 @@ test("GET /moderations/:id/duplicate_warning", async () => { if (response.status >= 400) throw await response.text(); expect(response.status).toBe(200); + expect(format(await response.text(), { parser: "html" })).resolves + .toMatchInlineSnapshot(` + " +
+

Attention : demande multiples

+

Il s'agit de la 2e demande pour cette organisation

+ Trouver les echanges pour l'email « adora.pony@unicorn.xyz » dans + Zammad + +
+
+
+ +
+
+
+
+ " + `); }); diff --git a/sources/moderations/api/src/:id/duplicate_warning/index.tsx b/sources/moderations/api/src/:id/duplicate_warning/index.tsx index 2e8d8fe93..a39edde68 100644 --- a/sources/moderations/api/src/:id/duplicate_warning/index.tsx +++ b/sources/moderations/api/src/:id/duplicate_warning/index.tsx @@ -1,35 +1,43 @@ // import { zValidator } from "@hono/zod-validator"; -import { Entity_Schema } from "@~/app.core/schema"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; -import { z } from "zod"; import { Duplicate_Warning } from "./Duplicate_Warning"; +import { + loadDuplicateWarningPageVariables, + ParamSchema, + QuerySchema, + type ContextType, +} from "./context"; // -export default new Hono().get( +export default new Hono().get( "/", jsxRenderer(), - zValidator("param", Entity_Schema), - zValidator( - "query", - z.object({ - organization_id: z.string().pipe(z.coerce.number().int().nonnegative()), - user_id: z.string().pipe(z.coerce.number().int().nonnegative()), - }), - ), - async function GET({ render, req }) { - const { id } = req.valid("param"); + zValidator("param", ParamSchema), + zValidator("query", QuerySchema), + async function set_variables_middleware( + { req, set, var: { identite_pg } }, + next, + ) { + const { id: moderation_id } = req.valid("param"); const { organization_id, user_id } = req.valid("query"); - + const variables = await loadDuplicateWarningPageVariables(identite_pg, { + moderation_id, + organization_id, + user_id, + }); + set_variables(set, variables); + return next(); + }, + async function GET({ render, var: variables }) { return render( - , + + + , ); }, ); diff --git a/sources/moderations/api/src/:id/email/index.tsx b/sources/moderations/api/src/:id/email/index.tsx index 43dad575c..d4c23c058 100644 --- a/sources/moderations/api/src/:id/email/index.tsx +++ b/sources/moderations/api/src/:id/email/index.tsx @@ -2,17 +2,12 @@ import { zValidator } from "@hono/zod-validator"; import { DescribedBy_Schema, Entity_Schema } from "@~/app.core/schema"; -import { get_crisp_mail } from "@~/crisp.lib"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { set_crisp_config } from "@~/crisp.middleware"; -import { GetCripsFromSessionId } from "@~/moderations.lib/usecase/GetCripsFromSessionId"; -import { GetZammadFromTicketId } from "@~/moderations.lib/usecase/GetZammadFromTicketId"; -import { GetModerationForEmail } from "@~/moderations.repository"; -import { get_zammad_mail } from "@~/zammad.lib/get_zammad_mail"; -import { to } from "await-to-js"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; import Page from "./page"; -import { type ContextType } from "./page/context"; +import { loadEmailPageVariables, type ContextType } from "./page/context"; // @@ -22,67 +17,18 @@ export default new Hono().get( zValidator("param", Entity_Schema), zValidator("query", DescribedBy_Schema), set_crisp_config(), - function set_constants({ set }, next) { - set("MAX_ARTICLE_COUNT", 3); - return next(); - }, - async function set_moderation({ req, set, var: { identite_pg } }, next) { - const { id: moderation_id } = req.valid("param"); - const get_moderation_for_email = GetModerationForEmail(identite_pg); - const moderation = await get_moderation_for_email(moderation_id); - set("moderation", moderation); - return next(); - }, - async function set_query_zammad_mail( - { - set, - var: { - MAX_ARTICLE_COUNT, - moderation: { ticket_id }, - }, - }, - next, - ) { - if (!ticket_id) return next(); - - const get_zammad_from_ticket_id = GetZammadFromTicketId({ - fetch_zammad_mail: get_zammad_mail, - }); - const [zammad_err, zammad] = await to( - get_zammad_from_ticket_id({ - ticket_id, - limit: MAX_ARTICLE_COUNT, - }), - ); - if (zammad_err) return next(); - - set("zammad", zammad); - return next(); - }, - async function set_query_crisp_mail( - { - set, - var: { - crisp_config, - MAX_ARTICLE_COUNT, - moderation: { ticket_id: session_id }, - }, - }, + async function set_variables_middleware( + { req, set, var: { identite_pg, crisp_config } }, next, ) { - if (!session_id) return next(); - const get_crisp_from_session_id = GetCripsFromSessionId({ + const { id } = req.valid("param"); + const variables = await loadEmailPageVariables(identite_pg, { + id, crisp_config, - fetch_crisp_mail: get_crisp_mail, }); - const [crip_err, crip] = await to( - get_crisp_from_session_id({ session_id, limit: MAX_ARTICLE_COUNT }), - ); - if (crip_err) return next(); - set("crisp", crip); + set_variables(set, variables); return next(); }, - async function GET({ render }) { return render(); }, diff --git a/sources/moderations/api/src/:id/email/page/context.ts b/sources/moderations/api/src/:id/email/page/context.ts index 7922f5c09..061626242 100644 --- a/sources/moderations/api/src/:id/email/page/context.ts +++ b/sources/moderations/api/src/:id/email/page/context.ts @@ -2,32 +2,78 @@ import { DescribedBy_Schema, Entity_Schema } from "@~/app.core/schema"; import type { App_Context } from "@~/app.middleware/context"; +import { get_crisp_mail } from "@~/crisp.lib"; import type { Crisp_Context } from "@~/crisp.middleware"; -import type { GetCripsFromSessionIdHandler } from "@~/moderations.lib/usecase/GetCripsFromSessionId"; -import type { GetModerationForEmailDto } from "@~/moderations.repository"; -import { type get_zammad_mail_dto } from "@~/zammad.lib/get_zammad_mail"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; +import { GetCripsFromSessionId } from "@~/moderations.lib/usecase/GetCripsFromSessionId"; +import { GetZammadFromTicketId } from "@~/moderations.lib/usecase/GetZammadFromTicketId"; +import { GetModerationForEmail } from "@~/moderations.repository"; +import { get_zammad_mail } from "@~/zammad.lib/get_zammad_mail"; +import { to } from "await-to-js"; import { type Env } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; import type { z } from "zod"; // -export interface ContextVariablesType extends Env { - Variables: { - MAX_ARTICLE_COUNT: number; - // - crisp?: Awaited>; - moderation: GetModerationForEmailDto; - zammad: - | undefined - | { - articles: get_zammad_mail_dto; - show_more: boolean; - subject: string; - ticket_id: string; - }; +export async function loadEmailPageVariables( + pg: IdentiteProconnect_PgDatabase, + { id, crisp_config }: { id: number; crisp_config: any }, +) { + const MAX_ARTICLE_COUNT = 3; + + // Load moderation data + const get_moderation_for_email = GetModerationForEmail(pg); + const moderation = await get_moderation_for_email(id); + + // Load zammad data if ticket_id exists + if (moderation.ticket_id) { + const get_zammad_from_ticket_id = GetZammadFromTicketId({ + fetch_zammad_mail: get_zammad_mail, + }); + const [, zammad_result] = await to( + get_zammad_from_ticket_id({ + ticket_id: moderation.ticket_id, + limit: MAX_ARTICLE_COUNT, + }), + ); + return { + MAX_ARTICLE_COUNT, + moderation, + zammad: zammad_result, + }; + } + + // Load crisp data if session_id exists (using ticket_id as session_id) + if (moderation.ticket_id) { + const get_crisp_from_session_id = GetCripsFromSessionId({ + crisp_config, + fetch_crisp_mail: get_crisp_mail, + }); + const [, crisp_result] = await to( + get_crisp_from_session_id({ + session_id: moderation.ticket_id, + limit: MAX_ARTICLE_COUNT, + }), + ); + return { + MAX_ARTICLE_COUNT, + moderation, + crisp: crisp_result, + }; + } + + return { + MAX_ARTICLE_COUNT, + moderation, }; } + +// + +export interface ContextVariablesType extends Env { + Variables: Awaited>; +} export type ContextType = App_Context & Crisp_Context & ContextVariablesType; // diff --git a/sources/moderations/api/src/:id/index.tsx b/sources/moderations/api/src/:id/index.tsx index 3adc5fa44..86eb57508 100644 --- a/sources/moderations/api/src/:id/index.tsx +++ b/sources/moderations/api/src/:id/index.tsx @@ -3,24 +3,13 @@ import { zValidator } from "@hono/zod-validator"; import { NotFoundError } from "@~/app.core/error"; import { Entity_Schema } from "@~/app.core/schema"; -import { z_email_domain } from "@~/app.core/schema/z_email_domain"; import { Main_Layout } from "@~/app.layout/index"; -import type { App_Context } from "@~/app.middleware/context"; -import { set_context_variables } from "@~/app.middleware/set_context_variables"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { moderation_type_to_title } from "@~/moderations.lib/moderation_type.mapper"; -import { GetModerationWithDetails } from "@~/moderations.repository"; -import { GetFicheOrganizationById } from "@~/organizations.lib/usecase"; -import { - GetDomainCount, - GetOrganizationMember, - GetOrganizationMembersCount, -} from "@~/organizations.repository"; -import { to } from "await-to-js"; import { Hono } from "hono"; -import { getContext } from "hono/context-storage"; import { jsxRenderer } from "hono/jsx-renderer"; import moderation_procedures_router from "./$procedures"; -import { type ContextType, type ContextVariablesType } from "./context"; +import { loadModerationPageVariables, type ContextType } from "./context"; import duplicate_warning_router from "./duplicate_warning"; import moderation_email_router from "./email/index"; import { Moderation_NotFound } from "./not-found"; @@ -33,67 +22,26 @@ export default new Hono() "/", jsxRenderer(Main_Layout), zValidator("param", Entity_Schema), - async function set_moderation({ render, req, set, status }, next) { - const { identite_pg } = getContext().var; - const { id: moderation_id } = req.valid("param"); - - const get_moderation_with_details = GetModerationWithDetails(identite_pg); - const [moderation_error, moderation] = await to( - get_moderation_with_details(moderation_id), - ); - - if (moderation_error instanceof NotFoundError) { - status(404); - return render(); - } else if (moderation_error) { - throw moderation_error; + async function set_variables_middleware( + { render, req, set, status, var: { identite_pg } }, + next, + ) { + const { id } = req.valid("param"); + + try { + const variables = await loadModerationPageVariables(identite_pg, { + id, + }); + set_variables(set, variables); + return next(); + } catch (error) { + if (error instanceof NotFoundError) { + status(404); + return render(); + } + throw error; } - - set("moderation", moderation); - return next(); }, - set_context_variables(async () => { - const { moderation, identite_pg } = getContext().var; - - // - - const domain = z_email_domain.parse(moderation.user.email, { - path: ["moderation.users.email"], - }); - - // - - const get_organization_member = GetOrganizationMember(identite_pg); - const organization_member = await get_organization_member({ - organization_id: moderation.organization_id, - user_id: moderation.user.id, - }); - - // - - const get_fiche_organization_by_id = GetFicheOrganizationById({ - pg: identite_pg, - }); - const organization_fiche = await get_fiche_organization_by_id( - moderation.organization_id, - ); - const get_organization_members_count = - GetOrganizationMembersCount(identite_pg); - const get_domain_count = GetDomainCount(identite_pg); - - // - - return { - domain, - moderation, - organization_fiche, - organization_member, - query_domain_count: get_domain_count(moderation.organization_id), - query_organization_members_count: get_organization_members_count( - moderation.organization_id, - ), - }; - }), function GET({ render, set, var: { moderation } }) { set( "page_title", diff --git a/sources/moderations/api/src/context.ts b/sources/moderations/api/src/context.ts index 1bb0d35a9..0c6714295 100644 --- a/sources/moderations/api/src/context.ts +++ b/sources/moderations/api/src/context.ts @@ -3,8 +3,11 @@ import { Pagination_Schema, type Pagination } from "@~/app.core/schema"; import { z_coerce_boolean } from "@~/app.core/schema/z_coerce_boolean"; import { z_empty_string_to_undefined } from "@~/app.core/schema/z_empty_string_to_undefined"; +import type { App_Context } from "@~/app.middleware/context"; import type { GetModerationsListHandler } from "@~/moderations.repository"; +import type { Env } from "hono"; import { createContext } from "hono/jsx"; +import { useRequestContext } from "hono/jsx-renderer"; import { z } from "zod"; // @@ -31,6 +34,30 @@ export type GetModerationsListDTO = Awaited< ReturnType >; +export async function loadModerationsListPageVariables({ + pagination, + search, +}: { + pagination: Pagination; + search: Search; +}) { + return { + pagination, + search, + }; +} + +// + +export interface ContextVariablesType extends Env { + Variables: Awaited>; +} +export type ContextType = App_Context & ContextVariablesType; + +// + +export const usePageRequestContext = useRequestContext; + export default createContext({ query_moderations_list: {} as Promise, pagination: {} as Pagination, diff --git a/sources/moderations/api/src/index.tsx b/sources/moderations/api/src/index.tsx index 99aa34794..815f313c6 100644 --- a/sources/moderations/api/src/index.tsx +++ b/sources/moderations/api/src/index.tsx @@ -3,43 +3,61 @@ import { Pagination_Schema } from "@~/app.core/schema"; import { Main_Layout } from "@~/app.layout/index"; import { authorized } from "@~/app.middleware/authorized"; -import type { App_Context } from "@~/app.middleware/context"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; -import { P, match } from "ts-pattern"; +import { match, P } from "ts-pattern"; import moderation_router from "./:id/index"; -import { Search_Schema } from "./context"; +import { + loadModerationsListPageVariables, + Search_Schema, + type ContextType, +} from "./context"; import { Moderations_Page } from "./page"; // -export default new Hono() +export default new Hono() .use(authorized()) .route("/:id", moderation_router) - .get("/", jsxRenderer(Main_Layout), function GET({ render, req, set }) { - const query = req.query(); + .get( + "/", + jsxRenderer(Main_Layout), + async function set_variables_middleware({ req, set }, next) { + const query = req.query(); - const search = match(Search_Schema.parse(query, { path: ["query"] })) - .with( - { search_email: P.not("") }, - { search_siret: P.not("") }, - (search) => ({ - ...search, - hide_join_organization: false, - hide_non_verified_domain: false, - processed_requests: true, - }), - ) - .otherwise((search) => search); + const search = match(Search_Schema.parse(query, { path: ["query"] })) + .with( + { search_email: P.not("") }, + { search_siret: P.not("") }, + (search) => ({ + ...search, + hide_join_organization: false, + hide_non_verified_domain: false, + processed_requests: true, + }), + ) + .otherwise((search) => search); - const pagination = match( - Pagination_Schema.safeParse(query, { path: ["query"] }), - ) - .with({ success: true }, ({ data }) => data) - .otherwise(() => Pagination_Schema.parse({})); + const pagination = match( + Pagination_Schema.safeParse(query, { path: ["query"] }), + ) + .with({ success: true }, ({ data }) => data) + .otherwise(() => Pagination_Schema.parse({})); - set("page_title", "Liste des moderations"); - return render(); - }); + const variables = await loadModerationsListPageVariables({ + pagination, + search, + }); + set_variables(set, variables); + return next(); + }, + function GET({ render, set, var: { pagination, search } }) { + set("page_title", "Liste des moderations"); + return render( + , + ); + }, + ); // diff --git a/sources/moderations/api/src/page.tsx b/sources/moderations/api/src/page.tsx index 68f72cf18..5f27e7e9e 100644 --- a/sources/moderations/api/src/page.tsx +++ b/sources/moderations/api/src/page.tsx @@ -6,7 +6,6 @@ import { } from "@~/app.core/date/date_format"; import { hx_include } from "@~/app.core/htmx"; import type { Pagination } from "@~/app.core/schema"; -import type { IdentiteProconnect_Pg_Context } from "@~/app.middleware/set_identite_pg"; import { Foot } from "@~/app.ui/hx_table"; import { row } from "@~/app.ui/table"; import { hx_urls, urls } from "@~/app.urls"; @@ -16,11 +15,11 @@ import { } from "@~/moderations.lib/moderation_type.mapper"; import { GetModerationsList } from "@~/moderations.repository"; import { useContext } from "hono/jsx"; -import { useRequestContext } from "hono/jsx-renderer"; import Moderations_Context, { MODERATION_TABLE_ID, MODERATION_TABLE_PAGE_ID, Page_Query, + usePageRequestContext, type GetModerationsListDTO, type Search, } from "./context"; @@ -54,7 +53,7 @@ export function Moderations_Page({ }) { const { var: { identite_pg }, - } = useRequestContext(); + } = usePageRequestContext(); const { page, page_size } = pagination; const { day: date, diff --git a/sources/moderations/lib/src/context/rejected.ts b/sources/moderations/lib/src/context/rejected.ts index 53366f62c..417e7860f 100644 --- a/sources/moderations/lib/src/context/rejected.ts +++ b/sources/moderations/lib/src/context/rejected.ts @@ -1,7 +1,7 @@ // import type { AgentConnect_UserInfo } from "@~/app.middleware/session"; -import type { Config } from "@~/crisp.lib/types"; +import type { CrispApi } from "@~/crisp.lib/api"; import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; import type { GetModerationDto } from "@~/moderations.repository"; import type { RejectedMessage } from "../schema/rejected.form"; @@ -9,7 +9,7 @@ import type { RejectedMessage } from "../schema/rejected.form"; // export type RejectedModeration_Context = { - crisp_config: Config; + crisp: CrispApi; moderation: GetModerationDto; pg: IdentiteProconnect_PgDatabase; reason: string; diff --git a/sources/moderations/lib/src/usecase/CreateAndSendEmailToUser.test.ts b/sources/moderations/lib/src/usecase/CreateAndSendEmailToUser.test.ts new file mode 100644 index 000000000..25be07e6e --- /dev/null +++ b/sources/moderations/lib/src/usecase/CreateAndSendEmailToUser.test.ts @@ -0,0 +1,141 @@ +// + +import { NotFoundError } from "@~/app.core/error"; +import { expect, mock, test } from "bun:test"; +import type { RejectedModeration_Context } from "../context/rejected"; +import { CreateAndSendEmailToUser } from "./CreateAndSendEmailToUser"; + +// + +test("creates conversation and sends email to user with clean dependency injection", async () => { + const mock_pg = { + query: { + users: { + findFirst: mock().mockResolvedValue({ + given_name: "Pink", + family_name: "Diamond", + }), + }, + }, + }; + + const mock_crisp = { + create_conversation: mock().mockResolvedValue({ + session_id: "session_123456789", + }), + }; + + const mock_update_moderation_by_id = mock().mockResolvedValue(undefined); + const mock_respond_to_ticket = mock().mockResolvedValue(undefined); + + const create_and_send_email_to_user = CreateAndSendEmailToUser({ + pg: mock_pg as any, + respond_to_ticket: mock_respond_to_ticket, + update_moderation_by_id: mock_update_moderation_by_id, + }); + + const context: RejectedModeration_Context = { + crisp: mock_crisp as any, + moderation: { + id: 1, + user_id: 123, + ticket_id: null, + } as any, + pg: mock_pg as any, + reason: "test reason", + resolve_delay: 0, + subject: "Test Subject", + userinfo: { email: "test@example.com" } as any, + }; + + const message = { + message: "Test message", + reason: "Test reason", + subject: "Test Subject", + to: "user@example.com", + }; + + await create_and_send_email_to_user(context, message); + + // Verify the core functionality we can control + expect(mock_pg.query.users.findFirst).toHaveBeenCalledWith({ + columns: { given_name: true, family_name: true }, + where: expect.any(Function), + }); + + // Verify clean dependency injection for crisp + expect(mock_crisp.create_conversation).toHaveBeenCalledWith({ + email: "user@example.com", + subject: "Test Subject", + nickname: "Pink Diamond", + }); + + // Verify clean dependency injection for update_moderation_by_id + expect(mock_update_moderation_by_id).toHaveBeenCalledWith(1, { + ticket_id: "session_123456789", + }); + + // Verify clean dependency injection for respond_to_ticket + expect(mock_respond_to_ticket).toHaveBeenCalledWith( + { + ...context, + moderation: { ...context.moderation, ticket_id: "session_123456789" }, + }, + { + message: "Test message", + reason: "Test reason", + subject: "Test Subject", + to: "session_123456789", + }, + ); +}); + +test("throws NotFoundError when user is not found", async () => { + const mock_pg = { + query: { + users: { + findFirst: mock().mockResolvedValue(null), + }, + }, + }; + + const mock_crisp = { + create_conversation: mock().mockResolvedValue({ + session_id: "session_123456789", + }), + }; + + const mock_update_moderation_by_id = mock().mockResolvedValue(undefined); + const mock_respond_to_ticket = mock().mockResolvedValue(undefined); + + const create_and_send_email_to_user = CreateAndSendEmailToUser({ + pg: mock_pg as any, + respond_to_ticket: mock_respond_to_ticket, + update_moderation_by_id: mock_update_moderation_by_id, + }); + + const context: RejectedModeration_Context = { + crisp: mock_crisp as any, + moderation: { + id: 1, + user_id: 999999, + ticket_id: null, + } as any, + pg: mock_pg as any, + reason: "test reason", + resolve_delay: 0, + subject: "Test Subject", + userinfo: { email: "test@example.com" } as any, + }; + + const message = { + message: "Test message", + reason: "Test reason", + subject: "Test Subject", + to: "user@example.com", + }; + + await expect( + async () => await create_and_send_email_to_user(context, message), + ).toThrow(NotFoundError); +}); diff --git a/sources/moderations/lib/src/usecase/CreateAndSendEmailToUser.ts b/sources/moderations/lib/src/usecase/CreateAndSendEmailToUser.ts new file mode 100644 index 000000000..542388316 --- /dev/null +++ b/sources/moderations/lib/src/usecase/CreateAndSendEmailToUser.ts @@ -0,0 +1,59 @@ +// + +import { NotFoundError } from "@~/app.core/error"; +import { z_username } from "@~/app.core/schema/z_username"; +import type { IdentiteProconnectDatabaseCradle } from "@~/identite-proconnect.database"; +import type { UpdateModerationByIdHandler } from "@~/moderations.repository"; +import type { + RejectedFullMessage, + RejectedModeration_Context, +} from "../context/rejected"; +import type { RespondToTicketHandler } from "./RespondToTicket"; + +// + +export function CreateAndSendEmailToUser({ + pg, + respond_to_ticket, + update_moderation_by_id, +}: IdentiteProconnectDatabaseCradle & { + respond_to_ticket: RespondToTicketHandler; + update_moderation_by_id: UpdateModerationByIdHandler; +}) { + return async function create_and_send_email_to_user( + context: RejectedModeration_Context, + { message, reason, subject, to }: RejectedFullMessage, + ) { + const { crisp, moderation } = context; + const user = await pg.query.users.findFirst({ + columns: { given_name: true, family_name: true }, + where: (table, { eq }) => eq(table.id, moderation.user_id), + }); + if (!user) throw new NotFoundError(`User not found`); + const nickname = z_username.parse({ + given_name: user.given_name, + usual_name: user.family_name, + }); + const { session_id } = await crisp.create_conversation({ + email: to, + subject, + nickname, + }); + + await update_moderation_by_id(moderation.id, { + ticket_id: session_id, + }); + + await respond_to_ticket( + { + ...context, + moderation: { ...context.moderation, ticket_id: session_id }, + }, + { message, reason, subject, to: session_id }, + ); + }; +} + +export type CreateAndSendEmailToUserHandler = ReturnType< + typeof CreateAndSendEmailToUser +>; diff --git a/sources/moderations/lib/src/usecase/respond_to_ticket.ts b/sources/moderations/lib/src/usecase/RespondToTicket.ts similarity index 66% rename from sources/moderations/lib/src/usecase/respond_to_ticket.ts rename to sources/moderations/lib/src/usecase/RespondToTicket.ts index 5eda541de..1315dc88b 100644 --- a/sources/moderations/lib/src/usecase/respond_to_ticket.ts +++ b/sources/moderations/lib/src/usecase/RespondToTicket.ts @@ -2,8 +2,7 @@ import { NotFoundError } from "@~/app.core/error"; import { z_username } from "@~/app.core/schema/z_username"; -import { get_user, is_crisp_ticket, send_message } from "@~/crisp.lib"; -import { CrispApi } from "@~/crisp.lib/api"; +import { is_crisp_ticket } from "@~/crisp.lib"; import { get_full_ticket, send_zammad_response } from "@~/zammad.lib"; import { ARTICLE_TYPE, @@ -20,45 +19,46 @@ import type { // -export async function respond_to_ticket( - context: RejectedModeration_Context, - full_message: RejectedFullMessage, -) { - return match(context.moderation.ticket_id) - .with(null, () => { - throw new NotFoundError("No existing ticket."); - }) - .when(is_crisp_ticket, () => respond_in_conversation(context, full_message)) - .when(is_zammad_ticket, () => - respond_to_zammad_ticket(context, full_message), - ) - .otherwise(() => { - throw new NotFoundError( - `Unknown provider for "${context.moderation.id}"`, - ); - }); +export function RespondToTicket() { + return async function respond_to_ticket( + context: RejectedModeration_Context, + full_message: RejectedFullMessage, + ) { + return match(context.moderation.ticket_id) + .with(null, () => { + throw new NotFoundError("No existing ticket."); + }) + .when(is_crisp_ticket, () => + respond_in_conversation(context, full_message), + ) + .when(is_zammad_ticket, () => + respond_to_zammad_ticket(context, full_message), + ) + .otherwise(() => { + throw new NotFoundError( + `Unknown provider for "${context.moderation.id}"`, + ); + }); + }; } +export type RespondToTicketHandler = ReturnType; + async function respond_in_conversation( - { - crisp_config, - moderation, - userinfo, - resolve_delay, - }: RejectedModeration_Context, + { crisp, moderation, userinfo, resolve_delay }: RejectedModeration_Context, { message }: { message: string }, ) { if (!moderation.ticket_id) throw new NotFoundError("Ticket not found."); const [, found_user] = await await_to( - get_user(crisp_config, { email: userinfo.email }), + crisp.get_user({ email: userinfo.email }), ); const user = found_user ?? { nickname: z_username.parse(userinfo), email: userinfo.email, }; - await send_message(crisp_config, { + await crisp.send_message({ content: message, user, session_id: moderation.ticket_id, @@ -68,8 +68,9 @@ async function respond_in_conversation( // Crisp seems to have a delay between the message being sent and the state being updated await new Promise((resolve) => setTimeout(resolve, resolve_delay)); - const { mark_conversation_as_resolved } = CrispApi(crisp_config); - await mark_conversation_as_resolved({ session_id: moderation.ticket_id }); + await crisp.mark_conversation_as_resolved({ + session_id: moderation.ticket_id, + }); } async function respond_to_zammad_ticket( diff --git a/sources/moderations/lib/src/usecase/SendRejectedMessageToUser.ts b/sources/moderations/lib/src/usecase/SendRejectedMessageToUser.ts new file mode 100644 index 000000000..f0f0531d3 --- /dev/null +++ b/sources/moderations/lib/src/usecase/SendRejectedMessageToUser.ts @@ -0,0 +1,47 @@ +import { NotFoundError } from "@~/app.core/error"; +import { z_username } from "@~/app.core/schema/z_username"; +import type { UpdateModerationByIdHandler } from "@~/moderations.repository"; +import { to as await_to } from "await-to-js"; +import consola from "consola"; +import type { RejectedModeration_Context } from "../context/rejected"; +import type { RejectedMessage } from "../schema/rejected.form"; +import { CreateAndSendEmailToUser } from "./CreateAndSendEmailToUser"; +import type { RespondToTicketHandler } from "./RespondToTicket"; + +// + +export function SendRejectedMessageToUser({ + respond_to_ticket, + update_moderation_by_id, +}: { + respond_to_ticket: RespondToTicketHandler; + update_moderation_by_id: UpdateModerationByIdHandler; +}) { + return async function send_rejected_message_to_user( + context: RejectedModeration_Context, + { message: text_body, reason, subject }: RejectedMessage, + ) { + const { moderation, userinfo } = context; + const username = z_username.parse(userinfo); + const body = text_body.concat(` \n\n${username}`); + const to = moderation.user.email; + + const [error] = await await_to( + respond_to_ticket(context, { message: body, reason, subject, to }), + ); + if (error instanceof NotFoundError) { + consola.info(error); + const create_and_send_email_to_user = CreateAndSendEmailToUser({ + pg: context.pg, + respond_to_ticket, + update_moderation_by_id, + }); + await create_and_send_email_to_user(context, { + message: body, + reason, + subject, + to, + }); + } + }; +} diff --git a/sources/moderations/lib/src/usecase/create_and_send_email_to_user.ts b/sources/moderations/lib/src/usecase/create_and_send_email_to_user.ts deleted file mode 100644 index ca4ab5957..000000000 --- a/sources/moderations/lib/src/usecase/create_and_send_email_to_user.ts +++ /dev/null @@ -1,47 +0,0 @@ -// - -import { NotFoundError } from "@~/app.core/error"; -import { z_username } from "@~/app.core/schema/z_username"; -import { create_conversation } from "@~/crisp.lib"; -import { UpdateModerationById } from "@~/moderations.repository"; -import type { - RejectedFullMessage, - RejectedModeration_Context, -} from "../context/rejected"; -import { respond_to_ticket } from "./respond_to_ticket"; - -// - -export async function create_and_send_email_to_user( - context: RejectedModeration_Context, - { message, reason, subject, to }: RejectedFullMessage, -) { - const { crisp_config, moderation, pg } = context; - const user = await pg.query.users.findFirst({ - columns: { given_name: true, family_name: true }, - where: (table, { eq }) => eq(table.id, moderation.user_id), - }); - if (!user) throw new NotFoundError(`User not found`); - const nickname = z_username.parse({ - given_name: user.given_name, - usual_name: user.family_name, - }); - const { session_id } = await create_conversation(crisp_config, { - email: to, - subject, - nickname, - }); - - const update_moderation_by_id = UpdateModerationById({ pg }); - await update_moderation_by_id(moderation.id, { - ticket_id: session_id, - }); - - await respond_to_ticket( - { - ...context, - moderation: { ...context.moderation, ticket_id: session_id }, - }, - { message, reason, subject, to: session_id }, - ); -} diff --git a/sources/moderations/lib/src/usecase/send_rejected_message_to_user.ts b/sources/moderations/lib/src/usecase/send_rejected_message_to_user.ts deleted file mode 100644 index 2ffb9b665..000000000 --- a/sources/moderations/lib/src/usecase/send_rejected_message_to_user.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NotFoundError } from "@~/app.core/error"; -import { z_username } from "@~/app.core/schema/z_username"; -import { to as await_to } from "await-to-js"; -import consola from "consola"; -import type { RejectedModeration_Context } from "../context/rejected"; -import type { RejectedMessage } from "../schema/rejected.form"; -import { create_and_send_email_to_user } from "./create_and_send_email_to_user"; -import { respond_to_ticket } from "./respond_to_ticket"; - -// - -export async function send_rejected_message_to_user( - context: RejectedModeration_Context, - { message: text_body, reason, subject }: RejectedMessage, -) { - const { moderation, userinfo } = context; - const username = z_username.parse(userinfo); - const body = text_body.concat(` \n\n${username}`); - const to = moderation.user.email; - - const [error] = await await_to( - respond_to_ticket(context, { message: body, reason, subject, to }), - ); - if (error instanceof NotFoundError) { - consola.info(error); - await create_and_send_email_to_user(context, { - message: body, - reason, - subject, - to, - }); - } -} diff --git a/sources/organizations/api/src/:id/Fiche.tsx b/sources/organizations/api/src/:id/Fiche.tsx index 2d266aedc..e13094193 100644 --- a/sources/organizations/api/src/:id/Fiche.tsx +++ b/sources/organizations/api/src/:id/Fiche.tsx @@ -8,7 +8,7 @@ import { usePageRequestContext } from "./context"; export async function Fiche() { const { - var: { organization, organization_fiche }, + var: { organization }, } = usePageRequestContext(); return ( @@ -17,7 +17,7 @@ export async function Fiche() {

« {organization.cached_libelle} »

- +
diff --git a/sources/organizations/api/src/:id/context.ts b/sources/organizations/api/src/:id/context.ts index e7e617b19..ddf9c6434 100644 --- a/sources/organizations/api/src/:id/context.ts +++ b/sources/organizations/api/src/:id/context.ts @@ -2,42 +2,58 @@ import type { App_Context } from "@~/app.middleware/context"; import { urls } from "@~/app.urls"; -import type { Organization } from "@~/organizations.lib/entities/Organization"; -import type { GetFicheOrganizationByIdHandler } from "@~/organizations.lib/usecase"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; import { - type GetDomainCountDto, - type GetOrganizationMembersCountDto, + GetDomainCount, + GetOrganizationById, + GetOrganizationMembersCount, } from "@~/organizations.repository"; import type { Env, InferRequestType } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; + // +export async function loadOrganizationPageVariables( + pg: IdentiteProconnect_PgDatabase, + { id }: { id: number }, +) { + const get_organization_by_id = GetOrganizationById(pg, { + columns: { + cached_activite_principale: true, + cached_adresse: true, + cached_categorie_juridique: true, + cached_code_officiel_geographique: true, + cached_code_postal: true, + cached_enseigne: true, + cached_est_active: true, + cached_etat_administratif: true, + cached_libelle_activite_principale: true, + cached_libelle_categorie_juridique: true, + cached_libelle_tranche_effectif: true, + cached_libelle: true, + cached_nom_complet: true, + cached_tranche_effectifs: true, + created_at: true, + id: true, + siret: true, + updated_at: true, + }, + }); -type FicheOrganization = Pick< - Organization, - | "cached_activite_principale" - | "cached_adresse" - | "cached_code_postal" - | "cached_est_active" - | "cached_etat_administratif" - | "cached_libelle_categorie_juridique" - | "cached_libelle_tranche_effectif" - | "cached_libelle" - | "cached_nom_complet" - | "cached_tranche_effectifs" - | "created_at" - | "id" - | "siret" - | "updated_at" ->; + const organization = await get_organization_by_id(id); -export interface ContextVariablesType extends Env { - Variables: { - organization_fiche: Awaited>; - organization: FicheOrganization; - query_organization_domains_count: Promise; - query_organization_members_count: Promise; + const query_organization_domains_count = GetDomainCount(pg)(id); + const query_organization_members_count = GetOrganizationMembersCount(pg)(id); + + return { + organization, + query_organization_domains_count, + query_organization_members_count, }; } + +export interface ContextVariablesType extends Env { + Variables: Awaited>; +} export type ContextType = App_Context & ContextVariablesType; // diff --git a/sources/organizations/api/src/:id/domains/context.ts b/sources/organizations/api/src/:id/domains/context.ts index 2c2ef99a3..dacd1dbec 100644 --- a/sources/organizations/api/src/:id/domains/context.ts +++ b/sources/organizations/api/src/:id/domains/context.ts @@ -2,13 +2,27 @@ import type { App_Context } from "@~/app.middleware/context"; import { urls } from "@~/app.urls"; -import type { get_orginization_domains_dto } from "@~/organizations.repository/get_orginization_domains"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; +import { get_orginization_domains } from "@~/organizations.repository/get_orginization_domains"; import type { Env, InferRequestType } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; import { z } from "zod"; // +export async function loadDomainsPageVariables( + pg: IdentiteProconnect_PgDatabase, + { organization_id }: { organization_id: number }, +) { + const domains = await get_orginization_domains({ pg }, { organization_id }); + + return { + domains, + }; +} + +// + const $get = urls.organizations[":id"].domains.$get; type PageInputType = { @@ -18,9 +32,7 @@ type PageInputType = { // interface ContextVariablesType extends Env { - Variables: { - domains: get_orginization_domains_dto; - }; + Variables: Awaited>; } export type ContextType = App_Context & ContextVariablesType; diff --git a/sources/organizations/api/src/:id/domains/index.tsx b/sources/organizations/api/src/:id/domains/index.tsx index a95a72d47..9f4eb89e1 100644 --- a/sources/organizations/api/src/:id/domains/index.tsx +++ b/sources/organizations/api/src/:id/domains/index.tsx @@ -7,18 +7,18 @@ import { Entity_Schema, Id_Schema, } from "@~/app.core/schema"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { EmailDomain_Type_Schema } from "@~/identite-proconnect.lib/email_domain"; import { ORGANISATION_EVENTS } from "@~/organizations.lib/event"; import { AddAuthorizedDomain, RemoveDomainEmailById, } from "@~/organizations.lib/usecase"; -import { get_orginization_domains } from "@~/organizations.repository/get_orginization_domains"; import { update_domain_by_id } from "@~/organizations.repository/update_domain_by_id"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; import { z } from "zod"; -import type { ContextType } from "./context"; +import { loadDomainsPageVariables, type ContextType } from "./context"; import { Add_Domain, Table } from "./Table"; // @@ -33,15 +33,15 @@ export default new Hono() jsxRenderer(), zValidator("param", Entity_Schema), zValidator("query", DescribedBy_Schema), - async function set_domains({ req, set, var: { identite_pg } }, next) { + async function set_variables_middleware( + { req, set, var: { identite_pg } }, + next, + ) { const { id: organization_id } = req.valid("param"); - const domains = await get_orginization_domains( - { pg: identite_pg }, - { - organization_id: organization_id, - }, - ); - set("domains", domains); + const variables = await loadDomainsPageVariables(identite_pg, { + organization_id, + }); + set_variables(set, variables); return next(); }, async function GET({ render }) { diff --git a/sources/organizations/api/src/:id/index.tsx b/sources/organizations/api/src/:id/index.tsx index ece46e689..cefa3aa62 100644 --- a/sources/organizations/api/src/:id/index.tsx +++ b/sources/organizations/api/src/:id/index.tsx @@ -1,24 +1,14 @@ // import { zValidator } from "@hono/zod-validator"; -import { NotFoundError } from "@~/app.core/error"; import { Entity_Schema } from "@~/app.core/schema"; import { Main_Layout } from "@~/app.layout"; -import { set_context_variables } from "@~/app.middleware/set_context_variables"; -import { GetFicheOrganizationById } from "@~/organizations.lib/usecase"; -import { - GetDomainCount, - GetOrganizationById, - GetOrganizationMembersCount, -} from "@~/organizations.repository"; -import { to as await_to } from "await-to-js"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { Hono } from "hono"; -import { getContext } from "hono/context-storage"; import { jsxRenderer } from "hono/jsx-renderer"; -import type { ContextType, ContextVariablesType } from "./context"; +import { loadOrganizationPageVariables, type ContextType } from "./context"; import organization_domains_router from "./domains"; import organization_members_router from "./members"; -import { Organization_NotFound } from "./not-found"; import Organization_Page from "./page"; // @@ -28,77 +18,19 @@ export default new Hono() .get( "/", zValidator("param", Entity_Schema), - async function set_organization( - { render, req, set, status, var: { identite_pg } }, + async function set_variables_middleware( + { req, set, var: { identite_pg } }, next, ) { const { id } = req.valid("param"); - const get_organization_by_id = GetOrganizationById({ - pg: identite_pg, - }); - const [error, organization] = await await_to( - get_organization_by_id(id, { - columns: { - cached_activite_principale: true, - cached_adresse: true, - cached_code_postal: true, - cached_est_active: true, - cached_etat_administratif: true, - cached_libelle_categorie_juridique: true, - cached_libelle_tranche_effectif: true, - cached_libelle: true, - cached_nom_complet: true, - cached_tranche_effectifs: true, - created_at: true, - id: true, - siret: true, - updated_at: true, - }, + set_variables( + set, + await loadOrganizationPageVariables(identite_pg, { + id, }), ); - - if (error instanceof NotFoundError) { - status(404); - return render( - , - ); - } else if (error) { - throw error; - } - - set("organization", organization); return next(); }, - async function set_query_organization_members_count( - { set, var: { organization, identite_pg } }, - next, - ) { - set( - "query_organization_members_count", - GetOrganizationMembersCount(identite_pg)(organization.id), - ); - return next(); - }, - async function set_context(ctx, next) { - const { - var: { identite_pg }, - } = getContext(); - const { id: organization_id } = ctx.req.valid("param"); - const get_fiche_organization_by_id = GetFicheOrganizationById({ - pg: identite_pg, - }); - const organization_fiche = - await get_fiche_organization_by_id(organization_id); - const query_organization_domains_count = - GetDomainCount(identite_pg)(organization_id); - return set_context_variables(() => ({ - organization_fiche, - organization: ctx.var.organization, - query_organization_domains_count, - query_organization_members_count: - ctx.var.query_organization_members_count, - }))(ctx as any, next); - }, async function GET({ render, set, var: { organization } }) { set( "page_title", diff --git a/sources/organizations/api/src/:id/members/context.ts b/sources/organizations/api/src/:id/members/context.ts index 73da65d35..c9d7fa0c5 100644 --- a/sources/organizations/api/src/:id/members/context.ts +++ b/sources/organizations/api/src/:id/members/context.ts @@ -1,15 +1,43 @@ // -import type { Pagination } from "@~/app.core/schema"; +import { type Pagination } from "@~/app.core/schema"; import type { App_Context } from "@~/app.middleware/context"; import { urls } from "@~/app.urls"; -import { type GetUsersByOrganizationIdDto } from "@~/users.repository"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; +import { + GetUsersByOrganizationId, + type GetUsersByOrganizationIdDto, +} from "@~/users.repository"; import type { Env, InferRequestType } from "hono"; import { createContext } from "hono/jsx"; import { useRequestContext } from "hono/jsx-renderer"; // +export async function loadMembersPageVariables( + pg: IdentiteProconnect_PgDatabase, + { + organization_id, + pagination, + }: { + organization_id: number; + pagination: Pagination; + }, +) { + const query_members_collection = GetUsersByOrganizationId(pg)({ + organization_id, + pagination: { ...pagination, page: pagination.page - 1 }, + }); + + return { + organization_id, + pagination, + query_members_collection, + }; +} + +// + export const Member_Context = createContext({ user: {} as GetUsersByOrganizationIdDto["users"][number], }); @@ -17,11 +45,7 @@ export const Member_Context = createContext({ // export interface ContextVariablesType extends Env { - Variables: { - organization_id: number; - pagination: Pagination; - query_members_collection: Promise; - }; + Variables: Awaited>; } export type ContextType = App_Context & ContextVariablesType; diff --git a/sources/organizations/api/src/:id/members/index.tsx b/sources/organizations/api/src/:id/members/index.tsx index 5cd3c2269..b2ee82bae 100644 --- a/sources/organizations/api/src/:id/members/index.tsx +++ b/sources/organizations/api/src/:id/members/index.tsx @@ -6,14 +6,14 @@ import { Entity_Schema, Pagination_Schema, } from "@~/app.core/schema"; -import { GetUsersByOrganizationId } from "@~/users.repository"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; import { match } from "ts-pattern"; import { z } from "zod"; import organization_member_router from "./:user_id"; import { Table } from "./Table"; -import type { ContextType } from "./context"; +import { loadMembersPageVariables, type ContextType } from "./context"; // @@ -32,34 +32,23 @@ export default new Hono() page_ref: z.string(), }), ), - - function set_organization_id({ req, set }, next) { - const { id: organization_id } = req.valid("param"); - set("organization_id", organization_id); - return next(); - }, - function set_query_members_collection( - { req, set, var: { identite_pg, organization_id } }, + async function set_variables_middleware( + { req, set, var: { identite_pg } }, next, ) { - const query = req.query(); + const { id: organization_id } = req.valid("param"); const pagination = match( - Pagination_Schema.safeParse(query, { path: ["query"] }), + Pagination_Schema.safeParse(req.query(), { path: ["query"] }), ) .with({ success: true }, ({ data }) => data) .otherwise(() => Pagination_Schema.parse({})); - - set("pagination", pagination); - set( - "query_members_collection", - GetUsersByOrganizationId(identite_pg)({ - organization_id, - pagination: { ...pagination, page: pagination.page - 1 }, - }), - ); + const variables = await loadMembersPageVariables(identite_pg, { + organization_id, + pagination, + }); + set_variables(set, variables); return next(); }, - async function GET({ render }) { return render(); }, diff --git a/sources/organizations/api/src/context.ts b/sources/organizations/api/src/context.ts index bec3e78c2..a6a6b28d1 100644 --- a/sources/organizations/api/src/context.ts +++ b/sources/organizations/api/src/context.ts @@ -7,17 +7,26 @@ import { } from "@~/app.core/schema"; import type { App_Context } from "@~/app.middleware/context"; import type { urls } from "@~/app.urls"; -import type { GetOrganizationsListHandler } from "@~/organizations.repository"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; +import { GetOrganizationsList } from "@~/organizations.repository"; import type { Env, InferRequestType } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; // -export interface ContextVariablesType extends Env { - Variables: { - query_organizations: GetOrganizationsListHandler; +export async function loadOrganizationsListPageVariables( + pg: IdentiteProconnect_PgDatabase, +) { + return { + query_organizations: GetOrganizationsList(pg), }; } + +// + +export interface ContextVariablesType extends Env { + Variables: Awaited>; +} export type ContextType = App_Context & ContextVariablesType; // diff --git a/sources/organizations/api/src/domaines/context.ts b/sources/organizations/api/src/domaines/context.ts index 23cb42192..411ef99f9 100644 --- a/sources/organizations/api/src/domaines/context.ts +++ b/sources/organizations/api/src/domaines/context.ts @@ -1,9 +1,10 @@ // +import { hyper_ref } from "@~/app.core/html"; +import { hx_include } from "@~/app.core/htmx"; import { Pagination_Schema, Search_Schema } from "@~/app.core/schema"; import type { App_Context } from "@~/app.middleware/context"; -import { set_context_variables } from "@~/app.middleware/set_context_variables"; -import type { urls } from "@~/app.urls"; +import { hx_urls, urls } from "@~/app.urls"; import { type Env, type InferRequestType } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; @@ -13,16 +14,31 @@ export const query_schema = Pagination_Schema.merge(Search_Schema); // -export interface ContextVariablesType extends Env { - Variables: { - $describedby: string; - $search: string; - $table: string; - hx_domains_query_props: Record<"hx-get", string>; +export async function loadDomainesPageVariables() { + const $describedby = hyper_ref(); + const $table = hyper_ref(); + const $search = hyper_ref(); + + const hx_domains_query_props = { + ...(await hx_urls.organizations.domains.$get({ query: {} })), + "hx-include": hx_include([$search, $table, query_schema.keyof().enum.page]), + "hx-replace-url": true, + "hx-select": `#${$table} > table`, + "hx-target": `#${$table}`, + }; + + return { + $describedby, + $search, + $table, + hx_domains_query_props, }; } -export const set_variables = set_context_variables; +export interface ContextVariablesType extends Env { + Variables: Awaited>; +} + export type ContextType = App_Context & ContextVariablesType; // diff --git a/sources/organizations/api/src/domaines/index.tsx b/sources/organizations/api/src/domaines/index.tsx index 42fa7b0ad..072a99b3e 100644 --- a/sources/organizations/api/src/domaines/index.tsx +++ b/sources/organizations/api/src/domaines/index.tsx @@ -1,22 +1,21 @@ // import { zValidator } from "@hono/zod-validator"; -import { hyper_ref } from "@~/app.core/html"; -import { hx_include } from "@~/app.core/htmx"; import { Main_Layout } from "@~/app.layout"; -import { hx_urls, urls } from "@~/app.urls"; +import { set_variables } from "@~/app.middleware/context/set_variables"; +import { urls } from "@~/app.urls"; import consola from "consola"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; -import { query_schema, set_variables, type ContextType } from "./context"; +import { + loadDomainesPageVariables, + query_schema, + type ContextType, +} from "./context"; import { Page } from "./Page"; // -const $describedby = hyper_ref(); -const $table = hyper_ref(); -const $search = hyper_ref(); - export default new Hono().use("/", jsxRenderer(Main_Layout)).get( "/", zValidator("query", query_schema, function hook(result, { redirect }) { @@ -24,26 +23,11 @@ export default new Hono().use("/", jsxRenderer(Main_Layout)).get( consola.error(result.error); return redirect(urls.organizations.domains.$url().pathname); }), - set_variables(async () => { - const hx_domains_query_props = { - ...(await hx_urls.organizations.domains.$get({ query: {} })), - "hx-include": hx_include([ - $search, - $table, - query_schema.keyof().enum.page, - ]), - "hx-replace-url": true, - "hx-select": `#${$table} > table`, - "hx-target": `#${$table}`, - }; - - return { - $describedby, - $search, - $table, - hx_domains_query_props, - }; - }), + async function set_variables_middleware({ set }, next) { + const variables = await loadDomainesPageVariables(); + set_variables(set, variables); + return next(); + }, async function GET({ render, set }) { set("page_title", "Liste des domaines à vérifier"); return render(); diff --git a/sources/organizations/api/src/index.tsx b/sources/organizations/api/src/index.tsx index d4d74c56a..8bf1245f1 100644 --- a/sources/organizations/api/src/index.tsx +++ b/sources/organizations/api/src/index.tsx @@ -3,11 +3,15 @@ import { zValidator } from "@hono/zod-validator"; import { Main_Layout } from "@~/app.layout"; import { authorized } from "@~/app.middleware/authorized"; -import { GetOrganizationsList } from "@~/organizations.repository"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; import user_page_route from "./:id/index"; -import { PageQuery_Schema, type ContextType } from "./context"; +import { + loadOrganizationsListPageVariables, + PageQuery_Schema, + type ContextType, +} from "./context"; import domains_router from "./domaines"; import leaders_router from "./leaders"; import Page from "./page"; @@ -25,11 +29,12 @@ export default new Hono() "/", jsxRenderer(Main_Layout), zValidator("query", PageQuery_Schema), - async function set_query_organizations( + async function set_variables_middleware( { set, var: { identite_pg } }, next, ) { - set("query_organizations", GetOrganizationsList(identite_pg)); + const variables = await loadOrganizationsListPageVariables(identite_pg); + set_variables(set, variables); return next(); }, function GET({ render, set }) { diff --git a/sources/organizations/repository/src/GetOrganizationById.test.ts b/sources/organizations/repository/src/GetOrganizationById.test.ts new file mode 100644 index 000000000..516ee7b04 --- /dev/null +++ b/sources/organizations/repository/src/GetOrganizationById.test.ts @@ -0,0 +1,47 @@ +// + +import { NotFoundError } from "@~/app.core/error"; +import { create_unicorn_organization } from "@~/identite-proconnect.database/seed/unicorn"; +import { + empty_database, + migrate, + pg, +} from "@~/identite-proconnect.database/testing"; +import { beforeAll, beforeEach, expect, test } from "bun:test"; +import { GetOrganizationById } from "./GetOrganizationById"; + +// + +beforeAll(migrate); +beforeEach(empty_database); + +test("returns organization with partial fields", async () => { + const organization_id = await create_unicorn_organization(pg); + + const get_organization_by_id = GetOrganizationById(pg, { + columns: { + cached_libelle: true, + cached_nom_complet: true, + id: true, + siret: true, + }, + }); + const result = await get_organization_by_id(organization_id); + + expect(result).toMatchInlineSnapshot(` + { + "cached_libelle": "🦄 libelle", + "cached_nom_complet": null, + "id": 1, + "siret": "🦄 siret", + } + `); +}); + +test("throws NotFoundError when organization not found", async () => { + const get_organization_by_id = GetOrganizationById(pg, { + columns: { id: true }, + }); + + await expect(get_organization_by_id(42)).rejects.toThrow(NotFoundError); +}); diff --git a/sources/organizations/repository/src/GetOrganizationById.ts b/sources/organizations/repository/src/GetOrganizationById.ts index 8af378c6f..162cc2402 100644 --- a/sources/organizations/repository/src/GetOrganizationById.ts +++ b/sources/organizations/repository/src/GetOrganizationById.ts @@ -8,18 +8,14 @@ import { import { eq } from "drizzle-orm"; // +type OrganizationQueryConfigColumns = Partial< + Record +>; -export function GetOrganizationById({ - pg, -}: { - pg: IdentiteProconnect_PgDatabase; -}) { - type Organizations = typeof schema.organizations.$inferSelect; - type OrganizationColumnsKeys = keyof Organizations; - - return async function get_organization_by_id< - TColumns extends Partial>, - >(organization_id: number, { columns }: { columns: TColumns }) { +export function GetOrganizationById< + TColumns extends OrganizationQueryConfigColumns, +>(pg: IdentiteProconnect_PgDatabase, { columns }: { columns: TColumns }) { + return async function get_organization_by_id(organization_id: number) { const organization = await pg.query.organizations.findFirst({ columns, where: eq(schema.organizations.id, organization_id), @@ -31,4 +27,6 @@ export function GetOrganizationById({ }; } -export type GetOrganizationByIdHandler = ReturnType; +export type GetOrganizationByIdHandler< + TColumns extends OrganizationQueryConfigColumns, +> = ReturnType>; diff --git a/sources/users/api/src/:id/context.ts b/sources/users/api/src/:id/context.ts index 3d16e1bd0..b1d2e6204 100644 --- a/sources/users/api/src/:id/context.ts +++ b/sources/users/api/src/:id/context.ts @@ -2,17 +2,44 @@ import type { App_Context } from "@~/app.middleware/context"; import { urls } from "@~/app.urls"; -import type { GetUserByIdDto } from "@~/users.repository"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; +import { GetUserById } from "@~/users.repository"; import type { Env, InferRequestType } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; // -export interface ContextVariablesType extends Env { - Variables: { - user: NonNullable; +export async function loadUserPageVariables( + pg: IdentiteProconnect_PgDatabase, + { id }: { id: number }, +) { + const get_user_by_id = GetUserById(pg, { + columns: { + created_at: true, + email_verified: true, + email: true, + family_name: true, + given_name: true, + id: true, + job: true, + last_sign_in_at: true, + phone_number: true, + reset_password_sent_at: true, + sign_in_count: true, + updated_at: true, + verify_email_sent_at: true, + totp_key_verified_at: true, + }, + }); + + return { + user: await get_user_by_id(id), }; } + +export interface ContextVariablesType extends Env { + Variables: Awaited>; +} export type ContextType = App_Context & ContextVariablesType; // diff --git a/sources/users/api/src/:id/index.tsx b/sources/users/api/src/:id/index.tsx index 1ae5e2165..63e0a8b5d 100644 --- a/sources/users/api/src/:id/index.tsx +++ b/sources/users/api/src/:id/index.tsx @@ -1,23 +1,25 @@ // import { zValidator } from "@hono/zod-validator"; +import { NotFoundError } from "@~/app.core/error"; import type { Htmx_Header } from "@~/app.core/htmx"; import { Entity_Schema } from "@~/app.core/schema"; import { Main_Layout } from "@~/app.layout/index"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { urls } from "@~/app.urls"; import { CrispApi } from "@~/crisp.lib/api"; import { set_crisp_config } from "@~/crisp.middleware"; import { schema } from "@~/identite-proconnect.database"; import { ResetMFA, ResetPassword } from "@~/users.lib/usecase"; -import { GetUserById } from "@~/users.repository"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; import type { ContextType } from "./context"; +import { loadUserPageVariables } from "./context"; import user_moderations_route from "./moderations"; -import { User_NotFound } from "./not-found"; +import { UserNotFound } from "./not-found"; import user_organizations_page_route from "./organizations"; -import Page from "./page"; +import { UserPage } from "./page"; // @@ -26,28 +28,30 @@ export default new Hono() "/", jsxRenderer(Main_Layout), zValidator("param", Entity_Schema), - async function set_user( + async function set_variables_middleware( { render, req, set, status, var: { identite_pg } }, next, ) { const { id } = req.valid("param"); - const get_user_by_id = GetUserById(identite_pg); - const user = await get_user_by_id(id); - if (!user) { - status(404); - return render(); + try { + const variables = await loadUserPageVariables(identite_pg, { id }); + set_variables(set, variables); + return next(); + } catch (error) { + if (error instanceof NotFoundError) { + status(404); + return render(); + } + throw error; } - - set("user", user); - return next(); }, async function GET({ render, set, var: { user } }) { set( "page_title", `Utilisateur ${user.given_name} ${user.family_name} (${user.email})`, ); - return render(); + return render(); }, ) .delete( diff --git a/sources/users/api/src/:id/moderations/context.tsx b/sources/users/api/src/:id/moderations/context.tsx index b9c314402..4d7a19ccb 100644 --- a/sources/users/api/src/:id/moderations/context.tsx +++ b/sources/users/api/src/:id/moderations/context.tsx @@ -2,18 +2,34 @@ import type { Entity_Schema, Pagination_Schema } from "@~/app.core/schema"; import type { App_Context } from "@~/app.middleware/context"; -import type { GetModerationsByUserIdHandler } from "@~/moderations.repository/GetModerationsByUserId"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; +import { + GetModerationsByUserId, + type GetModerationsByUserIdHandler, +} from "@~/moderations.repository/GetModerationsByUserId"; import type { Env } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; import type { z } from "zod"; // +export async function loadModerationsPageVariables( + pg: IdentiteProconnect_PgDatabase, + { user_id }: { user_id: number }, +) { + const get_moderations_by_user_id = GetModerationsByUserId({ pg }); + const moderations = await get_moderations_by_user_id(user_id); + + return { + moderations, + }; +} + +// + export type ModerationList = Awaited>; export interface ContextVariablesType extends Env { - Variables: { - moderations: ModerationList; - }; + Variables: Awaited>; } export type ContextType = App_Context & ContextVariablesType; diff --git a/sources/users/api/src/:id/moderations/index.tsx b/sources/users/api/src/:id/moderations/index.tsx index f7d654cbc..21a27e726 100644 --- a/sources/users/api/src/:id/moderations/index.tsx +++ b/sources/users/api/src/:id/moderations/index.tsx @@ -2,29 +2,29 @@ import { zValidator } from "@hono/zod-validator"; import { Entity_Schema } from "@~/app.core/schema"; -import { GetModerationsByUserId } from "@~/moderations.repository/GetModerationsByUserId"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; -import type { ContextType } from "./context"; +import { loadModerationsPageVariables, type ContextType } from "./context"; import { Table } from "./Table"; // -export default new Hono() - .use("/", jsxRenderer()) - .get( - "/", - zValidator("param", Entity_Schema), - async function GET({ render, req, set, var: { identite_pg } }) { - const { id } = req.valid("param"); - const get_moderations_by_user_id = GetModerationsByUserId({ - pg: identite_pg, - }); - const moderations = await get_moderations_by_user_id(id); - set("moderations", moderations); - - // - - return render(
); - }, - ); +export default new Hono().use("/", jsxRenderer()).get( + "/", + zValidator("param", Entity_Schema), + async function set_variables_middleware( + { req, set, var: { identite_pg } }, + next, + ) { + const { id: user_id } = req.valid("param"); + const variables = await loadModerationsPageVariables(identite_pg, { + user_id, + }); + set_variables(set, variables); + return next(); + }, + async function GET({ render }) { + return render(
); + }, +); diff --git a/sources/users/api/src/:id/not-found.tsx b/sources/users/api/src/:id/not-found.tsx index afc7e10a2..97d5da8b3 100644 --- a/sources/users/api/src/:id/not-found.tsx +++ b/sources/users/api/src/:id/not-found.tsx @@ -5,7 +5,7 @@ import { urls } from "@~/app.urls"; // -export function User_NotFound({ user_id }: { user_id?: number | undefined }) { +export function UserNotFound({ user_id }: { user_id?: number | undefined }) { return (
diff --git a/sources/users/api/src/:id/organizations/context.tsx b/sources/users/api/src/:id/organizations/context.tsx index e7e45eb45..b59e5b946 100644 --- a/sources/users/api/src/:id/organizations/context.tsx +++ b/sources/users/api/src/:id/organizations/context.tsx @@ -7,13 +7,32 @@ import { type Pagination, } from "@~/app.core/schema"; import type { App_Context } from "@~/app.middleware/context"; -import type { GetOrganizationsByUserIdDto } from "@~/organizations.repository"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; +import { GetOrganizationsByUserId } from "@~/organizations.repository"; import type { Env } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; import { z } from "zod"; // +export async function loadOrganizationsPageVariables( + pg: IdentiteProconnect_PgDatabase, + { user_id, pagination }: { user_id: number; pagination: Pagination }, +) { + const get_organizations_by_user_id = GetOrganizationsByUserId(pg); + const query_organizations_collection = get_organizations_by_user_id({ + user_id, + pagination: { ...pagination, page: pagination.page - 1 }, + }); + + return { + pagination, + query_organizations_collection, + }; +} + +// + export const QuerySchema = Pagination_Schema.merge(DescribedBy_Schema).extend({ page_ref: z.string(), }); @@ -23,10 +42,7 @@ export const ParamSchema = Entity_Schema; // export interface ContextVariablesType extends Env { - Variables: { - pagination: Pagination; - query_organizations_collection: Promise; - }; + Variables: Awaited>; } export type ContextType = App_Context & ContextVariablesType; diff --git a/sources/users/api/src/:id/organizations/index.tsx b/sources/users/api/src/:id/organizations/index.tsx index 0cd5972f1..d84442e96 100644 --- a/sources/users/api/src/:id/organizations/index.tsx +++ b/sources/users/api/src/:id/organizations/index.tsx @@ -1,13 +1,18 @@ // import { zValidator } from "@hono/zod-validator"; -import { Pagination_Schema } from "@~/app.core/schema/index"; -import { GetOrganizationsByUserId } from "@~/organizations.repository"; +import { Pagination_Schema } from "@~/app.core/schema"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; import { match } from "ts-pattern"; import { Table } from "./Table"; -import { ParamSchema, QuerySchema, type ContextType } from "./context"; +import { + loadOrganizationsPageVariables, + ParamSchema, + QuerySchema, + type ContextType, +} from "./context"; // @@ -16,24 +21,23 @@ export default new Hono().get( jsxRenderer(), zValidator("param", ParamSchema), zValidator("query", QuerySchema), - async function set_moderation({ req, set, var: { identite_pg } }, next) { + async function set_variables_middleware( + { req, set, var: { identite_pg } }, + next, + ) { const { id: user_id } = req.valid("param"); - const query = req.query(); + const pagination = match( - Pagination_Schema.safeParse(query, { path: ["query"] }), + Pagination_Schema.safeParse(req.query(), { path: ["query"] }), ) .with({ success: true }, ({ data }) => data) .otherwise(() => Pagination_Schema.parse({})); - const get_organizations_by_user_id = GetOrganizationsByUserId(identite_pg); - set("pagination", pagination); - set( - "query_organizations_collection", - get_organizations_by_user_id({ - user_id, - pagination: { ...pagination, page: pagination.page - 1 }, - }), - ); + const variables = await loadOrganizationsPageVariables(identite_pg, { + pagination, + user_id, + }); + set_variables(set, variables); return next(); }, async function GET({ render }) { diff --git a/sources/users/api/src/:id/page.tsx b/sources/users/api/src/:id/page.tsx index c274c4d31..e91690d57 100644 --- a/sources/users/api/src/:id/page.tsx +++ b/sources/users/api/src/:id/page.tsx @@ -13,7 +13,7 @@ import { usePageRequestContext } from "./context"; // -export default async function User_Page() { +export async function UserPage() { const { var: { user }, } = usePageRequestContext(); diff --git a/sources/users/api/src/context.ts b/sources/users/api/src/context.ts index 9227b2b1c..5cbd9736e 100644 --- a/sources/users/api/src/context.ts +++ b/sources/users/api/src/context.ts @@ -1,27 +1,28 @@ // -import { - Pagination_Schema, - Search_Schema, - type Pagination, -} from "@~/app.core/schema"; +import { Pagination_Schema, Search_Schema } from "@~/app.core/schema"; import type { App_Context } from "@~/app.middleware/context"; import type { urls } from "@~/app.urls"; -import { - schema, - type IdentiteProconnect_PgDatabase, -} from "@~/identite-proconnect.database"; -import { desc, count as drizzle_count, ilike, or } from "drizzle-orm"; +import type { IdentiteProconnect_PgDatabase } from "@~/identite-proconnect.database"; +import { GetUsersList } from "@~/users.repository"; import type { Env, InferRequestType } from "hono"; import { useRequestContext } from "hono/jsx-renderer"; // -export interface ContextVariablesType extends Env { - Variables: { - query_users: typeof get_users_list; +export async function loadUsersListPageVariables( + pg: IdentiteProconnect_PgDatabase, +) { + return { + query_users: GetUsersList(pg), }; } + +// + +export interface ContextVariablesType extends Env { + Variables: Awaited>; +} export type ContextType = App_Context & ContextVariablesType; // @@ -40,46 +41,3 @@ export const usePageRequestContext = useRequestContext< // // // - -export function get_users_list( - pg: IdentiteProconnect_PgDatabase, - { - pagination = { page: 0, page_size: 10 }, - search, - }: { - search?: string; - pagination?: Pagination; - }, -) { - const { page, page_size: take } = pagination; - - const where = or( - ilike(schema.users.family_name, `%${search ?? ""}%`), - ilike(schema.users.given_name, `%${search ?? ""}%`), - ilike(schema.users.email, `%${search ?? ""}%`), - ); - return pg.transaction(async function users_with_count(tx) { - const users = await tx - .select({ - id: schema.users.id, - email: schema.users.email, - created_at: schema.users.created_at, - family_name: schema.users.family_name, - given_name: schema.users.given_name, - email_verified_at: schema.users.email_verified_at, - last_sign_in_at: schema.users.last_sign_in_at, - }) - .from(schema.users) - .where(where) - .orderBy(desc(schema.users.created_at)) - .limit(take) - .offset(page * take); - - const [{ value: count }] = await tx - .select({ value: drizzle_count() }) - .from(schema.users) - .where(where); - return { users, count }; - }); -} -export type get_users_list_dto = Awaited>; diff --git a/sources/users/api/src/index.tsx b/sources/users/api/src/index.tsx index b05ad5bb5..07a394c7b 100644 --- a/sources/users/api/src/index.tsx +++ b/sources/users/api/src/index.tsx @@ -3,12 +3,17 @@ import { zValidator } from "@hono/zod-validator"; import { Main_Layout } from "@~/app.layout/index"; import { authorized } from "@~/app.middleware/authorized"; +import { set_variables } from "@~/app.middleware/context/set_variables"; import { urls } from "@~/app.urls"; import consola from "consola"; import { Hono } from "hono"; import { jsxRenderer } from "hono/jsx-renderer"; import user_id_router from "./:id"; -import { get_users_list, PageInput_Schema, type ContextType } from "./context"; +import { + loadUsersListPageVariables, + PageInput_Schema, + type ContextType, +} from "./context"; import Page from "./page"; // @@ -21,8 +26,12 @@ export default new Hono() .get( "/", jsxRenderer(Main_Layout), - ({ set }, next) => { - set("query_users", get_users_list); + async function set_variables_middleware( + { set, var: { identite_pg } }, + next, + ) { + const variables = await loadUsersListPageVariables(identite_pg); + set_variables(set, variables); return next(); }, zValidator("query", PageInput_Schema, function hook(result, { redirect }) { diff --git a/sources/users/api/src/page.tsx b/sources/users/api/src/page.tsx index 704c96966..12cca45dc 100644 --- a/sources/users/api/src/page.tsx +++ b/sources/users/api/src/page.tsx @@ -7,12 +7,9 @@ import { Foot } from "@~/app.ui/hx_table"; import { row } from "@~/app.ui/table"; import { LocalTime } from "@~/app.ui/time"; import { hx_urls, urls } from "@~/app.urls"; +import type { GetUsersListDto } from "@~/users.repository"; import { match } from "ts-pattern"; -import { - PageInput_Schema, - usePageRequestContext, - type get_users_list_dto, -} from "./context"; +import { PageInput_Schema, usePageRequestContext } from "./context"; // @@ -75,7 +72,7 @@ function Filter() { async function Table() { const { req, - var: { query_users, identite_pg }, + var: { query_users }, } = usePageRequestContext(); const { q } = req.valid("query"); @@ -85,7 +82,7 @@ async function Table() { .with({ success: true }, ({ data }) => data) .otherwise(() => Pagination_Schema.parse({})); - const { count, users } = await query_users(identite_pg, { + const { count, users } = await query_users({ search: q ? String(q) : undefined, pagination: { ...pagination, page: pagination.page - 1 }, }); @@ -127,7 +124,7 @@ function Row({ user, }: { key?: string; - user: get_users_list_dto["users"][number]; + user: GetUsersListDto["users"][number]; }) { return (
{ + const user_id = await create_adora_pony_user(pg); + + const get_user_by_id = GetUserById(pg, { + columns: { + id: true, + email: true, + given_name: true, + family_name: true, + }, + }); + const result = await get_user_by_id(user_id); + + expect(result).toMatchInlineSnapshot(` + { + "email": "adora.pony@unicorn.xyz", + "family_name": "Pony", + "given_name": "Adora", + "id": 1, + } + `); +}); + +test("throws NotFoundError when user not found", async () => { + const get_user_by_id = GetUserById(pg, { + columns: { id: true }, + }); + + await expect(get_user_by_id(42)).rejects.toThrow(NotFoundError); +}); diff --git a/sources/users/repository/src/GetUserById.ts b/sources/users/repository/src/GetUserById.ts index bd5276cc9..7acd301a2 100644 --- a/sources/users/repository/src/GetUserById.ts +++ b/sources/users/repository/src/GetUserById.ts @@ -1,5 +1,6 @@ // +import { NotFoundError } from "@~/app.core/error"; import { schema, type IdentiteProconnect_PgDatabase, @@ -7,30 +8,24 @@ import { import { eq } from "drizzle-orm"; // - -export function GetUserById(pg: IdentiteProconnect_PgDatabase) { +type UserQueryConfigColumns = Partial< + Record +>; +export function GetUserById( + pg: IdentiteProconnect_PgDatabase, + { columns }: { columns: TColumns }, +) { return async function get_user_by_id(id: number) { - return pg.query.users.findFirst({ - columns: { - created_at: true, - email_verified: true, - email: true, - family_name: true, - given_name: true, - id: true, - job: true, - last_sign_in_at: true, - phone_number: true, - reset_password_sent_at: true, - sign_in_count: true, - updated_at: true, - verify_email_sent_at: true, - totp_key_verified_at: true, - }, + const user = await pg.query.users.findFirst({ + columns, where: eq(schema.users.id, id), }); + + if (!user) throw new NotFoundError("User not found."); + + return user; }; } -export type GetUserByIdHandler = ReturnType; -export type GetUserByIdDto = Awaited>; +export type GetUserByIdHandler = + ReturnType>; diff --git a/sources/users/repository/src/GetUsersList.test.ts b/sources/users/repository/src/GetUsersList.test.ts new file mode 100644 index 000000000..8c13b6895 --- /dev/null +++ b/sources/users/repository/src/GetUsersList.test.ts @@ -0,0 +1,247 @@ +// + +import { schema } from "@~/identite-proconnect.database"; +import { + create_adora_pony_user, + create_pink_diamond_user, + create_red_diamond_user, +} from "@~/identite-proconnect.database/seed/unicorn"; +import { + empty_database, + migrate, + pg, +} from "@~/identite-proconnect.database/testing"; +import { beforeAll, beforeEach, expect, setSystemTime, test } from "bun:test"; +import { eq } from "drizzle-orm"; +import { GetUsersList } from "./GetUsersList"; + +// + +beforeAll(migrate); +beforeEach(empty_database); + +beforeAll(() => { + setSystemTime(new Date("2222-01-01T00:00:00.000Z")); +}); + +test("returns paginated users list with default pagination", async () => { + await create_adora_pony_user(pg); + + const get_users_list = GetUsersList(pg); + const result = await get_users_list({}); + + expect(result).toMatchInlineSnapshot(` + { + "count": 1, + "users": [ + { + "created_at": "2222-01-01 00:00:00+00", + "email": "adora.pony@unicorn.xyz", + "email_verified_at": null, + "family_name": "Pony", + "given_name": "Adora", + "id": 1, + "last_sign_in_at": null, + }, + ], + } + `); +}); + +test("returns empty list when no users", async () => { + const get_users_list = GetUsersList(pg); + const result = await get_users_list({}); + + expect(result).toMatchInlineSnapshot(` + { + "count": 0, + "users": [], + } + `); +}); + +test("filters users by search term in email", async () => { + await create_adora_pony_user(pg); + await create_pink_diamond_user(pg); + await create_red_diamond_user(pg); + + const get_users_list = GetUsersList(pg); + const result = await get_users_list({ search: "pony" }); + + expect(result).toMatchInlineSnapshot(` + { + "count": 1, + "users": [ + { + "created_at": "2222-01-01 00:00:00+00", + "email": "adora.pony@unicorn.xyz", + "email_verified_at": null, + "family_name": "Pony", + "given_name": "Adora", + "id": 1, + "last_sign_in_at": null, + }, + ], + } + `); +}); + +test("filters users by search term in family name", async () => { + await create_adora_pony_user(pg); + await create_pink_diamond_user(pg); + await create_red_diamond_user(pg); + + const get_users_list = GetUsersList(pg); + const result = await get_users_list({ search: "Pony" }); + + expect(result).toMatchInlineSnapshot(` + { + "count": 1, + "users": [ + { + "created_at": "2222-01-01 00:00:00+00", + "email": "adora.pony@unicorn.xyz", + "email_verified_at": null, + "family_name": "Pony", + "given_name": "Adora", + "id": 1, + "last_sign_in_at": null, + }, + ], + } + `); +}); + +test("filters users by search term in given name", async () => { + await create_adora_pony_user(pg); + await create_pink_diamond_user(pg); + await create_red_diamond_user(pg); + + const get_users_list = GetUsersList(pg); + const result = await get_users_list({ search: "Adora" }); + + expect(result).toMatchInlineSnapshot(` + { + "count": 1, + "users": [ + { + "created_at": "2222-01-01 00:00:00+00", + "email": "adora.pony@unicorn.xyz", + "email_verified_at": null, + "family_name": "Pony", + "given_name": "Adora", + "id": 1, + "last_sign_in_at": null, + }, + ], + } + `); +}); + +test("respects pagination parameters", async () => { + // Create 3 users + await create_adora_pony_user(pg); + await create_pink_diamond_user(pg); + await create_red_diamond_user(pg); + + const get_users_list = GetUsersList(pg); + + // Test first page + const firstPage = await get_users_list({ + pagination: { page: 0, page_size: 2 }, + }); + expect(firstPage).toMatchInlineSnapshot(` + { + "count": 3, + "users": [ + { + "created_at": "2222-01-01 00:00:00+00", + "email": "adora.pony@unicorn.xyz", + "email_verified_at": null, + "family_name": "Pony", + "given_name": "Adora", + "id": 1, + "last_sign_in_at": null, + }, + { + "created_at": "2222-01-01 00:00:00+00", + "email": "pink.diamond@unicorn.xyz", + "email_verified_at": null, + "family_name": "Diamond", + "given_name": "Pink", + "id": 2, + "last_sign_in_at": null, + }, + ], + } + `); + + // Test second page + const secondPage = await get_users_list({ + pagination: { page: 1, page_size: 2 }, + }); + expect(secondPage).toMatchInlineSnapshot(` + { + "count": 3, + "users": [ + { + "created_at": "2222-01-01 00:00:00+00", + "email": "red.diamond@unicorn.xyz", + "email_verified_at": null, + "family_name": "Diamond", + "given_name": "Red", + "id": 3, + "last_sign_in_at": null, + }, + ], + } + `); +}); + +test("orders users by created_at descending", async () => { + await create_adora_pony_user(pg); + await create_pink_diamond_user(pg); + + setSystemTime(new Date("2222-02-01T00:00:00.000Z")); + + // Create second user (will have later created_at) + await pg + .update(schema.users) + .set({ + email: "second@example.com", + family_name: "Second", + given_name: "User", + created_at: new Date().toISOString(), + }) + .where(eq(schema.users.id, 1)) + .returning({ id: schema.users.id }); + + const get_users_list = GetUsersList(pg); + const result = await get_users_list({}); + + expect(result).toMatchInlineSnapshot(` + { + "count": 2, + "users": [ + { + "created_at": "2222-02-01 00:00:00+00", + "email": "second@example.com", + "email_verified_at": null, + "family_name": "Second", + "given_name": "User", + "id": 1, + "last_sign_in_at": null, + }, + { + "created_at": "2222-01-01 00:00:00+00", + "email": "pink.diamond@unicorn.xyz", + "email_verified_at": null, + "family_name": "Diamond", + "given_name": "Pink", + "id": 2, + "last_sign_in_at": null, + }, + ], + } + `); +}); diff --git a/sources/users/repository/src/GetUsersList.ts b/sources/users/repository/src/GetUsersList.ts new file mode 100644 index 000000000..e70b54797 --- /dev/null +++ b/sources/users/repository/src/GetUsersList.ts @@ -0,0 +1,55 @@ +// + +import type { Pagination } from "@~/app.core/schema"; +import { + schema, + type IdentiteProconnect_PgDatabase, +} from "@~/identite-proconnect.database"; +import { desc, count as drizzle_count, ilike, or } from "drizzle-orm"; + +// + +export function GetUsersList(pg: IdentiteProconnect_PgDatabase) { + return async function get_users_list({ + pagination = { page: 0, page_size: 10 }, + search, + }: { + search?: string; + pagination?: Pagination; + }) { + const { page, page_size: take } = pagination; + + const where = or( + ilike(schema.users.family_name, `%${search ?? ""}%`), + ilike(schema.users.given_name, `%${search ?? ""}%`), + ilike(schema.users.email, `%${search ?? ""}%`), + ); + + return pg.transaction(async function users_with_count(tx) { + const users = await tx + .select({ + id: schema.users.id, + email: schema.users.email, + created_at: schema.users.created_at, + family_name: schema.users.family_name, + given_name: schema.users.given_name, + email_verified_at: schema.users.email_verified_at, + last_sign_in_at: schema.users.last_sign_in_at, + }) + .from(schema.users) + .where(where) + .orderBy(desc(schema.users.created_at)) + .limit(take) + .offset(page * take); + + const [{ value: count }] = await tx + .select({ value: drizzle_count() }) + .from(schema.users) + .where(where); + return { users, count }; + }); + }; +} + +export type GetUsersListHandler = ReturnType; +export type GetUsersListDto = Awaited>; diff --git a/sources/users/repository/src/index.ts b/sources/users/repository/src/index.ts index 63a50d6d8..41d9d5723 100644 --- a/sources/users/repository/src/index.ts +++ b/sources/users/repository/src/index.ts @@ -6,3 +6,4 @@ export * from "./GetEmailsByOrganizationId"; export * from "./GetMember"; export * from "./GetUserById"; export * from "./GetUsersByOrganizationId"; +export * from "./GetUsersList";