Profile & Preferences — self profile and per-user UI preferences
The whole self-service surface reduces to two ideas:
- Profile is just the signed-in user reading and editing their own
usersrow —currentUser(whoami) →updateUser(self-update) →changePassword. No separate "profile" collection exists; it is the sameusersdocument the rest of the system uses, scoped to@GqlCurrentUser.- Preferences is a tiny, per-user, per-table store of UI state — the
userColumnPreferencescollection holding which table columns a user has hidden (and an optional column order). Keyed by(userId, tableKey)and upserted. As implemented today, the only persisted preference is table column visibility — there is no theme, default-branch, or notification-opt-in preference stored server-side in this module.
Source: BE src/modules/user (profile via the same resolver), src/modules/user-preference · Admin src/modules/profile, src/modules/preferences, page src/pages/profile.tsx
See _overview.md for the users model and users.md for operator management.
1. Purpose & scope
Responsible for:
- A signed-in user viewing/editing their own profile fields (name, email, phone, username, etc.).
- Changing their own password.
- Persisting per-user table column visibility/order so the same user sees the same hidden columns across sessions and devices.
Explicitly not responsible for:
- Admin editing other users → users.md (Employee flow).
- Password-reset-by-token / forgot-password → auth. This module only covers the signed-in
changePassword. - Theme / locale / notification opt-ins → not implemented as stored preferences (the preferences module is column-visibility-only; see §9).
- RBAC → permissions-access.
2. Data model
2.1 Profile
There is no profile collection. Profile = the user's own row in users (see _overview.md §2). The fields surfaced on the admin profile page (profile/gql/fragment.ts User fragment): _id, key, referralId, idNumber, username, name, kind, email, phoneNumber, active, roles, address, accountId, currency { _id, name }.
2.2 userColumnPreferences — per-user table UI state
user-preference/user-column-preference.schema.ts:
@ApSchema({ collection: 'userColumnPreferences', timestamps: true })
export class UserColumnPreference extends BaseSchema {
@Prop({ required: true, index: true }) userId: string; // the owning user (string id)
@Prop({ required: true, index: true }) tableKey: string; // logical table identifier
@Prop({ type: [String], default: [] }) hiddenColumns: string[]; // column keys the user hid
@Prop({ type: [String], default: [] }) columnOrder?: string[]; // optional explicit order
}
UserColumnPreferenceSchema.plugin(SoftDelete, { deletedAt: true });
UserColumnPreferenceSchema.index({ userId: 1, tableKey: 1 }, { unique: true, sparse: true });| Field | Type | Required | Description |
|---|---|---|---|
userId |
string | yes | Owner user id. Indexed. |
tableKey |
string | yes | Logical table name (e.g. an invoice/customer grid). Indexed. |
hiddenColumns |
string[] | yes ([]) |
Column keys hidden for this user+table. |
columnOrder |
string[] | no ([]) |
Optional persisted column order. Schema-present but not yet written by the service (see §9). |
+ BaseSchema |
— | — | _id, ref, companyId, branchId, timestamps, soft-delete, etc. |
- Uniqueness: one row per
(userId, tableKey)(unique, sparse) → enforces the upsert semantics. - Soft delete:
mongoose-delete(deletedAt). - Scoping: carries
companyId/branchIdfromBaseSchema, but lookups are by(userId, tableKey)only — preferences are effectively per-user, not per-company.
3. API surface
3.1 Profile — user/user.resolver.ts (@ApGqlAuthorize())
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
currentUser |
Query | — (@GqlCurrentUser) |
User |
Whoami → getProfile(jwt._id). |
updateUser(user) |
Mutation | UpdateUserInput |
User |
Self-update of @GqlCurrentUser._id. AuditMeta UPDATE. |
changePassword lives in the auth resolver (auth/auth.resolver.ts), not the user module:
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
changePassword(password) |
Mutation | ChangePasswordInput {userId, oldPassword, newPassword} |
String |
Signed-in self change; verifies old password. See auth. |
3.2 Preferences — user-preference/user-column-preference.resolver.ts (@ApGqlAuthorize())
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
getColumnPreference(tableKey) |
Query | tableKey: String |
UserColumnPreference (nullable) |
For the current user (@GqlCurrentUser._id) + tableKey. |
updateColumnPreference(input) |
Mutation | UpdateColumnPreferenceInput {tableKey, hiddenColumns} |
UserColumnPreference |
Upsert for (currentUser, tableKey). AuditMeta UPDATE (user-column-preference/user_column_preferences). |
GraphQL shapes (user-column-preference.dto.ts, schema.gql):
type UserColumnPreference { ...BaseDto, userId: String!, tableKey: String!, hiddenColumns: [String!]!, columnOrder: [String!] }
input UpdateColumnPreferenceInput { tableKey: String!, hiddenColumns: [String!]! }Note:
updateColumnPreferenceaccepts onlyhiddenColumns—columnOrderis not part of the input, so it is never set through the API today (§9).
4. Business rules & calculations
- Profile is self-scoped. Both
currentUserandupdateUserresolve the target id from the JWT (@GqlCurrentUser._id), so a user can only read/edit themselves through this surface.updateUserruns the samevalidateUpdateExistuniqueness checks as any user update (email/phone/idNumber unique excluding self — see users.md §4.3). - Change password (auth):
changePasswordlooks up the user,compareData(oldPassword, user.password); on mismatch throws"Invalid password"; else bcrypt-hashes the new password and updates. Returns"Password changed". (The admin UI then forces re-login.) - Column preference upsert (
UserColumnPreferenceService.updateColumnPreference→repo.upsertPreference):First save creates the row; subsequent saves overwritemodel.findOneAndUpdate({ userId, tableKey }, { hiddenColumns }, { upsert: true, new: true })hiddenColumns.getColumnPreferencereturnsnullwhen none exists (the client then falls back to itsdefaultColumns). - No transactions — single-document upsert/find; nothing else writes when preferences change.
5. Permissions
- Profile:
/profilepage is gated only byApGuardBuilder.isAuth()— any authenticated user; nouser-maintenanceaction required. BE ops are@ApGqlAuthorize()(must be logged in). - Preferences:
@ApGqlAuthorize()only — every signed-in user manages their own preferences; no dedicated permission module/action. No CASL gate.
6. Flows
6.1 View & edit own profile
1. /profile (SSR: findCurrentUserAsync(token) → currentUser) [guard: isAuth]
2. ProfilePage renders avatar (initials from name), active/inactive badge, read-only detail card
(Full Name, Email, Phone, Username, ID Number, Referral ID).
3. Edit → updateUser($account: UpdateUserInput!) → UserService.update(currentUser._id, …)
→ validateUpdateExist (uniqueness) → toast "Profile updated" → setProfile(res).
6.2 Change own password
1. /profile → "Change Password" → <ChangePassword> modal
Formik: oldPassword*, newPassword*, confirmNewPassword (Yup: must equal newPassword)
2. changePassword({ oldPassword, newPassword }) (auth resolver)
→ compareData(old) [fail → "Invalid password"] → rehash → update
3. On success the admin redirects to /login (forces re-auth).
Forgot-password (token-based, not signed-in) is a separate auth flow — see auth / users.md §6.5.
6.3 Hide/reorder table columns (per user)
1. A table calls useColumnPreferences(tableKey, defaultColumns)
→ getColumnPreference(tableKey); if null, use defaultColumns as hiddenColumns.
2. User opens <ColumnVisibilityModal> → toggles checkboxes ("Show All"/"Hide All" shortcuts).
3. Save → onUpdate(localHidden) → updateColumnPreference({ tableKey, hiddenColumns })
→ upsert (userId,tableKey) → toast "Column preference saved".
4. The table re-renders with the persisted hidden set on every subsequent load for that user.
7. Admin UI
- Profile (
src/modules/profile)- Page
/profile(pages/profile.tsx):getServerSidePropsrunsApGuardBuilder.isAuth()and SSR-fetches viafindCurrentUserAsync(token)(opcurrentUser), then renders<ProfilePage>. gql/query.ts:PROFILE_PAGE(currentUser),UPDATE_PROFILE(updateUser($account: UpdateUserInput!)),CHANGE_PASSWORD(changePassword($password: ChangePasswordInput!)),findCurrentUserAsync(server helper).context.tsx:fetchProfilePage,updateProfile(values)(toast "Profile updated",setProfile),updatePassword(values)(toast "Password updated").model.ts:IProfile(id, _id, referralId, idNumber, username, name, email, phoneNumber, active, roles, address, latitude, longitude) andenum ProfilePages { Edit, Notifications, Security, page }(UI tab labels; "Notifications"/"Security" are nav labels, not backed by stored preferences).page.tsx: avatar withgetInitials(name), active/inactiveApBadge, read-only detail card, "Change Password" button →<ChangePassword>modal.- Change-password form (
modules/auth/password/change/page.tsx): FormikoldPassword,newPassword,confirmNewPassword(Yup.ref('newPassword')); on success callsonDismissand the auth context redirects to/login.
- Page
- Preferences (
src/modules/preferences) — there is no page; it is a reusable hook + modal used inside other tables:gql/query.ts:GET_COLUMN_PREFERENCE(getColumnPreference(tableKey){ _id, tableKey, hiddenColumns }),UPDATE_COLUMN_PREFERENCE(updateColumnPreference(input){ … }).useColumnPreferences(tableKey, defaultColumns)→{ hiddenColumns, updatePreference, loading }. Fetches on mount; falls back todefaultColumns;updatePreferencesaves + toasts "Column preference saved".ColumnVisibilityModal.tsx: title "Column Visibility", "Visible: X of Y" counter, "Show All" / "Hide All" links, a checkbox per column (ApCheckbox, checked = visible), Cancel/Save (loading).
8. Dependencies & integrations
user/auth— profile read/update and password change run against the sharedusersrow.base.service/base.repository—UserColumnPreferenceServiceextendsAbstractBaseService; the repo extendsAbstractBaseRepositoryand addsfindByUserAndTableKey+upsertPreference.auth—UserPreferenceModuleimportsAuthModulefor@ApGqlAuthorize()/@GqlCurrentUser.- No cron, no external services.
9. Gotchas & project-specific rules
- Preferences = column visibility only. Despite "preferences" implying theme/defaults/notifications, the only persisted preference is
userColumnPreferences(hidden columns). There is no stored theme, default-branch, locale, or notification-opt-in preference in this module. The profile UI's "Notifications"/"Security" tabs (ProfilePagesenum) are labels, not backed by a preferences store. columnOrderis dead-on-arrival via the API. The field exists on the schema/type butUpdateColumnPreferenceInputhas nocolumnOrder, andupsertPreferenceonly writeshiddenColumns— so order can't be persisted through GraphQL today.- Profile is strictly self.
currentUser/updateUseralways resolve to the JWT user; you cannot view/edit another user via the profile surface (use the Employee flow — users.md). - Password change forces re-login in the admin (redirect to
/login) — expected UX, not a bug. updateUserarg alias. Admin sendsaccount:; BE binds@Args("user")— cosmetic (see _overview §7).userIdis a plain string onuserColumnPreferences(not anObjectIdref) — match exactly on the JWT_idstring when querying.