User
Data Entity
Description
Core identity entity representing every authenticated person on the Meander platform — peer mentors, coordinators, organization admins, and global admins. Stores credentials, profile data, organizational affiliation, and account lifecycle state. Organization context is set at invitation time and cannot be changed at login. Deactivation is always soft (active flag) to preserve audit history.
Data Structure
| Name | Type | Description | Constraints |
|---|---|---|---|
id |
uuid |
Globally unique identifier for the user, used as the primary key across all foreign key relationships | PKrequiredunique |
email |
string |
User's email address. Used as login identifier for email/password auth and as the delivery address for invitation and notification emails. Unique across the entire platform. | requiredunique |
password_hash |
string |
bcrypt hash of the user's password. NULL for users who authenticate exclusively via BankID, Vipps, or passkey. Must never store plaintext. | - |
first_name |
string |
User's given name, displayed throughout the UI and in peer mentor profiles shared with contacts | required |
last_name |
string |
User's family name, displayed alongside first_name in lists, profiles, and coordinator views | required |
phone |
string |
User's phone number in E.164 format. Used for SMS notifications and displayed on peer mentor profiles. Optional at signup but required for BankID/Vipps flows. | - |
organization_id |
uuid |
Foreign key to the organization this user belongs to. Set at invitation time by an admin. Users cannot change this themselves. Drives all multi-tenancy scoping across the platform. | required |
active |
boolean |
Soft-delete flag. FALSE means the user is deactivated and cannot log in. Deactivated users are never hard-deleted to preserve referential integrity in audit logs, activities, and approval history. | required |
email_verified |
boolean |
Whether the user has confirmed their email address via the invitation activation link. Unverified users cannot log in. | required |
invited_at |
datetime |
Timestamp when the invitation email was first dispatched. Used to detect stale invitations (e.g. expired after 72 hours). | - |
activated_at |
datetime |
Timestamp when the user completed email verification and set their initial password. NULL until activation completes. | - |
last_login_at |
datetime |
Timestamp of the most recent successful authentication (any method). Used in security monitoring and dormant account detection. | - |
roles_updated_at |
datetime |
Timestamp updated whenever role assignments change. Mobile clients and the admin portal compare this against the JWT iat claim to trigger re-validation when roles have changed since the token was issued. | required |
bankid_subject |
string |
The stable subject identifier returned by the BankID OIDC provider after successful authentication. Used to link subsequent BankID logins to the correct user record without storing personnummer in plaintext. | unique |
vipps_subject |
string |
The stable subject identifier returned by Vipps Login OIDC after successful authentication. Used to link subsequent Vipps logins to the correct user record. | unique |
personnummer_encrypted |
string |
AES-256-GCM encrypted Norwegian national identity number (personnummer) retrieved from BankID or Vipps during first OAuth login. Used to sync back to member systems that lack this field. Stored encrypted at rest, never logged or exposed via API. | - |
preferred_language |
enum |
User's preferred app language. Drives locale selection for push notification templates, email content, and the mobile app UI. | required |
profile_image_url |
string |
CDN URL of the user's uploaded profile photo. Used in peer mentor profiles shared with coordinators and contacts. NULL if the user has not uploaded a photo. | - |
passkey_enabled |
boolean |
Whether the user has registered at least one FIDO2 passkey credential. Drives UI display of the passkey option at login. | required |
created_at |
datetime |
Timestamp of user record creation. Set once at insert and never updated. | required |
updated_at |
datetime |
Timestamp of the most recent update to any field on this record. Automatically maintained by an ON UPDATE trigger. | required |
Database Indexes
idx_users_email
Columns: email
idx_users_organization_id
Columns: organization_id
idx_users_active_org
Columns: organization_id, active
idx_users_bankid_subject
Columns: bankid_subject
idx_users_vipps_subject
Columns: vipps_subject
idx_users_roles_updated_at
Columns: roles_updated_at
idx_users_last_login_at
Columns: last_login_at
Validation Rules
email_format
error
Validation failed
email_unique_platform_wide
error
Validation failed
password_policy
error
Validation failed
name_non_empty
error
Validation failed
phone_e164_format
error
Validation failed
organization_id_exists
error
Validation failed
preferred_language_valid_enum
error
Validation failed
profile_image_https_only
error
Validation failed
Business Rules
organization_set_at_invitation
A user's organization_id is set when an admin sends the invitation and cannot be changed by the user. Users do not select their organization during login or onboarding — it is pre-determined by who invited them.
soft_delete_only
Users must never be hard-deleted. Deactivation sets active=false. This preserves referential integrity for all historical activities, audit logs, expense approvals, and assignment records that reference the user ID.
role_escalation_prevention
An Org Admin cannot assign the Global Admin role. Only an existing Global Admin can grant Global Admin privileges. The Role Assignment Service enforces this by comparing the actor's highest role against the target role.
roles_updated_at_on_role_change
Whenever a user's role assignments change (add or revoke), roles_updated_at is bumped to NOW(). This signals mobile clients and the admin portal to re-validate their JWT, ensuring stale role claims are not honoured.
global_admin_no_org_context
Global Admin accounts have no organization_id affiliation and cannot access any organization's operational data by default. They manage the platform (system config, cross-org support) but not individual org content.
deactivated_user_cannot_login
Any authentication attempt (email/password, BankID, Vipps, passkey, biometric) for a user with active=false must be rejected with a non-specific error message that does not confirm the account exists.
unverified_user_cannot_login
A user whose email_verified=false cannot complete email/password login. The invitation link sets email_verified=true upon activation. BankID and Vipps flows set email_verified=true automatically on first successful auth.
personnummer_encrypted_storage
The personnummer retrieved from BankID or Vipps OIDC claims must be encrypted using AES-256-GCM before persistence. It must never appear in application logs, API responses, or error messages.
invitation_token_expiry
Invitation tokens are time-limited (72 hours). An expired token must reject activation without creating or modifying the user record.