Profile & Preferences — self profile and per-user UI preferences

The whole self-service surface reduces to two ideas:

  1. Profile is just the signed-in user reading and editing their own users row — currentUser (whoami) → updateUser (self-update) → changePassword. No separate "profile" collection exists; it is the same users document the rest of the system uses, scoped to @GqlCurrentUser.
  2. Preferences is a tiny, per-user, per-table store of UI state — the userColumnPreferences collection 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 usersusers.md (Employee flow).
  • Password-reset-by-token / forgot-passwordauth. This module only covers the signed-in changePassword.
  • Theme / locale / notification opt-insnot implemented as stored preferences (the preferences module is column-visibility-only; see §9).
  • RBACpermissions-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/branchId from BaseSchema, 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: updateColumnPreference accepts only hiddenColumnscolumnOrder is not part of the input, so it is never set through the API today (§9).

4. Business rules & calculations

  • Profile is self-scoped. Both currentUser and updateUser resolve the target id from the JWT (@GqlCurrentUser._id), so a user can only read/edit themselves through this surface. updateUser runs the same validateUpdateExist uniqueness checks as any user update (email/phone/idNumber unique excluding self — see users.md §4.3).
  • Change password (auth): changePassword looks 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.updateColumnPreferencerepo.upsertPreference):
    model.findOneAndUpdate({ userId, tableKey }, { hiddenColumns }, { upsert: true, new: true })
    First save creates the row; subsequent saves overwrite hiddenColumns. getColumnPreference returns null when none exists (the client then falls back to its defaultColumns).
  • No transactions — single-document upsert/find; nothing else writes when preferences change.

5. Permissions

  • Profile: /profile page is gated only by ApGuardBuilder.isAuth() — any authenticated user; no user-maintenance action 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): getServerSideProps runs ApGuardBuilder.isAuth() and SSR-fetches via findCurrentUserAsync(token) (op currentUser), 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) and enum ProfilePages { Edit, Notifications, Security, page } (UI tab labels; "Notifications"/"Security" are nav labels, not backed by stored preferences).
    • page.tsx: avatar with getInitials(name), active/inactive ApBadge, read-only detail card, "Change Password" button → <ChangePassword> modal.
    • Change-password form (modules/auth/password/change/page.tsx): Formik oldPassword, newPassword, confirmNewPassword (Yup.ref('newPassword')); on success calls onDismiss and the auth context redirects to /login.
  • 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 to defaultColumns; updatePreference saves + 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 shared users row.
  • base.service / base.repositoryUserColumnPreferenceService extends AbstractBaseService; the repo extends AbstractBaseRepository and adds findByUserAndTableKey + upsertPreference.
  • authUserPreferenceModule imports AuthModule for @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 (ProfilePages enum) are labels, not backed by a preferences store.
  • columnOrder is dead-on-arrival via the API. The field exists on the schema/type but UpdateColumnPreferenceInput has no columnOrder, and upsertPreference only writes hiddenColumns — so order can't be persisted through GraphQL today.
  • Profile is strictly self. currentUser/updateUser always 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.
  • updateUser arg alias. Admin sends account:; BE binds @Args("user") — cosmetic (see _overview §7).
  • userId is a plain string on userColumnPreferences (not an ObjectId ref) — match exactly on the JWT _id string when querying.