// @ts-check
/**
* @import {ExpressRequestAuthorized, ExpressResponse} from '../types.js'
*/
import express from 'express';
import { checkBaseAdmin, checkAdmin } from '../utils/authChecks.js';
import { requestUpdateLogger } from '../utils/requestLogger.js';
import * as userController from './user/controller.js';
/** @type {express.Express} */
const api = express();
/**
* User Router
*
* Manages the lifecycle of **identifyer links** - the join records that connect
* a Keycloak account (UID) to a CommTool person/guest/extern object.
* Also handles org-wide and batch user sync, full-text user search,
* user-cache invalidation, and the portal-pending flow used for out-of-band
* account linking via email token.
*
* @swagger
* tags:
* - name: User
* description: >
* Identifyer link management, user sync, search, cache invalidation,
* and portal pending link lifecycle.
*
* components:
* schemas:
* UserObject:
* type: object
* description: Person/guest/extern member object returned with user context
* properties:
* UID:
* type: string
* format: uuid
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* Display:
* type: string
* example: Jane Doe
* Type:
* type: string
* enum: [person, guest, extern, job]
* example: person
* Title:
* type: string
* example: Jane
* Data:
* type: object
* description: Arbitrary member JSON data
* example:
* UIDuser: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* email: jane@example.com
* UserSearchResult:
* type: object
* description: One candidate returned by the user search endpoint
* properties:
* title:
* type: string
* description: Combined ObjectBase.Title + Member.Display
* example: Jane Jane Doe
* value:
* type: string
* format: uuid
* description: ObjectBase UID of the person record
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* key:
* type: integer
* description: 0-based row index used by UI selectors
* example: 0
* type:
* type: string
* enum: [person, guest, extern]
* example: person
* UIDuser:
* type: string
* format: uuid
* description: Keycloak identifyer UID linked to this person
* example: b1c2d3e4-5678-90ab-cdef-1234567890ab
* PortalPendingResult:
* type: object
* description: Portal pending link row with Member data
* properties:
* UID:
* type: string
* format: uuid
* description: Person UID the pending link points to
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* Display:
* type: string
* example: Jane Doe
* Data:
* type: object
* description: Full Member.Data including _portal* token fields
* example:
* email: jane@example.com
* _portalToken: a1b2c3d4e5f6abcd
* _portalExpiry: 1741651200
* _portalLastSent: 1741564800
* _portalSentCount: 2
* SuccessResponse:
* type: object
* properties:
* success:
* type: boolean
* example: true
* ErrorResponse:
* type: object
* properties:
* success:
* type: boolean
* example: false
* message:
* type: string
* example: person with this UID not found
*/
/**
* @swagger
* /api/user/{person}/{identifyer}:
* put:
* summary: Assign a Keycloak identifyer to a person
* description: >
* Updates for the given person and atomically
* rebuilds the identifyer link + self-visibility rows. Logs the request
* via requestUpdateLogger. The identifyer UUID must be a valid v1 UUID that
* corresponds to an existing Keycloak sub (kcUID).
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: person
* required: true
* schema:
* type: string
* format: uuid
* description: Person ObjectBase UID
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* - in: path
* name: identifyer
* required: true
* schema:
* type: string
* format: uuid
* description: Keycloak subject UUID to link to the person
* example: b1c2d3e4-5678-90ab-cdef-1234567890ab
* responses:
* 200:
* description: Identifyer updated and links rebuilt
* content:
* application/json:
* schema:
* : '#/components/schemas/SuccessResponse'
* example:
* success: true
* 300:
* description: Validation error (invalid UUID or person not found)
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
* examples:
* invalidUUID:
* summary: Malformed identifyer UUID
* value:
* success: false
* message: invalid login indentifyer supplied in body
* notFound:
* summary: Person not found
* value:
* success: false
* message: person with this UID not found
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.put('/:person/:identifyer', requestUpdateLogger, checkAdmin, userController.updatePersonIdentifyerController);
/**
* @swagger
* /api/user/{person}:
* put:
* summary: Rebuild identifyer link from Member.Data.UIDuser
* description: >
* Reads for the given person and re-creates the
* identifyer link and self-visibility row without changing the stored
* UIDuser value. Useful to repair broken link state after migrations or
* manual DB edits.
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: person
* required: true
* schema:
* type: string
* format: uuid
* description: Person ObjectBase UID
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* responses:
* 200:
* description: Link rebuilt (or error details if person not found / no UIDuser)
* content:
* application/json:
* schema:
* oneOf:
* - : '#/components/schemas/SuccessResponse'
* - : '#/components/schemas/ErrorResponse'
* examples:
* ok:
* summary: Link rebuilt
* value:
* success: true
* notFound:
* summary: Person does not exist
* value:
* success: false
* message: person with this UID not found
* noUIDuser:
* summary: Member.Data has no UIDuser field
* value:
* success: false
* message: person has no UIDuser in Member.Data
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.put('/:person', checkAdmin, userController.putUserController);
/**
* @swagger
* /api/user/sync/{UIDsource}:
* post:
* summary: Sync all identifyer links for an organisation source group
* description: >
* Performs a full replace-sync of identifyer links and self-visibility rows
* for **every** person/guest/extern/job that is a member of the given source
* group. The operation runs in a single DB transaction:
* 1. Deletes all existing identifyer links for the orga.
* 2. Deletes all self-visibility rows for the orga.
* 3. Re-inserts identifyer links from .
* 4. Re-inserts self-visibility rows.
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: UIDsource
* required: true
* schema:
* type: string
* format: uuid
* description: Source group ObjectBase UID
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* responses:
* 200:
* description: Sync completed
* content:
* application/json:
* schema:
* : '#/components/schemas/SuccessResponse'
* example:
* success: true
* 300:
* description: Precondition failed
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
* examples:
* noPersons:
* summary: No persons found for the source group
* value:
* success: false
* message: no persons found for this organisation
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.post('/sync/:UIDsource', checkAdmin, userController.syncBySourceController);
/**
* @swagger
* /api/user:
* post:
* summary: Sync identifyer links for a selected list of person UIDs
* description: >
* Rebuilds identifyer links and self-visibility rows for the supplied list of
* person UIDs in a single transaction. Useful for partial syncs after
* Keycloak import of specific accounts.
* tags: [User]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - users
* properties:
* users:
* type: array
* description: Array of person ObjectBase UIDs to sync
* minItems: 1
* items:
* type: string
* format: uuid
* example:
* - aefe03ad-3d74-11f0-b612-b2b0f54f4439
* - b1c2d3e4-5678-90ab-cdef-1234567890ab
* responses:
* 200:
* description: Sync completed or validation error
* content:
* application/json:
* schema:
* oneOf:
* - : '#/components/schemas/SuccessResponse'
* - : '#/components/schemas/ErrorResponse'
* examples:
* ok:
* summary: Successfully synced
* value:
* success: true
* badBody:
* summary: Missing or invalid users array
* value:
* success: false
* message: body has to contain an object of {users:[...]}
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.post('/', checkAdmin, userController.syncUsersController);
/**
* @swagger
* /api/user/{UIDperson}:
* delete:
* summary: Remove identifyer link and self-visibility for one person
* description: >
* Deletes the persisted identifyer link (Links.Type = 'identifyer') whose
* UIDTarget points to the given person, and removes all Visible rows where
* UIDUser = person. This effectively logs the person out of all sessions
* and prevents future Keycloak authentication.
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: UIDperson
* required: true
* schema:
* type: string
* format: uuid
* description: Person ObjectBase UID
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* responses:
* 200:
* description: Identifyer and visibility rows deleted
* content:
* application/json:
* schema:
* : '#/components/schemas/SuccessResponse'
* example:
* success: true
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.delete('/:UIDperson', checkAdmin, userController.deleteUserController);
/**
* @swagger
* /api/user:
* delete:
* summary: Bulk-delete identifyer links and visibility for selected users
* description: >
* Same as DELETE /api/user/{UIDperson} but for a batch of persons in a
* single query. Requires a non-empty array in the request body.
* tags: [User]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - users
* properties:
* users:
* type: array
* description: Person ObjectBase UIDs to de-authenticate
* minItems: 1
* items:
* type: string
* format: uuid
* example:
* - aefe03ad-3d74-11f0-b612-b2b0f54f4439
* - b1c2d3e4-5678-90ab-cdef-1234567890ab
* responses:
* 200:
* description: Deleted or validation error
* content:
* application/json:
* schema:
* oneOf:
* - : '#/components/schemas/SuccessResponse'
* - : '#/components/schemas/ErrorResponse'
* examples:
* ok:
* summary: Deleted
* value:
* success: true
* badBody:
* summary: Missing or empty users array
* value:
* success: false
* message: body has to contain an object of {users:[...]}
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.delete('/', checkAdmin, userController.deleteUsersController);
/**
* @swagger
* /api/user/invalidate-cache:
* post:
* summary: Invalidate the user cache for one, many, or all users
* description: >
* Intended for bots and automation that perform Keycloak syncs and need
* to flush stale session / permission caches without restarting the server.
* Accepts three mutually exclusive body shapes:
* - - single user by UUID string
* - - array of UUID strings
* - - all users in the organisation (use with caution)
* tags: [User]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* oneOf:
* - type: object
* required: [user]
* properties:
* user:
* type: string
* format: uuid
* description: Single user UUID to invalidate
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* - type: object
* required: [users]
* properties:
* users:
* type: array
* description: Multiple user UUIDs to invalidate
* items:
* type: string
* format: uuid
* example:
* - aefe03ad-3d74-11f0-b612-b2b0f54f4439
* - b1c2d3e4-5678-90ab-cdef-1234567890ab
* - type: object
* required: [all]
* properties:
* all:
* type: boolean
* enum: [true]
* description: Invalidate all users for the current organisation
* example: true
* responses:
* 200:
* description: Cache invalidated
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* invalidated:
* type: integer
* description: Number of users invalidated (-1 means all)
* example: 2
* examples:
* single:
* summary: Single user invalidated
* value:
* success: true
* invalidated: 1
* batch:
* summary: Batch of users invalidated
* value:
* success: true
* invalidated: 3
* all:
* summary: All users invalidated
* value:
* success: true
* invalidated: -1
* 400:
* description: Invalid request body shape
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
* example:
* success: false
* message: >
* Body must contain { user: UUID-... } or { users: [...] } or { all: true }
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.post('/invalidate-cache', checkAdmin, userController.invalidateCacheController);
/**
* @swagger
* /api/user/SearchData:
* get:
* summary: Full-text search for user candidates
* description: >
* Searches in BOOLEAN MODE for persons, guests, and
* externs that have an identifyer link (i.e. are active Keycloak users).
* Returns a compact list suitable for autocomplete / select-box widgets.
* Results are sorted by ascending and grouped by member UID.
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: query
* name: search
* required: true
* schema:
* type: string
* description: MySQL BOOLEAN MODE full-text search expression
* example: jane
* responses:
* 200:
* description: Matching user candidates
* content:
* application/json:
* schema:
* type: array
* items:
* : '#/components/schemas/UserSearchResult'
* example:
* - title: Jane Jane Doe
* value: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* key: 0
* type: person
* UIDuser: b1c2d3e4-5678-90ab-cdef-1234567890ab
* - title: Doctor Jane Smith
* value: c3d4e5f6-7890-12ab-cdef-234567890abc
* key: 1
* type: extern
* UIDuser: d4e5f6a7-8901-23bc-def0-34567890abcd
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.get('/SearchData', checkBaseAdmin, userController.getSearchDataController);
/**
* @swagger
* /api/user/person/{UIDperson}:
* get:
* summary: Resolve the Keycloak identifyer UID for a person
* description: >
* Looks up for a row where and * and returns the linked , which is the Keycloak subject UUID.
* Returns 404 if no link exists (person is not yet a Keycloak user).
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: UIDperson
* required: true
* schema:
* type: string
* format: uuid
* description: Person ObjectBase UID
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* responses:
* 200:
* description: Identifyer UID resolved
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* result:
* type: string
* format: uuid
* description: Keycloak subject UUID
* example: b1c2d3e4-5678-90ab-cdef-1234567890ab
* 404:
* description: No identifyer link found for this person
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
* example:
* success: false
* message: no identifyer found for person
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.get('/person/:UIDperson', checkAdmin, userController.getPersonIdentifyerController);
/**
* @swagger
* /api/user/{UIDuser}:
* get:
* summary: Get member details for a Keycloak identifyer UID
* description: >
* Returns the person/guest/extern/job member object that is linked to the
* given Keycloak subject UUID () within the
* current organisation (). Includes full Member.Data.
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: UIDuser
* required: true
* schema:
* type: string
* format: uuid
* description: Keycloak subject UUID (identifyer UID)
* example: b1c2d3e4-5678-90ab-cdef-1234567890ab
* responses:
* 200:
* description: User record found or not found in this orga
* content:
* application/json:
* schema:
* oneOf:
* - type: object
* properties:
* success:
* type: boolean
* example: true
* result:
* : '#/components/schemas/UserObject'
* - : '#/components/schemas/ErrorResponse'
* examples:
* found:
* summary: User found in the current organisation
* value:
* success: true
* result:
* UID: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* Display: Jane Doe
* Type: person
* Title: Jane
* Data:
* UIDuser: b1c2d3e4-5678-90ab-cdef-1234567890ab
* email: jane@example.com
* notFound:
* summary: User not in current organisation
* value:
* success: false
* message: user not found in this orga
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.get('/:UIDuser', checkAdmin, userController.getUserByUIDController);
/**
* @swagger
* /api/user:
* get:
* summary: List all identifyer link UIDs
* description: >
* Returns every row in where .
* Intended for admin/diagnostic tooling. The list is not paginated and
* may be large in production environments.
* tags: [User]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: All identifyer link UIDs
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* result:
* type: array
* items:
* type: object
* properties:
* UIDidentifyer:
* type: string
* format: uuid
* description: Keycloak identifyer UID
* example: b1c2d3e4-5678-90ab-cdef-1234567890ab
* UIDuser:
* type: string
* format: uuid
* description: CommTool person ObjectBase UID
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* example:
* success: true
* result:
* - UIDidentifyer: b1c2d3e4-5678-90ab-cdef-1234567890ab
* UIDuser: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* - UIDidentifyer: d4e5f6a7-8901-23bc-def0-34567890abcd
* UIDuser: c3d4e5f6-7890-12ab-cdef-234567890abc
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.get('/', checkAdmin, userController.getAllIdentifyersController);
/**
* @swagger
* /api/user/pending/{person}/{kcUID}:
* put:
* summary: Create or refresh a portal pending link
* description: >
* Used by the portal email-linking flow. Creates (or replaces) a
* row that maps a Keycloak UID to a person
* UID, and stores the hashed token + expiry + audit fields in
* . The entire operation is atomic.
*
* - = kcUID (Keycloak sub, binary 16)
* - = * - = person UID
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: person
* required: true
* schema:
* type: string
* format: uuid
* description: Person ObjectBase UID
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* - in: path
* name: kcUID
* required: true
* schema:
* type: string
* format: uuid
* description: Keycloak subject UUID for the pending link
* example: b1c2d3e4-5678-90ab-cdef-1234567890ab
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tokenHash:
* type: string
* description: Hashed email-link token (hex or base64)
* example: a1b2c3d4e5f6abcdef1234567890abcd
* expiry:
* type: integer
* description: Unix timestamp of token expiry
* example: 1741651200
* lastSent:
* type: integer
* description: Unix timestamp of the last send
* example: 1741564800
* sentCount:
* type: integer
* description: Number of times the email has been sent (default 1)
* example: 1
* example:
* tokenHash: a1b2c3d4e5f6abcdef1234567890abcd
* expiry: 1741651200
* lastSent: 1741564800
* sentCount: 1
* responses:
* 200:
* description: Portal pending link created or refreshed
* content:
* application/json:
* schema:
* : '#/components/schemas/SuccessResponse'
* example:
* success: true
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.put('/pending/:person/:kcUID', checkAdmin, userController.upsertPortalPendingController);
/**
* @swagger
* /api/user/pending/{kcUID}:
* get:
* summary: Look up a portal pending link by Keycloak UID
* description: >
* Returns the Member data of the person that has an outstanding
* link to the given Keycloak sub UUID within the current
* organisation. The full is returned including the
* token fields so the caller can validate the token.
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: kcUID
* required: true
* schema:
* type: string
* format: uuid
* description: Keycloak subject UUID of the pending link
* example: b1c2d3e4-5678-90ab-cdef-1234567890ab
* responses:
* 200:
* description: Pending link found or not found
* content:
* application/json:
* schema:
* oneOf:
* - type: object
* properties:
* success:
* type: boolean
* example: true
* result:
* : '#/components/schemas/PortalPendingResult'
* - : '#/components/schemas/ErrorResponse'
* examples:
* found:
* summary: Pending link found
* value:
* success: true
* result:
* UID: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* Display: Jane Doe
* Data:
* email: jane@example.com
* _portalToken: a1b2c3d4e5f6abcdef1234567890abcd
* _portalExpiry: 1741651200
* _portalLastSent: 1741564800
* _portalSentCount: 1
* notFound:
* summary: No pending link for this KC UID in the current org
* value:
* success: false
* message: No pending link found for this organization
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.get('/pending/:kcUID', checkAdmin, userController.getPortalPendingByKcUIDController);
/**
* @swagger
* /api/user/pending/{person}:
* delete:
* summary: Remove a portal pending link and clean up token metadata
* description: >
* Atomically deletes the Links row whose UIDTarget is the
* given person, and removes the , ,
* , and fields from .
* Called by the portal after successful token validation, immediately
* before activating the permanent identifyer link.
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: person
* required: true
* schema:
* type: string
* format: uuid
* description: Person ObjectBase UID
* example: aefe03ad-3d74-11f0-b612-b2b0f54f4439
* responses:
* 200:
* description: Portal pending link and token fields removed
* content:
* application/json:
* schema:
* : '#/components/schemas/SuccessResponse'
* example:
* success: true
* 500:
* description: Unexpected server error
* content:
* application/json:
* schema:
* : '#/components/schemas/ErrorResponse'
*/
// @ts-ignore
api.delete('/pending/:person', checkAdmin, userController.deletePortalPendingController);
export default api;