Identity and access
Users, Better Auth sessions and credentials, roles and permissions, devices and notification preferences — tables, keys and example rows.
Logins and what they may do. A User is a login of one of three kinds (UserType: staff, delegate, reseller); the commercial record of a store is the Reseller and a delegate's field data is the DelegateProfile. Requirements: AUTH, ACL. Prisma file: packages/db/prisma/schema/identity.prisma.
Better Auth adaptation (SRV-05). User, Session, Account and Verification are Better Auth's core tables used through its Prisma adapter, with these choices:
| Better Auth setting | Value | Why |
|---|---|---|
advanced.database.generateId | a function returning a UUIDv7 | ids are @db.Uuid everywhere |
| Phone-number plugin fields | phoneNumber → phone, phoneNumberVerified → phoneVerified | one naming in the whole schema |
session.additionalFields | app, deviceId, appVersion | session list (auth.sessions.list) shows device and app |
user.additionalFields | type, status, locale, … (every column of User beyond the core five) | read by our guards, written by our services only |
| Providers | credential only | phone or e-mail + password, OTP by our own service |
| Admin plugin | not used | roles and permissions are our own tables |
Better Auth requires a non-null, unique email: phone-only users get a placeholder (see User.email). Assumption to verify in SRV-05 — if a later Better Auth release accepts phone-only users, the column becomes nullable through an expand migration.
Sessions, verification rows and devices are technical records: they are hard-deleted on expiry, revocation or unregistration (the audit trail keeps LOGIN/LOGOUT). Users are never deleted — DISABLED keeps history (SEC-06).
Accounts and Better Auth tables
User
A login: back-office staff, delegate or reseller. Holds identity and account status only — the commercial record of a point of sale is Reseller and the field profile is DelegateProfile. Table users · written by identity (Better Auth + admin user management).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
type | UserType | — | — | Never changes after creation. |
status | AccountStatus | — | PENDING_VALIDATION | Gates sign-in and actions (AUTH-08). Staff and delegates are created ACTIVE; a reseller becomes ACTIVE when the account is validated (CRM-09). SUSPENDED/DISABLED revoke all sessions. |
statusReason | String | yes | — | Shown verbatim on the blocked-sign-in screen (≤ 300 chars). |
statusChangedAt | DateTime | yes | — | — |
name | String | — | — | Full name (Better Auth field). For a reseller: the store manager (Responsable). |
email | String @db.VarChar(254) | — | — | Unique. Lower-cased. Better Auth requires a value: phone-only users get the non-routable placeholder <id>@users.hoco.invalid, which the API returns as null and mailers skip. |
emailVerified | Boolean | — | false | Better Auth field; false for placeholders. |
image | String | yes | — | Better Auth field, kept for compatibility and always null; avatars use avatarFileId. |
phone | String @db.VarChar(16) | yes | — | Unique. E.164 (+213…), the sign-in identifier for delegates and resellers. Better Auth phone-number plugin mapped to this field (phoneNumber → phone). |
phoneVerified | Boolean | — | false | Set when an OTP sent to phone was verified (AUTH-03). |
locale | String @db.VarChar(2) | — | "fr" | fr, ar or en (NFR-06); drives localised messages and notifications. |
avatarFileId | String @db.Uuid | yes | — | FK → File · SetNull. Purpose AVATAR. |
mustChangePassword | Boolean | — | false | Set when an admin creates the user with a temporary password (ADM-01); cleared by auth.password.change. |
termsAcceptedAt | DateTime | yes | — | Assumption — acceptance of the privacy policy / terms (SEC-08, Law 18-07) at sign-up or first sign-in. |
termsVersion | String @db.VarChar(20) | yes | — | Version accepted, e.g. 2026-11. |
lastLoginAt | DateTime | yes | — | Updated on every successful sign-in (adoption KPI, cahier §1.4); full history is in AuditLog LOGIN. |
anonymiseAfter | DateTime @db.Date | yes | — | Set by me.deleteAccount (the anonymiseAfter it answers); once passed, identity.anonymiseDeletedAccounts anonymises the account (BR-85). Indexed. |
anonymisedAt | DateTime | yes | — | When that job anonymised the account; never run twice. |
createdById | String @db.Uuid | yes | — | FK → User · SetNull. Staff member who created the account; null for self sign-up. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
avatarFile→ File (avatarFileId,onDelete: SetNull) ·createdBy→ User (createdById,onDelete: SetNull). - Referenced by: User.
createdById, Session.userId, Account.userId, RolePermission.grantedById, UserRole.userId, UserRole.assignedById, Device.userId, NotificationPreference.userId, Zone.createdById, DelegateProfile.id, DelegateZone.assignedById, Reseller.validatedById, Reseller.createdByUserId, Reseller.userId, ResellerDocument.reviewedById, ResellerDocument.uploadedById, ResellerPhoto.takenById, ResellerStatusChange.actorUserIdand 67 more. - Keys & indexes:
emailunique ·phoneunique ·@@index([type, status])·@@index([status, createdAt]). - Enums: UserType, AccountStatus.
- Lifecycle:
PENDING_VALIDATION → ACTIVE ⇄ SUSPENDED → DISABLED; transitions byadmin.users.setStatus(staff, delegates) or reseller validation (admin.resellers.validate). Never deleted (SEC-06):DISABLEDkeeps every reference intact. - History: Status changes, role changes and password resets are written to AuditLog (
STATUS_CHANGE,PERMISSION_CHANGE). - Better Auth is configured with
advanced.database.generateIdreturning a UUIDv7, so its inserts satisfy@db.Uuid(verify in SRV-05). Only the credential provider and the phone-number plugin are used; the admin plugin is not (RBAC is our own). - Account claim (AUTH-05):
auth.signup.resellerwith a verified phone equal to an existingReseller.phonelinks that record (Reseller.userId) instead of creating a second point of sale.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE users ADD CONSTRAINT users_phone_required
CHECK (type = 'STAFF' OR phone IS NOT NULL);
ALTER TABLE users ADD CONSTRAINT users_locale_check
CHECK (locale IN ('fr', 'ar', 'en'));Example row:
{
"id": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"type": "RESELLER",
"status": "ACTIVE",
"statusReason": null,
"statusChangedAt": "2026-11-03T10:40:12.000Z",
"name": "Amine Benali",
"email": "01a1a077-1080-7aed-87e0-11ae957ae11e@users.hoco.invalid",
"emailVerified": false,
"image": null,
"phone": "+213555123456",
"phoneVerified": true,
"locale": "fr",
"avatarFileId": null,
"mustChangePassword": false,
"termsAcceptedAt": "2026-11-03T08:11:40.000Z",
"termsVersion": "2026-11",
"lastLoginAt": "2026-11-05T07:58:31.000Z",
"createdById": null,
"createdAt": "2026-11-03T08:12:00.000Z",
"updatedAt": "2026-11-05T07:58:31.000Z"
}Session
An authenticated session on one client (Better Auth core table plus three additional fields). Table sessions · written by identity (Better Auth).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK, generated by Better Auth (generateId → UUIDv7). |
token | String | — | — | Unique. Opaque token sent as Authorization: Bearer (mobile) or in the httpOnly cookie (web). Never logged. |
expiresAt | DateTime | — | — | Sliding: 30 days for reseller/delegate apps, 7 days for the back-office (decision to confirm). |
userId | String @db.Uuid | — | — | FK → User · Cascade. |
ipAddress | String | yes | — | As captured by Better Auth (text, IPv4 or IPv6). |
userAgent | String | yes | — | — |
app | ClientApp | yes | — | Additional field: which client opened the session (from X-App-Platform/origin). |
deviceId | String @db.VarChar(64) | yes | — | Additional field: X-Device-Id of the installation. |
appVersion | String @db.VarChar(20) | yes | — | Additional field: X-App-Version at sign-in. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | Refreshed by Better Auth on sliding renewal; shown as last activity in auth.sessions.list. |
- Relations:
user→ User (userId,onDelete: Cascade). - Keys & indexes:
tokenunique ·@@index([userId])·@@index([expiresAt]). - Enums: ClientApp.
- Not a business record: rows are hard-deleted on sign-out, revocation (
auth.sessions.revoke, user suspension) and by the nightly purge of expired sessions. The audit trail keepsLOGIN/LOGOUT.
Example row:
{
"id": "01a1aab7-7058-7d0e-9739-d2a6fd5ec1fa",
"token": "hoco_s_8Jd0q2Lx7cVb3nR5tY1wPz4kM6aE9fGh",
"expiresAt": "2026-12-05T07:58:31.000Z",
"userId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"ipAddress": "105.235.131.20",
"userAgent": "HOCO-Revendeur/1.0.0 (Android 14; SM-A145F)",
"app": "RESELLER_APP",
"deviceId": "a3f1c9e2-5b7d-4e10-9c3a-77d2e1f0b8c4",
"appVersion": "1.0.0",
"createdAt": "2026-11-05T07:58:31.000Z",
"updatedAt": "2026-11-05T07:58:31.000Z"
}Account
Better Auth credential record. V1 uses only the credential provider: one row per user holding the password hash. Table accounts · written by identity (Better Auth).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK, generated by Better Auth. |
accountId | String | — | — | For credential: the user id. |
providerId | String | — | — | credential in V1. |
userId | String @db.Uuid | — | — | FK → User · Cascade. |
accessToken | String | yes | — | Unused in V1 (OAuth only). |
refreshToken | String | yes | — | Unused in V1. |
idToken | String | yes | — | Unused in V1. |
accessTokenExpiresAt | DateTime | yes | — | — |
refreshTokenExpiresAt | DateTime | yes | — | — |
scope | String | yes | — | — |
password | String | yes | — | Password hash (Better Auth default scrypt; never returned by any API). Policy in AUTH-07. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
user→ User (userId,onDelete: Cascade). - Keys & indexes:
@@unique([providerId, accountId])·@@index([userId]).
Example row:
{
"id": "01a1a077-1080-7670-898a-e76d9386a515",
"accountId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"providerId": "credential",
"userId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"accessToken": null,
"refreshToken": null,
"idToken": null,
"accessTokenExpiresAt": null,
"refreshTokenExpiresAt": null,
"scope": null,
"password": "<scrypt hash — never exposed>",
"createdAt": "2026-11-03T08:12:00.000Z",
"updatedAt": "2026-11-03T08:12:00.000Z"
}Verification
Short-lived secrets: OTP codes and the verification tokens returned by auth.otp.verify. Table verifications · written by identity (Better Auth + OTP service).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK, generated by Better Auth / OTP service. |
identifier | String | — | — | otp:<OtpPurpose>:<e164> for codes, vtoken:<sha256(token)> for verification tokens. |
value | String | — | — | For an OTP: JSON text holding the SHA-256 of the code (h) and the attempt count (a) — the code itself is never stored. For a token: JSON text with phone and purpose. |
expiresAt | DateTime | — | — | OTP: 5 min; verification token: 15 min. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations: none outgoing.
- Keys & indexes:
@@index([identifier])·@@index([expiresAt]). - Enums: OtpPurpose.
- Rows are deleted when consumed, after 5 failed attempts (AUTH-07) and by the hourly purge of expired rows. Send-rate limits (1 per 60 s, 5 per hour per phone) live in Redis, not here.
Example row:
{
"id": "01a1a070-cef0-761b-bef8-a54fac6f57e9",
"identifier": "otp:SIGNUP:+213555123456",
"value": "{\"h\":\"9b74c9897bac770ffc029102a200c5de\",\"a\":0}",
"expiresAt": "2026-11-03T08:10:10.000Z",
"createdAt": "2026-11-03T08:05:10.000Z",
"updatedAt": "2026-11-03T08:05:10.000Z"
}Roles and permissions
Role
One of the eight system roles of cahier §3. Labels come from RoleKey in enums.json. Table roles · written by access (seeded; permissions edited in BO-62).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
key | RoleKey | — | — | Unique. |
isSystem | Boolean | — | true | System roles cannot be removed; SUPER_ADMIN keeps roles.administer and users.administer whatever the edit (guard rail). |
description | String | yes | — | — |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations: none outgoing.
- Referenced by: RolePermission.
roleId, UserRole.roleId. - Keys & indexes:
keyunique. - Enums: RoleKey.
- Custom roles are out of V1 scope; adding them later means turning
keyinto aString(expand migration).
Example row:
{
"id": "01a15db0-5680-77fa-ae2b-171fb0ec90b2",
"key": "COMMERCIAL_ADMIN",
"isSystem": true,
"description": "CRM, revendeurs, délégués, tournées, promotions, commandes et reporting.",
"createdAt": "2026-10-21T09:00:00.000Z",
"updatedAt": "2026-10-21T09:00:00.000Z"
}Permission
A grantable <module>.<action> permission (ACL-02). Table permissions · written by access (seeded from the contract's permission catalogue).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
key | String @db.VarChar(64) | — | — | Unique. <module>.<action>, e.g. orders.validate. |
module | String @db.VarChar(32) | — | — | One of the 18 permission modules (dashboard, crm, catalog, pricing, stock, orders, delivery, finance, promotions, loyalty, field, support, communication, reports, users, roles, audit, settings). |
action | PermissionAction | — | — | — |
description | String | yes | — | — |
isSensitive | Boolean | — | false | Back-office re-authentication required before use (sessions policy). |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations: none outgoing.
- Referenced by: RolePermission.
permissionId. - Keys & indexes:
keyunique ·@@unique([module, action]). - Enums: PermissionAction.
- Only pairs used by at least one endpoint are seeded. Scopes
selfanddelegateare query filters, not permissions.
Example row:
{
"id": "01a15db0-5680-7e26-9e96-e375fbad6280",
"key": "orders.validate",
"module": "orders",
"action": "VALIDATE",
"description": "Valider ou refuser une commande, valider une commande assistée.",
"isSensitive": true,
"createdAt": "2026-10-21T09:00:00.000Z",
"updatedAt": "2026-10-21T09:00:00.000Z"
}RolePermission
Grants a permission to a role (the editable matrix of BO-62). Table role_permissions · written by access.
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
roleId | String @db.Uuid | — | — | PK. FK → Role · Cascade. Part of the composite PK. |
permissionId | String @db.Uuid | — | — | PK. FK → Permission · Cascade. Part of the composite PK. |
grantedById | String @db.Uuid | yes | — | FK → User · SetNull. null for seeded grants. |
createdAt | DateTime | — | now() | — |
- Relations:
role→ Role (roleId,onDelete: Cascade) ·permission→ Permission (permissionId,onDelete: Cascade) ·grantedBy→ User (grantedById,onDelete: SetNull). - Keys & indexes:
@@id([roleId, permissionId]). - Link row:
admin.roles.updatePermissionsreplaces the set (delete + insert in one transaction); each change is an AuditLogPERMISSION_CHANGEwith the before/after keys.
Example row:
{
"roleId": "01a15db0-5680-77fa-ae2b-171fb0ec90b2",
"permissionId": "01a15db0-5680-7e26-9e96-e375fbad6280",
"grantedById": null,
"createdAt": "2026-10-21T09:00:00.000Z"
}UserRole
Assigns a role to a user. Staff may hold several roles; delegates hold exactly DELEGATE, resellers exactly RESELLER. Table user_roles · written by access (via admin.users.create/update).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
userId | String @db.Uuid | — | — | PK. FK → User · Cascade. Part of the composite PK. |
roleId | String @db.Uuid | — | — | PK. FK → Role · Restrict. Part of the composite PK. |
assignedById | String @db.Uuid | yes | — | FK → User · SetNull. null when assigned by the system (reseller sign-up). |
createdAt | DateTime | — | now() | — |
- Relations:
user→ User (userId,onDelete: Cascade) ·role→ Role (roleId,onDelete: Restrict) ·assignedBy→ User (assignedById,onDelete: SetNull). - Keys & indexes:
@@id([userId, roleId])·@@index([roleId]). - The service rejects combinations that contradict
User.type(aRESELLERuser with a staff role, a staff user withRESELLER). Removal deletes the row and writes AuditLogPERMISSION_CHANGE.
Example row:
{
"userId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"roleId": "01a15cfa-7543-75d3-befa-bc785a1eb392",
"assignedById": null,
"createdAt": "2026-11-03T10:40:12.000Z"
}Devices and preferences
Device
A mobile installation able to receive push notifications (AUTH-09, NOT-02). Table devices · written by identity (me.devices.register).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
userId | String @db.Uuid | — | — | FK → User · Cascade. |
deviceId | String @db.VarChar(64) | — | — | X-Device-Id: installation id generated by the app at first launch. |
app | ClientApp | — | — | RESELLER_APP or DELEGATE_APP only. |
platform | DevicePlatform | — | — | ANDROID or IOS. |
appVersion | String @db.VarChar(20) | — | — | Semver of the installed build; compared with AppVersionPolicy. |
osVersion | String @db.VarChar(40) | yes | — | — |
model | String @db.VarChar(80) | yes | — | — |
fcmToken | String | yes | — | Unique. Current FCM registration token; set to null on sign-out. Unique, so a token re-registered by another user moves to that user. |
pushEnabled | Boolean | — | true | OS notification permission as last reported by the app. |
locale | String @db.VarChar(2) | yes | — | Device language, used when the user has none set. |
lastSeenAt | DateTime | — | now() | Refreshed at most once per hour by authenticated calls. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
user→ User (userId,onDelete: Cascade). - Referenced by: NotificationDelivery.
deviceId. - Keys & indexes:
fcmTokenunique ·@@unique([userId, deviceId])·@@index([userId, lastSeenAt]). - Enums: ClientApp, DevicePlatform (proposed).
- Technical record:
me.devices.removedeletes the row; FCMUNREGISTEREDerrors null the token.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE devices ADD CONSTRAINT devices_app_check
CHECK (app IN ('RESELLER_APP', 'DELEGATE_APP'));Example row:
{
"id": "01a1aab7-9380-72b4-afca-8f9ed2d4913e",
"userId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"deviceId": "a3f1c9e2-5b7d-4e10-9c3a-77d2e1f0b8c4",
"app": "RESELLER_APP",
"platform": "ANDROID",
"appVersion": "1.0.0",
"osVersion": "Android 14",
"model": "Samsung SM-A145F",
"fcmToken": "eYx3…:APA91bH2…",
"pushEnabled": true,
"locale": "fr",
"lastSeenAt": "2026-11-05T07:58:40.000Z",
"createdAt": "2026-11-05T07:58:40.000Z",
"updatedAt": "2026-11-05T07:58:40.000Z"
}NotificationPreference
A user's opt-out for one category on one channel (NOT-06), or — since smart notifications (SRV-39) — the push opt-out of one Notification Centre group. A missing row means the default from NotificationRule. Table notification_preferences · written by notifications (notifications.preferences.update).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
userId | String @db.Uuid | — | — | FK → User · Cascade. |
category | NotificationCategory | yes | — | Set on a category row; null on a group row. |
channel | NotificationChannel | — | — | PUSH on a group row. |
group | NotificationGroup | yes | — | Set on a group row (push opt-out of a Notification Centre tab, NOT-10); URGENT cannot be muted. |
enabled | Boolean | — | — | — |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
user→ User (userId,onDelete: Cascade). - Keys & indexes:
@@unique([userId, category, channel])(category rows; Postgres treats thenullcategory of group rows as distinct) ·@@unique([userId, group])(one row per group). - Enums: NotificationCategory, NotificationChannel, NotificationGroup.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE notification_preferences ADD CONSTRAINT notification_preferences_mandatory
CHECK (enabled OR category NOT IN ('TRANSACTIONAL', 'SECURITY'));
-- a row is either a category row or a group row; a group row mutes push only,
-- and URGENT cannot be muted (422 PREFERENCE_NOT_EDITABLE)
ALTER TABLE notification_preferences ADD CONSTRAINT notification_preferences_group_push
CHECK ((("group" IS NULL) = (category IS NOT NULL))
AND ("group" IS NULL OR (channel = 'PUSH' AND "group" <> 'URGENT')));Example row:
{
"id": "01a1b216-c880-76a8-9de9-534a5eae7aa7",
"userId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"category": "PROMOTIONAL",
"channel": "PUSH",
"enabled": false,
"createdAt": "2026-11-06T18:20:00.000Z",
"updatedAt": "2026-11-06T18:20:00.000Z"
}