Skip to content

SS-851: Stats Module — UserId on Records, Supervisor Flow, and Code Quality #83

Open
hrandhawa13 wants to merge 3 commits into
mainfrom
feat/SS-932
Open

SS-851: Stats Module — UserId on Records, Supervisor Flow, and Code Quality #83
hrandhawa13 wants to merge 3 commits into
mainfrom
feat/SS-932

Conversation

@hrandhawa13
Copy link
Copy Markdown
Collaborator

@hrandhawa13 hrandhawa13 commented May 5, 2026

Pull Request for JIRA Ticket: https://jira.justice.gov.bc.ca/browse/SS-932

Description

Adds UserId to stat records so each record is associated with the employee it belongs to (not just the audit creator). Introduces a supervisor flow that allows supervisors to enter
time on behalf of other users at a selected location. Also exposes roles from the auth endpoint directly, replacing fragile hardcoded claim type URLs on the frontend.


Changes

Data Model

  • Added UserId (Guid) and User navigation property to StatRecord
  • Added FK constraint (DeleteBehavior.Restrict) and index on UserId in StatRecordConfiguration
  • Added comment on StatRecord.Value documenting the numeric-only assumption
  • Migration required: dotnet ef migrations add AddUserIdToStatRecord --project db --startup-project api/Unified.Api

API — Stats

  • StatRecordRequest now requires UserId; added NotEmpty validation
  • StatRecordService.CreateBatchAsync enforces that non-supervisors can only submit records for themselves (looks up caller by IdirName against DB); supervisors bypass the check
    entirely (no unnecessary DB query)
  • UnauthorizedAccessException is now thrown from the service and caught by GlobalExceptionHandler → HTTP 403, removing the manual try/catch from the controller
  • StatRecordsController.CreateBatch accepts IReadOnlyList (bound once by model binding; no redundant .ToList() in the service)
  • Removed the Generate test data endpoint from StatRecordsController
  • All response DTOs (StatCategoryResponse, StatGroupResponse, StatMetricResponse, StatRecordResponse, StatSignoffResponse, SubCategoryMetricResponse, SubCategoryResponse) converted
    from primary constructor style to property-style init records

API — User Management

  • UserQueryParams gains IdirName (exact match) and LocationId (home location) filters on GET /api/users
  • UserInfo record gains IReadOnlyList Roles, populated from ClaimTypes.Role claims in AuthController
  • AuthController.GetUserInfo materializes User.Claims once instead of iterating twice

Infrastructure

  • GlobalExceptionHandler now handles UnauthorizedAccessException → HTTP 403 ProblemDetails

Frontend

  • auth store: added currentUser (UserResponse), isSupervisor computed (reads from userInfo.roles), setCurrentUser, updated clearUserInfo
  • Router auth guard: after login, fetches the current user by IdirName and stores in auth store
  • EnterHoursView: supervisors see an Employee picker that populates from the selected location's users; location change immediately clears the picker and reloads; stale concurrent
    fetch responses are discarded via an activeLocationId guard
  • getApiUsersParams model: added IdirName and LocationId query params
  • userInfo model: added roles: string[]
  • stats.ts API layer: added userId to StatRecordRequest and StatRecordResponse

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

image image

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • Any dependent changes have been merged and published in downstream modules

@hrandhawa13 hrandhawa13 self-assigned this May 5, 2026
Comment thread web/src/router/index.ts
Comment on lines 36 to +44
const { data: userInfo } = await getApiAuthUser();

authStore.setUserInfo(userInfo.value);

if (userInfo.value?.isAuthenticated) {
if (userInfo.value.name) {
const { data: users } = await getApiUsers({ IdirName: userInfo.value.name });
authStore.setCurrentUser(users.value?.[0] ?? null);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getApiAuthUser endpoint can return the current user details. We can modify the UserInfo response to return the DB.User.Id, seems like that is what is not being returned from UserInfo. I'm currently working on claimsTransformer so I can add the User Id from Db in claims so we can return that in the UserInfo.

https://github.com/bcgov/unified-scheduling/blob/SS-897-role-permissions/api/Unified.Authorization/Claims/PermissionClaimsTransformer.cs#L24

export type OriginalGetApiUsersParams = {
Search?: string;
IsEnabled?: boolean;
IdirName?: string;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are usually using IdirId instead of IdirName. We probably don't need to query by IdirName Once we implement returning User.Id in UserInfo.

See my other comment about returning DB.User.Id in UserInfo.

Comment on lines +233 to +239
const resolvedUserId = authStore.isSupervisor ? selectedUserId.value : authStore.currentUser?.id;
if (!resolvedUserId) {
errors['user'] = authStore.isSupervisor
? 'Please select a user to submit hours for.'
: 'Unable to determine current user. Please refresh and try again.';
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Point for discussion: Wondering if we should add a permission, instead of checking role directly. To be consistent and flexible, if we have permissions then if in the future rules change then we do not have to change code, we just assign a permission to a different role.

cc: @BronzBierd

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes please stick to only checking permissions.

this way we don't increase permutations checking for role + permisssion combinations. This doesn't scale well and we have terrible examples to scare any developer.

[Required]
public string PeriodType { get; set; } = string.Empty;

public Guid UserId { get; set; }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a non-null value so, this may be a breaking change if there are pre-existing StatRecord entities in the db.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternatively, you can make this nullable public Guid? UserId { get; set; }

@@ -60,10 +60,27 @@
}

public async Task<IReadOnlyCollection<StatRecordResponse>> CreateBatchAsync(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems odd that only the batch method would enforce auth in this manner, should CreateAsync and UpdateAsync also enforce this? If so, a shared function would be desirable to avoid code duplication.

CancellationToken cancellationToken = default
)
{
var entity = MapToEntity(request);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar issue exists here, were the user id from the request is being trusted and not checked against the logged-in user id.

.Select(u => u.Id)
.SingleOrDefaultAsync(cancellationToken);

if (requests.Any(r => r.UserId != callerId))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a question, is the user's home location relevant for this? ie. can a user submit stat records for any location?

selectedLocationId.value = value !== null && value !== undefined ? Number(value) : null;
if (authStore.isSupervisor) {
selectedUserId.value = null;
locationUsers.value = [];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be added here?

activeLocationId = selectedLocationId.value;

if (string.IsNullOrWhiteSpace(callerIdirName))
throw new UnauthorizedAccessException("Only supervisors can submit records on behalf of another user.");

var callerId = await db
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Id be tempted to have a user service handle these user queries, (possibly by a call at the controller level). Otherwise if we have multiple service methods that need to do user checking they are all going to have to duplicate this code.

var result = await service.CreateBatchAsync(
requests,
User.Identity?.Name ?? string.Empty,
User.IsInRole("Supervisor"),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could have an enum for the available roles instead of hardcoded strings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants