Note: This is a complete rebuild of the Service B.C. Queue Management System, designed for modern accessibility, performance, and maintainability.
The Queue Managment System is used to manage citizen flow and provide analtyics for our Service BC locations. This system is designed to be used for government offices with a large number of services.
| Item | Description |
|---|---|
| Next.js | TypeScript, App Router, and Webpack bundler |
| Biome | Formatting and linting |
| Headless UI | Components |
| TailwindCSS | Styling |
| Zod | Server and form action validation |
| Zustand | Global state management |
| Vitest & Jest | Testing |
The application follows the BC Government Digital Design System for a consistent government look and feel.
globals.css is the root stylesheet. It imports the BC Sans font (@bcgov/bc-sans), then layers three custom CSS files before TailwindCSS:
colours.css— Defines the full BC Gov colour palette as TailwindCSS theme tokens (blue, gold, grays, status colours, typography, borders, buttons, icons), replacing Tailwind defaults.spacing.css— Adjusts font sizes, line heights, spacing scale, border widths, and breakpoints to match BC Gov typography and layout standards.components.css— Base<button>styles at the CSScomponentslayer with primary/secondary/tertiary variants, danger modifiers, and disabled states.
Together, these make BC Gov tokens available as Tailwind utility classes throughout the app (e.g., bg-button-primary, text-typography-primary, border-border-dark, px-sm).
Reusable UI components exported from src/components/common/index.ts (re-exported via @/components). They consume the BC Gov tokens for consistent styling.
- Header/Footer — Application shell with the official BC Gov SVG logo, responsive navigation, land acknowledgement, and standard government footer links.
- Dialog/Modal — Composable system (
Dialog,Modal,DialogHeader,DialogBody,DialogActions,CloseButton) with size/position variants and Headless UI transitions. - Form inputs —
TextField,TextArea,SelectInput,MultiSelect,CheckboxInput, andSwitch, all sharing consistent label, border, and disabled styling. - Notice — Alert banner with colour-coded left border (success, error, warn, info).
- Loginout — Conditionally renders
LoginButtonorLogoutButtonbased on auth state.
The application uses BC Gov SSO (Keycloak) with the OpenID Connect Authorization Code flow. The identity provider is Azure AD IDIR (azureidir), for multi-factor auth.
| Variable | Description | Example |
|---|---|---|
SSO_ENVIRONMENT |
Keycloak environment | dev, test, prod |
SSO_REALM |
Keycloak realm | standard |
SSO_PROTOCOL |
Protocol | openid-connect |
SSO_CLIENT_ID |
OAuth2 client ID | citz-sbc-queue-6089 |
SSO_CLIENT_SECRET |
OAuth2 client secret | — |
SSO_INTEGRATION_ID |
CSS API integration ID for role management | 6089 |
CSS_API_CLIENT_ID |
CSS API service account client ID | — |
CSS_API_CLIENT_SECRET |
CSS API service account secret | — |
APP_URL |
Application base URL for redirects | http://localhost:3000 |
-
Login Button — The
LoginButtoncomponent (src/components/auth/LoginButton.tsx) opens a popup window pointing to/api/auth/login. -
Redirect to Keycloak — The
/api/auth/loginroute (src/app/api/auth/login/route.ts) constructs the Keycloak authorization URL and redirects the popup to BC Gov SSO with parameters:client_id,response_type=code,scope=email+openid,kc_idp_hint=azureidir, andredirect_uripointing to the callback route. -
User Authenticates — The user enters their credentials at the Keycloak login page.
-
Callback and Token Exchange — Keycloak redirects back to
/api/auth/login/callback(src/app/api/auth/login/callback/route.ts) with an authorizationcode. The callback route:-
Exchanges the code for tokens via
getTokens()(src/utils/auth/token/getTokens.ts), which POSTs to Keycloak's/tokenendpoint. -
Upserts the user in the database via
updateUserOnLogin()(src/utils/user/updateUserOnLogin.ts). New users are assigned a role based on legacy CSR records if they exist. -
Sets five cookies on the response:
Cookie HttpOnly Purpose access_tokenNo JWT access token (readable by JS) refresh_tokenYes Refresh token (server-only, sent automatically) id_tokenNo ID token (needed for logout) expires_inNo Access token expiry duration metadata refresh_expires_inNo Refresh token expiry duration metadata -
Returns an HTML page displaying "Login Successful".
-
-
Popup Polling — The
LoginButtonpolls the popup viapollPopupLogin()(src/utils/auth/popup/pollPopupLogin.ts) every 300ms. It checks if the popup URL matches the callback path, waits for the document to finish loading, then reads the non-HttpOnly tokens fromdocument.cookieand resolves with the token payload. -
Session Created —
loginFromTokens()(src/stores/auth/store.ts) stores the access token in the Zustand in-memory store and records a 10-hour session window inlocalStorage.
- A background timer (
src/stores/auth/timers.ts) fires 45 seconds before the access token expires. - It calls
POST /api/auth/token(src/app/api/auth/token/route.ts), which reads the HttpOnlyrefresh_tokencookie and exchanges it for new tokens at Keycloak. - The 10-hour session window is not reset on refresh — it is a hard limit set at initial login.
- Two minutes before the 10-hour window closes, an
AuthExpiryModal(src/components/auth/AuthExpiryModal/AuthExpiryModal.tsx) displays a countdown with options to extend the session or log out. - At the 10-hour mark, the user is logged out automatically.
AuthInitializer (src/components/auth/AuthInitializer.tsx) calls bootstrap() on mount, which checks localStorage for an active session window and attempts to refresh tokens using the HttpOnly cookie. This allows sessions to survive full page reloads within the 10-hour window.
-
Logout Button —
LogoutButton(src/components/auth/LogoutButton.tsx) callsupdateUserOnLogout()to mark the user inactive in the database, then navigates to/api/auth/logout. -
Server-Side Cleanup —
/api/auth/logout(src/app/api/auth/logout/route.ts) clears all auth cookies (maxAge: 0) and redirects through BC Gov SiteMinder (logontest7.gov.bc.ca/clp-cgi/logoff.cgi), which clears the SSO session before redirecting to Keycloak's logout endpoint and back to the application. -
Client-Side Cleanup —
LogoutHandler(src/components/auth/LogoutHandler.tsx) detects the?post_logout=truequery parameter, clears the Zustand store and timers, and removes the query parameter from the URL.
Route protection is handled by Next.js middleware (src/middleware/auth.ts), invoked via src/proxy.ts. A route is protected if its pathname contains /protected.
- API routes: The middleware extracts the
Authorization: Bearer <token>header, validates it against Keycloak's token introspection endpoint (POST /token/introspect), and on success addsx-user-token,x-user-info, andx-user-rolesheaders for downstream handlers. - Frontend routes: The middleware reads the
access_tokencookie, validates it the same way, and redirects to/on failure. On success it adds the samex-user-*headers.
Protected routes access auth context via getAuthContext() (src/utils/auth/getAuthContext.ts), which reads the headers set by middleware.
Roles are managed in Keycloak via the CSS API (src/utils/sso/):
| Role | Description |
|---|---|
Authenticated |
Base role for all logged-in users |
CSR |
Customer service representative |
SCSR |
Senior customer service representative |
SDM |
Service delivery manager |
Administrator |
Full access |
Roles are assigned via assignRole() (src/utils/sso/assignRole.ts) and removed via unassignRole() (src/utils/sso/unassignRole.ts), both calling the CSS API at api.loginproxy.gov.bc.ca.
Resource-level policies (src/utils/policies/) enforce attribute-based access control (ABAC) on top of roles. For example, Administrators can archive any staff user except themselves, while SDM users can only edit users in their own location.
| Component | Purpose |
|---|---|
LoginButton |
Opens SSO popup flow |
LogoutButton |
Triggers server-side logout |
AuthProvider |
Mounts AuthInitializer, AuthExpiryModal, and LogoutHandler |
Loginout |
Conditionally renders LoginButton or LogoutButton based on auth state |
IsAuthenticated |
Guard component for role-based conditional rendering |
useAuth() |
Client-side hook returning isAuthenticated, role, hasRole(), authorizationHeader, and user profile fields |
- Cookie security:
secure: trueandsameSite: "none"in production;secure: falseandsameSite: "lax"in development. - HTTP headers (
next.config.ts):X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin. - Login redirect caching: The
/api/auth/loginroute setsCache-Control: no-storeto prevent stale authorization redirects.
All features use Next.js Server Actions (not REST API routes) for data operations. Access control is enforced via a policy system (src/utils/policies/) that evaluates role hierarchy and location scoping.
The header displays the logged-in user's current location and counter. Users with the CSR role or above see a switch icon that opens a "Change Location or Counter" modal with two dropdowns:
- Location — populated from all locations in the database.
- Counter — filtered to counters associated with the selected location. Changing location auto-resets the counter to the default "Counter".
On confirm, the user's locationCode and counterId are updated via the updateStaffUser server action. Cancel reverts to the previous values.
A switch in the header lets users toggle their availability status (StaffUser.isActive). When toggled off, the user is temporarily removed from the queue and a modal explains they are unavailable. A "Return to Availability" button restores their status.
A navigation index page links to the following sub-pages. Each page provides a DataTable with create/edit/archive (or delete) functionality. Visibility and actions are policy-gated — SDMs manage their own location, Administrators manage all.
| Page | Description |
|---|---|
| Users | View/edit staff users. Edit modal includes read-only user info, role assignment (synced to SSO), location/counter assignment, and permission flags (receptionist, office manager, etc.). Archived users are hidden by default with a toggle to show them. |
| Locations | Create/edit locations with code, name, address (geocoded via API), timezone, phone, and M:N relationships to services, counters, and staff users. Changing a location's staff or counter assignments automatically resets displaced users to the default counter. |
| Counters | Create/edit/delete counters with a name and M:N location/user assignments. The default "Counter" cannot be edited or deleted. Deleting a counter reassigns its staff users to the default counter. |
| Services | Create/edit services with code, ticket prefix, name, public name, description, back-office flag, and M:N relationships to locations and categories. |
| Service Categories | Create/edit categories and assign services to them. |
| Developer | (Developer role only) Switch your own role between any of the five roles for testing purposes. |
The live queue is tracked entirely within PostgreSQL — no separate message broker (e.g., RabbitMQ) is used. The approach relies on database triggers and PostgreSQL LISTEN/NOTIFY to push real-time queue state changes to connected clients.
- Queue operations (add citizen, call next, serve, transfer, complete) are performed via server actions that mutate queue tables in the
appschema. - Database triggers on those tables fire after each write and call
pg_notify()with a channel name (e.g.,queue_update) and a JSON payload describing the change (action type, location, affected rows). - A listener service in the application holds a persistent
pgclient connection (separate from the Prisma connection pool) subscribed to those channels. On receiving a notification, it fans out the payload to connected clients. - Clients (CSR dashboards, digital signage, self-service kiosks) receive updates via Server-Sent Events (SSE) or WebSocket from the listener, keeping their queue displays in sync without polling.
- The queue state is relational data that already lives in PostgreSQL — triggers avoid duplicating it into an external system.
- LISTEN/NOTIFY is lightweight and sufficient for the scale of a single-location queue (dozens of concurrent clients, not thousands).
- Fewer moving parts: no broker to deploy, monitor, or synchronize with the database.
New models will be added to the app schema, informed by the legacy public schema (CitizenLegacy, ServiceReq, Period, CitizenState, SRState):
| Model | Purpose |
|---|---|
| Ticket | A citizen entry in the queue — ticket number (generated from Service.ticketPrefix), state (waiting, serving, etc.), priority, linked service requests, assigned counter/CSR. |
| TicketState | Enum or table of queue states (e.g., Waiting, Called, Serving, Paused, Completed, NoShow). |
| ServiceRequest | Links a ticket to one or more services requested, with its own state tracking. |
| Period | Time-bounded record of a CSR interacting with a ticket (start/end timestamps, period state). Used for wait-time and service-time metrics. |
-- Example: fires after a ticket state change
CREATE OR REPLACE FUNCTION notify_queue_update()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('queue_update', json_build_object(
'action', TG_OP,
'table', TG_TABLE_NAME,
'location_code', NEW.location_code,
'ticket_id', NEW.ticket_id,
'state', NEW.state
)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;Triggers will be attached to the ticket and service_request tables, notifying on INSERT, UPDATE, and DELETE.
A long-running service (started in lib/queues/) will:
- Open a raw
pgclient connection (not via Prisma, since Prisma does not support LISTEN/NOTIFY). - Subscribe to
queue_update(and potentially per-location channels likequeue_update_<location_code>). - On notification, broadcast the payload to connected SSE/WebSocket clients filtered by location.
lib/prisma/queues/
├── getQueueStatus.ts # Get real-time queue info
├── updateQueueMetrics.ts # Update queue statistics
└── listener.ts # LISTEN/NOTIFY listener service
app/queue/
├── page.tsx # CSR queue dashboard
└── api/stream/route.ts # SSE endpoint for live queue updates
Use the provided npm scripts to control the compose environment. The runner reads CONTAINER_CLIENT and WSL env vars, so set those there before running.
This allows for use of docker or podman, with optional wsl support for Windows.
- npm run up — start services.
- npm run down — stop and remove containers, images, and orphans.
- npm run rebuild — rebuild images (no-cache) then bring services up.
Example .env values (project root):
CONTAINER_CLIENT=docker # or podman
WSL=true # set to "true" on Windows when using WSL to run the container CLI
| Type | File Path | Description |
|---|---|---|
| Directory | .github |
Holds Github related files. See .github/README.md for more information |
| Configuration | .vscode/settings.json |
Editor and linter configuration |
| Directory | public |
Globally accessable static files |
| Directory | scripts |
Holds useful scripts to run on the project |
| Directory | src |
Application code. |
| Configuration | .editorconfig |
Cross-platform editor configuration |
| Configuration | .gitattributes |
Line ending specifications (LF for all text files) |
| Configuration | .gitignore |
List of file types to not be tracked by Github |
| Configuration | .npmrc |
Strict npm install versions |
| Configuration | biome.json |
Biome formatter/linter settings |
| Legal | LICENSE |
The Apache 2.0 license documentation |
| Configuration | next.config.ts |
Allows for Next.js configuration. See documentation here |
| Configuration | package.json |
Contains run commands, dependencies, and project informaiton |
| Configuration | postcss.config.mjs |
Used to add Tailwind to project |
| Configuration | tailwind.config.ts |
Defines styling configuration and theme for Tailwind |
| Configuration | tsconfig.json |
TypeScript configuration |
| Configuration | vitest.config.ts |
Configuration for ViTest |
This project ONLY accepts dependencies with Apache 2.0 compatible licenses. All dependencies must use licenses that are compatible with Apache 2.0 for government use.
This script should be run:
- 📦 After adding any new dependency
- 🔄 Before every release
Approved transitive dependencies with legal review:
lightningcss(MPL-2.0) - Transitive dependency from Next.js/Turbopack- Cannot be removed without breaking core framework functionality
- Overridden by TailwindCSS/PostCSS in our build pipeline
- Not used in final application output
To run the license compatibility check:
npm run check-licensesThis script scans all dependencies (production + development) and:
- 📊 Identify Apache 2.0 compatible licenses
- Apache-2.0 - Same license
- MIT - Permissive, fully compatible
- BSD-2-Clause, BSD-3-Clause - Permissive, compatible
- ISC - Permissive, compatible
- CC0-1.0, Unlicense, 0BSD - Public domain equivalent
- ❌ Block incompatible licenses:
- GPL-2.0/3.0 - Copyleft, incompatible
- LGPL-2.1/3.0 - Weak copyleft, problematic
- AGPL-3.0 - Strong copyleft, incompatible
- MPL-2.0 - Weak copyleft, requires review
- EPL-1.0/2.0, CDDL-1.0/1.1 - Incompatible
- ❓ Flag unknown licenses for manual review
If incompatible licenses are detected:
- STOP - Do not merge or deploy
- Replace the dependency with an Apache 2.0 compatible alternative
- Remove the dependency if not essential
- Contact legal team only for critical dependencies with no alternatives
For unknown/custom licenses:
- Research the license terms manually
- Replace if terms are unclear or potentially incompatible
- Document any legal team approvals