Aller au contenu

project-financing-request-user

Who may act on a financing request, and with which role.

Extracted from project-financing-request because the subject is distinct and growing: multi-user access (BRI-1807), users list (BRI-1785), invitations (BRI-1784).

Tables

Table Role
project_financing_request_user_role Single authorization source — one user, one project, one role (soft-delete to revoke)
project_financing_request_user_invitation Pending invite (email + role + token hash)

Relational columns only (no json + generated _view). Role matrix: data-reference.md §9.

Authorization guard

ProjectFinancingRequestUserGuard (controllers across eight modules) extends Better Auth and calls ProjectFinancingRequestUserRoleService.findProjectAccess.

Outcome HTTP
No project_financing_request_user row for session user 404
Project id invalid / project missing 404
Project exists, user has no active role row 403
Active role the route does not allow 403
Active, allowed role 200 — sets projectFinancingRequestUserId and projectFinancingRequestUserRole on the request

Role gating — @AllowedProjectRoles

Every controller carrying a :projectId declares who may reach it, next to @UseGuards:

@Controller('project-financing-request/:projectId/echeancier')
@UseGuards(ProjectFinancingRequestUserGuard)
@AllowedProjectRoles('representative', 'collaborator')

Default-deny: a :projectId controller without the decorator answers 403 to everyone, so a new one stays closed until it opts in. The decision is business/is-project-financing-request-role-allowed.ts — framework-free, so only SetMetadata and Reflector would need rewriting the day the API leaves Nest. Routes with no :projectId (/projects, /mapbox, /pappers) resolve no role and are not gated.

Surface representative collaborator apporteur_affaires
Users ✅ ✅ ✅
Financing request (presentation, documents, borrower, analysis, submit) ✅ ✅ ✅
Offer, finalization ✅ ✅ ❌
Post-financing (echeancier, news, requests, construction budget, management fees, dashboard) ✅ ✅ ❌
Invitations † ✅ ✅ ✅
Signature † ✅ ❌ ❌

Managing users is its own right, not a post-financing one — that is what lets the apporteur d'affaires keep the users page while the rest of the post-financing space is closed to him (matrix validated by Romain, Slack 23/07).

⚠️ The route decorator gates routes, not the step a request acts on. One documents controller serves onboarding, analysis and finalization, and the step comes from elsewhere: context.type in the body on confirm-upload, the stored documentType on DELETE /:documentId. Both are gated where that step becomes known, against FINALIZATION_ROLES — shared with the finalization controller so the surface and its documents cannot drift apart. Any endpoint whose payload or stored row selects a surface needs the same treatment; the decorator alone cannot see it.

† Signature : no controller yet — the row records the product rule the endpoint must declare when the funnel lands it; until then the guard closes it by default. Invitations ship in ProjectFinancingRequestInvitationController (@AllowedProjectRoles('representative', 'collaborator', 'apporteur_affaires') on invite + resend).

The lint at scripts/lint-conventions/lint-project-role-gating.ts fails the build when a :projectId route omits the decorator, or when a route without :projectId carries one (dead metadata that reads as protection). It reads route by route — class prefix plus method path, class decorators inherited — because a controller that declares roles on some methods only would otherwise hide a bare one.

project_financing_request.createdByUserId_view records who created the project only; it is not used for access checks anymore.

On project creation, project-creation.service inserts a role row for the creator in the same transaction — without it the author could not access their own project. The role is body.role, defaulting to collaborator; the creation contract excludes representative, which is granted later (see below).

HTTP surface

Method Path Auth Role
GET /project-financing-request/:projectId/users project guard Active members + pending invitations (GetProjectUsersResponse)
POST /project-financing-request/:projectId/invitations project guard Creates the invitation, sends the mail
POST /project-financing-request/:projectId/invitations/:invitationId/resend project guard Rotates the token, sends the mail again
GET /project-financing-request/invitations/:token none Landing page context
POST /project-financing-request/invitations/:token/accept better-auth session Creates the role row, closes the invitation

Every active role may invite, apporteur_affaires included — no filter on top of the guard (settled with Rémy and Romain on 23/07, reconfirmed on 11/08). Invitations sent from the members page grant collaborator: apporteur_affaires is self-declared at project creation.

representative is granted at the Emprunteur step only, through grantRepresentative (BRI-1787). It promotes an existing member, raises the role of a pending invitation, or sends a new invitation already carrying representative — consumeInvitation reads invitation.role, so acceptance grants it directly. Returning the plain conflicts instead would leave a signatory a member who is never a signatory.

An apporteur_affaires is never promoted, on any of those paths: the role column holds one value, so raising it would erase the introducer capacity and grant the offer and signature surfaces the matrix above denies it.

GET …/users response shape

  • users[] — active roles: id, projectUserId, email, firstName, lastName, role, isCurrentUser. id is the role row — recreated on remove/re-add, so never store it as a link; projectUserId is the stable person, and what the borrower step persists on a signatory.
  • pendingInvitations[] — id, email, role (no token)

Invitations

  • Email is the only identity key. Lowercased on write and on every lookup (business/normalize-invitation-email.ts), because the partial uniqueness index compares raw strings with no generated column — and a CHECK enforces the lowercase form in the database. firstName/lastName are display-only and nullable: better-auth rewrites the identity at signup.
  • The token is never stored in clear (lib/invitation-token.ts: 32 random bytes, SHA-256 in database). No argon2: it is not a password, and resolving it must stay one indexed read.
  • A link never expires (product decision, 30/07, same reasoning as the financing offer). A resend rotates the token and therefore kills the previous link — but only once the mail went out: the resend is the recovery path, so a Customer.io outage must not kill the link the invitee holds. No minimum delay between two resends was ever specified by product.
  • The token must never reach the logs. It travels as a URL segment, so lib/log/redact-req-secrets.ts masks the segment following /project-financing-request/invitations/ — without it pino's autoLogging exposes it in Datadog, and a link that never expires stays exploitable forever.
  • One way in (consumeInvitation): clicking the link. An invitee who signs up without it joins nothing until they open the mail — the inviter's "Relancer" button is the recovery path.
  • Acceptance locks the invitation by id (FOR UPDATE) then writes role and status in the same transaction — that lock is what makes a double-click and a replay harmless.

The mail goes out through the Customer.io B2B workspace (CustomerioEmailB2bApi.sendTransactionalEmail) without a customerId: an invitee may or may not already hold a Bricks account, so the profile is resolved by email (mailType: 'ProjectFinancingRequestInvitation'). Payload : inviterName, projectName, invitationUrl ({pdpFrontUrl}/invitation?token=). A failed send never rolls back the row — it is what the "Relancer" button acts on (invitation-mail-not-sent on resend when the provider is down).

Invite / resend / accept errors (HTTP)

Code Statut Endpoint
invitation-already-pending 409 POST …/invitations — same normalized email already pending on this project
user-already-member 409 POST …/invitations — email already has an active role row
invitation-not-found 404 POST …/invitations/:id/resend
invitation-already-accepted 409 resend, or POST …/invitations/:token/accept replay
invitation-mail-not-sent 409 resend — Customer.io failed; previous token unchanged
invalid-token 404 GET / POST …/invitations/:token — unknown hash or already accepted
invitation-email-mismatch 403 accept — session email ≠ invitation email (normalized)
user-not-found 404 accept — no project_financing_request_user for session

Back-office surface (BRI-2197)

administration/project-financing-request/users* + …/invitations*, AdminAuthGuard, no @AllowedProjectRoles (no :projectId to resolve a role against). Routes, list semantics and choices: docs/administration.md.

Public invitation flow (PDP front)

Route: /invitation?token= → InvitationScreen (modules/auth/screens/InvitationScreen).

  • Unauthenticated preview, then signup + accept
  • Already signed in (the invitee owned an account, or the accept failed after signup) → InvitationScreenJoinUI offers to join rather than creating a second account

Dependencies (bidirectional edge)

From sibling → here: guard + creator role row on POST /projects.

From here → sibling: controller imports ProjectFinancingRequestUserGuard from the sibling module (shared infra for all guarded owner routes — 14 of them :projectId-scoped and therefore role-gated, 3 not). Moving the guard here would invert the edge without removing it — a shared shell/ module would be the long-term fix.