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.idis the role row — recreated on remove/re-add, so never store it as a link;projectUserIdis 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 aCHECKenforces the lowercase form in the database.firstName/lastNameare 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.tsmasks the segment following/project-financing-request/invitations/— without it pino'sautoLoggingexposes 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) →
InvitationScreenJoinUIoffers 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.