SuperStackDevelopment Studio
Work
Full-Stack Engineering·

Employee Wellness SaaS

Multi-Role Case Management Platform for an Employee Assistance Programme

Ongoing backend engineering support for a production Django/DRF SaaS platform — fixing role-scoping bugs in the critical incident response module, hardening queryset access control, and adding a parameterised test suite for multi-role edge cases.

DjangoDRFPostgreSQLReactRBAC

Overview

Employee assistance programmes involve a web of people who each see a different slice of the same underlying case: a call centre agent opens the case, an affiliated care provider is assigned to it, a clinical worker tracks sessions, HR monitors aggregated (anonymised) data, and a supervisor handles escalations. Each role has strict visibility rules — an affiliate should only see cases routed to their practice, not every case on the platform.

This platform had grown organically from a simpler model, and the access control layer had not always kept pace with the product's complexity. We were brought in to stabilise the backend, resolve a backlog of production incidents, and build out new clinical workflow features.

The System

The backend is a Django / Django REST Framework application backed by PostgreSQL, serving a React frontend. Cases progress through a structured lifecycle: intake → triage → assignment → active support → closure. Each stage triggers notifications, permission changes, and audit log entries.

The platform has five core user roles:

| Role | Visibility scope | |------|-----------------| | Admin | All cases, all providers, platform-wide | | Call centre | Cases they opened or are assigned to supervise | | Affiliate / care provider | Cases routed to their registered practice | | Case worker / counsellor | Cases directly assigned to them | | Client (employee) | Their own case only |

Getting queryset scoping right — so that each role sees exactly what it should and nothing more — is the core correctness requirement of the entire system.

Engagement Highlights

Critical Incident Response Module

The CIR module handles formal critical incident cases: workplace trauma, critical illness, serious accidents. These follow a distinct clinical pathway from standard EAP cases, with separate visit tracking, different approval flows, and their own session records.

A recurring class of bugs involved role-scope logic that used elif chains to select a queryset based on the user's primary role. The problem: a user with both call_centre_user and affiliate roles would only match the first branch, giving them an undersized view of cases they legitimately needed to see — or causing downstream 404s that silently broke UI features.

The fix replaced the elif chain with a function that ORs all applicable role scopes:

def cir_role_scope_q(user) -> Q:
    """
    Return a Q object that ORs every scope applicable to this user.
    A user may hold multiple roles; all relevant cases must be visible.
    """
    q = Q(pk__in=[])  # empty base
    if user.has_role("admin"):
        return Q()    # unrestricted
    if user.has_role("call_centre_user"):
        q |= Q(assigned_worker=user)
    if user.has_role("affiliate"):
        q |= Q(case__assigned_practice__affiliate__user=user)
    if user.has_role("case_worker"):
        q |= Q(counsellor=user)
    return q

This pattern was then applied consistently across all CIR-adjacent querysets, with a dedicated test suite covering multi-role user fixtures.

Session History Across Case Types

The frontend's session history component was reading only from case_details.case_sessions (standard CaseSession records). CIR cases store their sessions differently — under critical_incident_response.visits[].sessions. A client with a CIR case saw an empty session history accordion despite having attended sessions.

The fix added a conditional render path in the React component: when case_details.is_cir is true, the component maps over the CIR visits array instead of the standard sessions list.

const sessions = caseDetails.is_cir
  ? caseDetails.critical_incident_response?.visits?.flatMap(v => v.sessions ?? [])
  : caseDetails.case_sessions ?? [];

A small change with significant clinical impact — session history is part of the formal case record.

Queryset Correctness and Test Coverage

Throughout the engagement we added focused test coverage for role-scoping behaviour using pytest-django and factory_boy. Tests are parameterised across role combinations to catch the multi-role edge cases that were the source of most production incidents:

@pytest.mark.parametrize("roles,expected_count", [
    (["affiliate"], 1),
    (["call_centre_user"], 1),
    (["affiliate", "call_centre_user"], 2),  # multi-role: must see both
])
def test_cir_queryset_scope(roles, expected_count, db, user_factory, cir_factory):
    user = user_factory(roles=roles)
    ...
    assert CriticalIncidentResponse.objects.for_user(user).count() == expected_count

UAT → Production Release Workflow

The platform follows a structured release process: backend and frontend changes are deployed to a _uat-tagged release, client teams retest, sign-off, then the non-suffixed production tag is cut. We worked inside this process, producing clean PRs that could be reviewed, deployed to UAT, iterated on from client feedback, and promoted to production without surprises.

  • Django
  • Django REST Framework
  • PostgreSQL
  • React
  • pytest-django
  • Role-Based Access Control
  • Multi-tenant SaaS
  • Clinical Workflow

Outcome

  • Multi-role visibility bugs eliminated across the CIR module — users with combined roles now see the correct union of cases rather than a subset.
  • Session history renders correctly for all case types, including CIR visit-based sessions.
  • A parameterised test suite covering role-scope permutations now runs on every PR, catching regressions before they reach UAT.
  • Release cadence tightened: average time from fix-merged to production-deployed dropped significantly as the UAT workflow became more predictable.

Need a hand with this? Tell us what's broken.

30 minutes, no slide deck. If we're not the right fit, we'll say so.