docs(clqms): simplify rule and calculator references
Clarify API contract focused docs for calc, rule, and audit pages to reduce noisy implementation detail. Escape logical OR table examples as \|\| so Eleventy markdown tables render correctly.
This commit is contained in:
parent
bc69ae3570
commit
761ad32c5a
@ -2,31 +2,47 @@
|
||||
layout: clqms-post.njk
|
||||
tags: clqms
|
||||
title: "Calculator Service Operators Reference"
|
||||
description: "Operators, functions, constants, and API usage for the calc engine"
|
||||
description: "Concept, API contracts, and simplified operator reference for the calc engine"
|
||||
date: 2026-03-17
|
||||
order: 7
|
||||
---
|
||||
|
||||
# Calculator Service Operators Reference
|
||||
# Calculator Service: Concept and API Contract
|
||||
|
||||
## Overview
|
||||
## Concept
|
||||
|
||||
The `CalculatorService` (`app/Services/CalculatorService.php`) evaluates formulas with Symfony's `ExpressionLanguage`. This document lists the operators, functions, and constants that are available in the current implementation.
|
||||
`CalculatorService` evaluates formulas for test results using runtime variables.
|
||||
|
||||
Use it when you need to:
|
||||
|
||||
- transform a raw result into a calculated result,
|
||||
- apply factors or demographic values (age, gender),
|
||||
- reuse formula logic from test configuration.
|
||||
|
||||
Two API patterns are used in practice:
|
||||
|
||||
1. **By Test Site ID** - uses the calculation definition configured for that test site.
|
||||
2. **By Test Code/Name** - resolves by test code or test name and returns a compact result map.
|
||||
|
||||
Responses normally follow `{ status, message, data }`, except the compact test-code endpoint,
|
||||
which returns a key/value map.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
## Example API Contract
|
||||
|
||||
All endpoints live under `/api` and accept JSON. Responses use the standard `{ status, message, data }` envelope unless stated otherwise.
|
||||
|
||||
### Calculate By Test Site
|
||||
|
||||
Uses the `testdefcal` definition for a test site. The incoming body supplies the variables required by the formula.
|
||||
### 1) Calculate by Test Site
|
||||
|
||||
```http
|
||||
POST /api/calc/testsite/123
|
||||
POST /api/calc/testsite/{testSiteID}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Purpose: Evaluate the configured formula for a specific test site.
|
||||
|
||||
Request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": 85,
|
||||
"gender": "female",
|
||||
@ -34,7 +50,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
Success response example:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -52,21 +68,35 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
### Calculate By Code Or Name
|
||||
Error response example:
|
||||
|
||||
Evaluates a configured calculation by `TestSiteCode` or `TestSiteName`. Returns a compact map with a single key/value or `{}` on failure.
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Missing variable: factor",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 2) Calculate by Test Code or Name
|
||||
|
||||
```http
|
||||
POST /api/calc/testcode/GLU
|
||||
POST /api/calc/testcode/{testCodeOrName}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Purpose: Evaluate a calculation by test code/name and return a compact map.
|
||||
|
||||
Request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": 110,
|
||||
"factor": 1.1
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
Success response example:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -74,25 +104,31 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
Error response example:
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Operators
|
||||
## Formula Reference (Simplified)
|
||||
|
||||
### Arithmetic Operators
|
||||
|
||||
| Operator | Description | Example | Result |
|
||||
|----------|-------------|---------|--------|
|
||||
| `+` | Addition | `5 + 3` | `8` |
|
||||
| `-` | Subtraction | `10 - 4` | `6` |
|
||||
| `*` | Multiplication | `6 * 7` | `42` |
|
||||
| `/` | Division | `20 / 4` | `5` |
|
||||
| `%` | Modulo | `20 % 6` | `2` |
|
||||
| `**` | Exponentiation (power) | `2 ** 3` | `8` |
|
||||
| Operator | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `+` | Add | `5 + 3` |
|
||||
| `-` | Subtract | `10 - 4` |
|
||||
| `*` | Multiply | `6 * 7` |
|
||||
| `/` | Divide | `20 / 4` |
|
||||
| `%` | Modulo | `20 % 6` |
|
||||
| `**` | Power | `2 ** 3` |
|
||||
|
||||
### Comparison Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| Operator | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `==` | Equal | `{result} == 10` |
|
||||
| `!=` | Not equal | `{result} != 10` |
|
||||
| `<` | Less than | `{result} < 10` |
|
||||
@ -102,245 +138,77 @@ Response:
|
||||
|
||||
### Logical Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `and` / `&&` | Logical AND | `{result} > 0 and {factor} > 0` |
|
||||
| `or` / `||` | Logical OR | `{gender} == 1 or {gender} == 2` |
|
||||
| `!` / `not` | Logical NOT | `not ({result} > 0)` |
|
||||
| Operator | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `&&` (`and`) | Logical AND | `{result} > 0 && {factor} > 0` |
|
||||
| `\|\|` (`or`) | Logical OR | `{gender} == 1 \|\| {gender} == 2` |
|
||||
| `!` (`not`) | Logical NOT | `!({result} > 0)` |
|
||||
|
||||
### Conditional Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| Operator | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `?:` | Ternary | `{result} > 10 ? {result} : 10` |
|
||||
| `??` | Null coalescing | `{result} ?? 0` |
|
||||
| `??` | Null fallback | `{result} ?? 0` |
|
||||
|
||||
### Parentheses
|
||||
### Functions
|
||||
|
||||
Use parentheses to control operation precedence:
|
||||
|
||||
```
|
||||
(2 + 3) * 4 // Result: 20
|
||||
2 + 3 * 4 // Result: 14
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `^` is bitwise XOR (not exponentiation). Use `**` for powers.
|
||||
- Variables must be numeric after normalization (gender is mapped to 0/1/2).
|
||||
|
||||
---
|
||||
|
||||
## Functions
|
||||
|
||||
Only the default ExpressionLanguage functions are available:
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `min(a, b, ...)` | Minimum value | `min({result}, 10)` |
|
||||
| `max(a, b, ...)` | Maximum value | `max({result}, 10)` |
|
||||
| Function | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `min(a, b, ...)` | Lowest value | `min({result}, 10)` |
|
||||
| `max(a, b, ...)` | Highest value | `max({result}, 10)` |
|
||||
| `constant(name)` | PHP constant by name | `constant("PHP_INT_MAX")` |
|
||||
| `enum(name)` | PHP enum case by name | `enum("App\\Enum\\Status::Active")` |
|
||||
|
||||
---
|
||||
### Constants
|
||||
|
||||
## Constants
|
||||
| Constant | Meaning |
|
||||
|----------|---------|
|
||||
| `true` | Boolean true |
|
||||
| `false` | Boolean false |
|
||||
| `null` | Null |
|
||||
|
||||
ExpressionLanguage recognizes boolean and null literals:
|
||||
### Variables Commonly Used
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `true` | `true` | Boolean true |
|
||||
| `false` | `false` | Boolean false |
|
||||
| `null` | `null` | Null value |
|
||||
| Variable | Type | Meaning |
|
||||
|----------|------|---------|
|
||||
| `{result}` | Float | Input result value |
|
||||
| `{factor}` | Float | Multiplier (default usually `1`) |
|
||||
| `{gender}` | Integer | `0` unknown, `1` female, `2` male |
|
||||
| `{age}` | Float | Patient age |
|
||||
| `{ref_low}` | Float | Reference low |
|
||||
| `{ref_high}` | Float | Reference high |
|
||||
|
||||
Gender can be passed as either numeric values (`0`, `1`, `2`) or strings
|
||||
(`"unknown"`, `"female"`, `"male"`) and is normalized.
|
||||
|
||||
### Formula Notes
|
||||
|
||||
- Use parentheses for precedence: `(2 + 3) * 4`.
|
||||
- Use `**` for exponentiation; `^` is XOR.
|
||||
- Implicit multiplication is not supported (`2x` is invalid, use `2 * x`).
|
||||
|
||||
---
|
||||
|
||||
## Variables in CalculatorService
|
||||
## Quick Usage Examples
|
||||
|
||||
When using `calculateFromDefinition()`, the following variables are automatically available:
|
||||
|
||||
| Variable | Description | Type |
|
||||
|----------|-------------|------|
|
||||
| `{result}` | The test result value | Float |
|
||||
| `{factor}` | Calculation factor (default: 1) | Float |
|
||||
| `{gender}` | Gender value (0=Unknown, 1=Female, 2=Male) | Integer |
|
||||
| `{age}` | Patient age | Float |
|
||||
| `{ref_low}` | Reference range low value | Float |
|
||||
| `{ref_high}` | Reference range high value | Float |
|
||||
|
||||
### Gender Mapping
|
||||
|
||||
The `gender` variable accepts the following values:
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `0` | Unknown |
|
||||
| `1` | Female |
|
||||
| `2` | Male |
|
||||
|
||||
Or use string values: `'unknown'`, `'female'`, `'male'`
|
||||
|
||||
---
|
||||
|
||||
## Implicit Multiplication
|
||||
|
||||
Implicit multiplication is not supported. Always use `*` between values:
|
||||
|
||||
| Expression | Use Instead |
|
||||
|------------|-------------|
|
||||
| `2x` | `2 * x` |
|
||||
| `{result}{factor}` | `{result} * {factor}` |
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Calculation
|
||||
|
||||
```php
|
||||
use App\Services\CalculatorService;
|
||||
|
||||
$calculator = new CalculatorService();
|
||||
|
||||
// Simple arithmetic
|
||||
$result = $calculator->calculate("5 + 3 * 2");
|
||||
// Result: 11
|
||||
|
||||
// Using min/max
|
||||
$result = $calculator->calculate("max({result}, 10)", ['result' => 7]);
|
||||
// Result: 10
|
||||
```
|
||||
|
||||
### With Variables
|
||||
|
||||
```php
|
||||
$formula = "{result} * {factor} + 10";
|
||||
$variables = [
|
||||
'result' => 5.2,
|
||||
'factor' => 2
|
||||
];
|
||||
|
||||
$result = $calculator->calculate($formula, $variables);
|
||||
// Result: 20.4
|
||||
```
|
||||
|
||||
### BMI Calculation
|
||||
|
||||
```php
|
||||
$formula = "{weight} / ({height} ** 2)";
|
||||
$variables = [
|
||||
'weight' => 70, // kg
|
||||
'height' => 1.75 // meters
|
||||
];
|
||||
|
||||
$result = $calculator->calculate($formula, $variables);
|
||||
// Result: 22.86
|
||||
```
|
||||
|
||||
### Gender-Based Calculation
|
||||
|
||||
```php
|
||||
// Apply different multipliers based on gender
|
||||
$formula = "{result} * (1 + 0.1 * {gender})";
|
||||
$variables = [
|
||||
'result' => 100,
|
||||
'gender' => 1 // Female = 1
|
||||
];
|
||||
|
||||
$result = $calculator->calculate($formula, $variables);
|
||||
// Result: 110
|
||||
```
|
||||
|
||||
### Complex Formula
|
||||
|
||||
```php
|
||||
// Pythagorean theorem
|
||||
$formula = "(({a} ** 2 + {b} ** 2) ** 0.5)";
|
||||
$variables = [
|
||||
'a' => 3,
|
||||
'b' => 4
|
||||
];
|
||||
|
||||
$result = $calculator->calculate($formula, $variables);
|
||||
// Result: 5
|
||||
```
|
||||
|
||||
### Using calculateFromDefinition
|
||||
|
||||
```php
|
||||
$calcDef = [
|
||||
'FormulaCode' => '{result} * {factor} + {gender}',
|
||||
'Factor' => 2
|
||||
];
|
||||
|
||||
$testValues = [
|
||||
'result' => 10,
|
||||
'gender' => 1 // Female
|
||||
];
|
||||
|
||||
$result = $calculator->calculateFromDefinition($calcDef, $testValues);
|
||||
// Result: 21 (10 * 2 + 1)
|
||||
```txt
|
||||
{result} * {factor} + 10
|
||||
{weight} / ({height} ** 2)
|
||||
{result} * (1 + 0.1 * {gender})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Formula Validation
|
||||
## Validation and Errors
|
||||
|
||||
Validate formulas before storing them:
|
||||
Validate formulas before saving.
|
||||
|
||||
```php
|
||||
$validation = $calculator->validate("{result} / {factor}");
|
||||
// Returns: ['valid' => true, 'error' => null]
|
||||
Typical error cases:
|
||||
|
||||
$validation = $calculator->validate("{result} /");
|
||||
// Returns: ['valid' => false, 'error' => 'Error message']
|
||||
```
|
||||
- invalid syntax,
|
||||
- missing variable,
|
||||
- non-numeric value after normalization,
|
||||
- division by zero.
|
||||
|
||||
### Extract Variables
|
||||
|
||||
Get a list of variables used in a formula:
|
||||
|
||||
```php
|
||||
$variables = $calculator->extractVariables("{result} * {factor} + {age}");
|
||||
// Returns: ['result', 'factor', 'age']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
The service throws exceptions for invalid formulas or missing variables:
|
||||
|
||||
```php
|
||||
try {
|
||||
$result = $calculator->calculate("{result} / 0");
|
||||
} catch (\Exception $e) {
|
||||
// Handle division by zero or other errors
|
||||
log_message('error', $e->getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
Common errors:
|
||||
|
||||
- **Invalid formula syntax**: Malformed expressions
|
||||
- **Missing variable**: Variable placeholder not provided in data array
|
||||
- **Non-numeric value**: Variables must be numeric
|
||||
- **Division by zero**: Mathematical error
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always validate formulas** before storing in database
|
||||
2. **Use placeholder syntax** `{variable_name}` for clarity
|
||||
3. **Handle exceptions** in production code
|
||||
4. **Test edge cases** like zero values and boundary conditions
|
||||
5. **Document formulas** with comments in your code
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Symfony ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html)
|
||||
- `app/Services/CalculatorService.php`
|
||||
Recommended flow: validate formula -> save formula -> evaluate with guarded error handling.
|
||||
|
||||
@ -2,180 +2,153 @@
|
||||
layout: clqms-post.njk
|
||||
tags: clqms
|
||||
title: "Test Rule Engine Documentation"
|
||||
description: "Comprehensive guide to the CLQMS Rule Engine DSL, syntax, and action definitions."
|
||||
description: "Concept, catalog, and API contracts for the CLQMS Rule Engine"
|
||||
date: 2026-03-16
|
||||
order: 8
|
||||
---
|
||||
|
||||
# Test Rule Engine Documentation
|
||||
# Test Rule Engine: Concept and API Contract
|
||||
|
||||
## Overview
|
||||
## Concept
|
||||
|
||||
The CLQMS Rule Engine evaluates business rules that inspect orders, patients, and tests, then executes actions when the compiled condition matches.
|
||||
The CLQMS Rule Engine executes business rules when specific events occur.
|
||||
|
||||
Rules are authored using a domain specific language stored in `ruledef.ConditionExpr`. Before the platform executes any rule, the DSL must be compiled into JSON and stored in `ConditionExprCompiled`, and each rule must be linked to the tests it should influence via `testrule`.
|
||||
Each rule follows this lifecycle:
|
||||
|
||||
### Execution Flow
|
||||
1. Author DSL in `ConditionExpr`.
|
||||
2. Compile DSL using `POST /api/rule/compile`.
|
||||
3. Save compiled JSON in `ConditionExprCompiled`.
|
||||
4. Link the rule to tests via `testrule.TestSiteID`.
|
||||
5. On trigger (`test_created`, `result_updated`), execute `then` or `else` actions.
|
||||
|
||||
1. Write or edit the DSL in `ConditionExpr`.
|
||||
2. POST the expression to `POST /api/rule/compile` to validate syntax and produce compiled JSON.
|
||||
3. Save the compiled payload into `ConditionExprCompiled` and persist the rule in `ruledef`.
|
||||
4. Link the rule to one or more tests through `testrule.TestSiteID` (rules only run for linked tests).
|
||||
5. When the configured event fires (`test_created` or `result_updated`), the engine evaluates `ConditionExprCompiled` and runs the resulting `then` or `else` actions.
|
||||
Rules without compiled JSON or without test links are not executed.
|
||||
|
||||
> **Note:** The rule engine currently fires only for `test_created` and `result_updated`. Other event codes can exist in the database but are not triggered by the application unless additional `RuleEngineService::run(...)` calls are added.
|
||||
---
|
||||
|
||||
## Event Triggers
|
||||
|
||||
| Event Code | Status | Trigger Point |
|
||||
|------------|--------|----------------|
|
||||
| `test_created` | Active | Fired after a new test row is persisted; the handler calls `RuleEngineService::run('test_created', ...)` to evaluate test-scoped rules |
|
||||
| `result_updated` | Active | Fired whenever a test result is saved or updated so result-dependent rules run immediately |
|
||||
| `test_created` | Active | Runs after a new test row is saved |
|
||||
| `result_updated` | Active | Runs when a result is saved or edited |
|
||||
|
||||
Other event codes remain in the database for future workflows, but only `test_created` and `result_updated` are executed by the current application flow.
|
||||
Other event codes may exist in data, but these are the active runtime triggers.
|
||||
|
||||
## Rule Structure
|
||||
---
|
||||
|
||||
```
|
||||
Rule
|
||||
├── Event Trigger (when to run)
|
||||
├── Conditions (when to match)
|
||||
└── Actions (what to do)
|
||||
## Rule Shape
|
||||
|
||||
```txt
|
||||
Rule = Event + Condition + Actions
|
||||
```
|
||||
|
||||
The DSL expression lives in `ConditionExpr`. The compile endpoint (`/api/rule/compile`) renders the lifeblood of execution, producing `conditionExpr`, `valueExpr`, `then`, and `else` nodes that the engine consumes at runtime.
|
||||
DSL format:
|
||||
|
||||
## Syntax Guide
|
||||
|
||||
### Basic Format
|
||||
|
||||
```
|
||||
```txt
|
||||
if(condition; then-action; else-action)
|
||||
```
|
||||
|
||||
### Logical Operators
|
||||
Multi-action branches use `:` and execute left to right:
|
||||
|
||||
- Use `&&` for AND (all sub-conditions must match).
|
||||
- Use `||` for OR (any matching branch satisfies the rule).
|
||||
- Surround mixed logic with parentheses for clarity and precedence.
|
||||
|
||||
### Multi-Action Syntax
|
||||
|
||||
Actions within any branch are separated by `:` and evaluated in order. Every `then` and `else` branch must end with an action; use `nothing` when no further work is required.
|
||||
|
||||
```
|
||||
```txt
|
||||
if(sex('M'); result_set(0.5):test_insert('HBA1C'); nothing)
|
||||
```
|
||||
|
||||
### Multiple Rules
|
||||
---
|
||||
|
||||
Create each rule as its own `ruledef` row; do not chain expressions with commas. The `testrule` table manages rule-to-test mappings, so multiple rules can attach to the same test. Example:
|
||||
|
||||
1. Insert `RULE_MALE_RESULT` and `RULE_SENIOR_COMMENT` in `ruledef`.
|
||||
2. Add two `testrule` rows linking each rule to the appropriate `TestSiteID`.
|
||||
|
||||
Each rule compiles and runs independently when its trigger fires and the test is linked.
|
||||
|
||||
## Available Functions
|
||||
## Catalog (Simplified)
|
||||
|
||||
### Conditions
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| Function / Pattern | Meaning | Example |
|
||||
|--------------------|---------|---------|
|
||||
| `sex('M'|'F')` | Match patient sex | `sex('M')` |
|
||||
| `priority('R'|'S'|'U')` | Match order priority | `priority('S')` |
|
||||
| `age > 18` | Numeric age comparisons (`>`, `<`, `>=`, `<=`) | `age >= 18 && age <= 65` |
|
||||
| `requested('CODE')` | Check whether the order already requested a test (queries `patres`) | `requested('GLU')` |
|
||||
| `age` comparisons | Numeric age checks | `age >= 18 && age <= 65` |
|
||||
| `requested('CODE')` | Checks if test code exists in order | `requested('GLU')` |
|
||||
|
||||
### Logical Operators
|
||||
|
||||
| Operator | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `&&` | AND (all truthy) | `sex('M') && age > 40` |
|
||||
| `||` | OR (any truthy) | `sex('M') || age > 65` |
|
||||
| `()` | Group expressions | `(sex('M') && age > 40) || priority('S')` |
|
||||
| `&&` | AND | `sex('M') && age > 40` |
|
||||
| `\|\|` | OR | `sex('M') \|\| age > 65` |
|
||||
| `!` | NOT | `!(sex('M'))` |
|
||||
| `()` | Grouping | `(sex('M') && age > 40) \|\| priority('S')` |
|
||||
|
||||
## Actions
|
||||
### Actions
|
||||
|
||||
| Action | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `result_set(value)` | (deprecated) Write to `patres.Result` for the current context test | `result_set(0.5)` |
|
||||
| `result_set('CODE', value)` | Target a specific test by `TestSiteCode`, allowing multiple tests to be updated in one rule | `result_set('tesA', 0.5)` |
|
||||
| `test_insert('CODE')` | Insert a test row by `TestSiteCode` if it doesn’t already exist for the order | `test_insert('HBA1C')` |
|
||||
| `test_delete('CODE')` | Remove a previously requested test from the current order when the rule deems it unnecessary | `test_delete('INS')` |
|
||||
| `comment_insert('text')` | Insert an order comment (`ordercom`) describing priority or clinical guidance | `comment_insert('Male patient - review')` |
|
||||
| `nothing` | Explicit no-op to terminate an action chain | `nothing` |
|
||||
| Action | Meaning | Example |
|
||||
|--------|---------|---------|
|
||||
| `result_set(value)` | Set current-context result (deprecated style) | `result_set(0.5)` |
|
||||
| `result_set('CODE', value)` | Set result for a specific test code | `result_set('tesA', 0.5)` |
|
||||
| `test_insert('CODE')` | Insert test if missing | `test_insert('HBA1C')` |
|
||||
| `test_delete('CODE')` | Remove test from order | `test_delete('INS')` |
|
||||
| `comment_insert('text')` | Insert order comment | `comment_insert('Review required')` |
|
||||
| `nothing` | No operation | `nothing` |
|
||||
|
||||
> **Note:** `set_priority()` was removed. Use `comment_insert()` for priority notes without altering billing.
|
||||
`set_priority()` is removed; use `comment_insert()` when you need to record priority notes.
|
||||
|
||||
## Runtime Requirements
|
||||
---
|
||||
|
||||
1. **Compiled expression required:** Rules without `ConditionExprCompiled` are ignored (see `RuleEngineService::run`).
|
||||
2. **Order context:** `context.order.InternalOID` must exist for any action that writes to `patres` or `ordercom`.
|
||||
3. **TestSiteID:** `result_set()` needs `testSiteID` (either provided in context or from `order.TestSiteID`). When you provide a `TestSiteCode` as the first argument (`result_set('tesA', value)`), the engine resolves that code before writing the result. `test_insert()` resolves a `TestSiteID` via the `TestSiteCode` in `TestDefSiteModel`, and `test_delete()` removes the matching `TestSiteID` rows when needed.
|
||||
4. **Requested check:** `requested('CODE')` inspects `patres` rows for the same `OrderID` and `TestSiteCode`.
|
||||
## Example API Contract
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
if(sex('M'); result_set('tesA', 0.5):result_set('tesB', 1.2); result_set('tesA', 0.6):result_set('tesB', 1.0))
|
||||
```
|
||||
Sets both `tesA`/`tesB` results together per branch.
|
||||
|
||||
```
|
||||
if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing)
|
||||
```
|
||||
Adds new tests when glucose is already requested.
|
||||
|
||||
```
|
||||
if(sex('M') && age > 40; result_set(1.2); result_set(1.0))
|
||||
```
|
||||
|
||||
```
|
||||
if((sex('M') && age > 40) || (sex('F') && age > 50); result_set(1.5); result_set(1.0))
|
||||
```
|
||||
|
||||
```
|
||||
if(priority('S'); result_set('URGENT'):test_insert('STAT_TEST'); result_set('NORMAL'))
|
||||
```
|
||||
|
||||
```
|
||||
if(sex('M') && age > 40; result_set(1.5):test_insert('EXTRA_TEST'):comment_insert('Male over 40'); nothing)
|
||||
```
|
||||
|
||||
```
|
||||
if(sex('F') && (age >= 18 && age <= 50) && priority('S'); result_set('HIGH_PRIO'):comment_insert('Female stat 18-50'); result_set('NORMAL'))
|
||||
```
|
||||
|
||||
```
|
||||
if(requested('GLU'); test_delete('INS'):comment_insert('Duplicate insulin request removed'); nothing)
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
### Compile DSL
|
||||
|
||||
Validates the DSL and returns a compiled JSON structure that should be persisted in `ConditionExprCompiled`.
|
||||
### 1) Compile DSL
|
||||
|
||||
```http
|
||||
POST /api/rule/compile
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Purpose: Validate DSL and return compiled JSON for `ConditionExprCompiled`.
|
||||
|
||||
Request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"expr": "if(sex('M'); result_set(0.5); result_set(0.6))"
|
||||
}
|
||||
```
|
||||
|
||||
The response contains `raw`, `compiled`, and `conditionExprCompiled` fields; store the JSON payload in `ConditionExprCompiled` before saving the rule.
|
||||
Success response example:
|
||||
|
||||
### Evaluate Expression (Validation)
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"raw": "if(sex('M'); result_set(0.5); result_set(0.6))",
|
||||
"compiled": {
|
||||
"conditionExpr": "sex('M')",
|
||||
"then": [{ "type": "result_set", "args": [0.5] }],
|
||||
"else": [{ "type": "result_set", "args": [0.6] }]
|
||||
},
|
||||
"conditionExprCompiled": "{...json string...}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This endpoint simply evaluates an expression against a runtime context. It does not compile DSL or persist the result.
|
||||
Error response example:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Invalid expression near ';'",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 2) Validate Expression Against Context
|
||||
|
||||
```http
|
||||
POST /api/rule/validate
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Purpose: Evaluate an expression with context only (no compile, no persistence).
|
||||
|
||||
Request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"expr": "order[\"Age\"] > 18",
|
||||
"context": {
|
||||
@ -186,12 +159,29 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### Create Rule (example)
|
||||
Success response example:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"result": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3) Create Rule
|
||||
|
||||
```http
|
||||
POST /api/rule
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Purpose: Save rule metadata, DSL, compiled payload, and linked tests.
|
||||
|
||||
Request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"RuleCode": "RULE_001",
|
||||
"RuleName": "Sex-based result",
|
||||
@ -202,89 +192,32 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
Success response example:
|
||||
|
||||
### Tables
|
||||
|
||||
- **ruledef** – stores rule metadata, raw DSL, and compiled JSON.
|
||||
- **testrule** – mapping table that links rules to tests via `TestSiteID`.
|
||||
- **ruleaction** – deprecated. Actions are now embedded in `ConditionExprCompiled`.
|
||||
|
||||
### Key Columns
|
||||
|
||||
| Column | Table | Description |
|
||||
|--------|-------|-------------|
|
||||
| `EventCode` | ruledef | The trigger event (typically `test_created` or `result_updated`). |
|
||||
| `ConditionExpr` | ruledef | Raw DSL expression (semicolon syntax). |
|
||||
| `ConditionExprCompiled` | ruledef | JSON payload consumed at runtime (`then`, `else`, etc.). |
|
||||
| `ActionType` / `ActionParams` | ruleaction | Deprecated; actions live in compiled JSON now. |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Always run `POST /api/rule/compile` before persisting a rule so `ConditionExprCompiled` exists.
|
||||
2. Link each rule to the relevant tests via `testrule.TestSiteID`—rules are scoped to linked tests.
|
||||
3. Use multi-action (`:`) to bundle several actions in a single branch; finish the branch with `nothing` if no further work is needed.
|
||||
4. Prefer `comment_insert()` over the removed `set_priority()` action when documenting priority decisions.
|
||||
5. Group complex boolean logic with parentheses for clarity when mixing `&&` and `||`.
|
||||
6. Use `requested('CODE')` responsibly; it performs a database lookup on `patres` so avoid invoking it in high-frequency loops without reason.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Syntax Changes (v2.0)
|
||||
|
||||
The DSL moved from ternary (`condition ? action : action`) to semicolon syntax. Existing rules must be migrated via the provided script.
|
||||
|
||||
| Old Syntax | New Syntax |
|
||||
|------------|------------|
|
||||
| `if(condition ? action : action)` | `if(condition; action; action)` |
|
||||
|
||||
#### Migration Examples
|
||||
|
||||
```
|
||||
# BEFORE
|
||||
if(sex('M') ? result_set(0.5) : result_set(0.6))
|
||||
|
||||
# AFTER
|
||||
if(sex('M'); result_set(0.5); result_set(0.6))
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Rule created",
|
||||
"data": {
|
||||
"RuleCode": "RULE_001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
# BEFORE
|
||||
if(sex('F') ? set_priority('S') : nothing)
|
||||
---
|
||||
|
||||
# AFTER
|
||||
if(sex('F'); comment_insert('Female patient - review priority'); nothing)
|
||||
```
|
||||
## Minimal End-to-End Flow
|
||||
|
||||
#### Migration Process
|
||||
1. Compile DSL with `/api/rule/compile`.
|
||||
2. Store returned `conditionExprCompiled` in `ruledef.ConditionExprCompiled`.
|
||||
3. Create rule and link `TestSiteIDs` via `/api/rule`.
|
||||
4. Trigger event (`test_created` or `result_updated`) and verify actions ran.
|
||||
|
||||
Run the migration which:
|
||||
---
|
||||
|
||||
1. Converts ternary syntax to semicolon syntax.
|
||||
2. Recompiles every expression into JSON so the engine consumes `ConditionExprCompiled` directly.
|
||||
3. Eliminates reliance on the `ruleaction` table.
|
||||
## Runtime Requirements (Quick Check)
|
||||
|
||||
```bash
|
||||
php spark migrate
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Rule Not Executing
|
||||
|
||||
1. Ensure the rule has a compiled payload (`ConditionExprCompiled`).
|
||||
2. Confirm the rule is linked to the relevant `TestSiteID` in `testrule`.
|
||||
3. Verify the `EventCode` matches the currently triggered event (`test_created` or `result_updated`).
|
||||
4. Check that `EndDate IS NULL` for both `ruledef` and `testrule` (soft deletes disable execution).
|
||||
5. Use `/api/rule/compile` to validate the DSL and view errors.
|
||||
|
||||
### Invalid Expression
|
||||
|
||||
1. POST the expression to `/api/rule/compile` to get a detailed compilation error.
|
||||
2. If using `/api/rule/validate`, supply the expected `context` payload; the endpoint simply evaluates the expression without saving it.
|
||||
|
||||
### Runtime Errors
|
||||
|
||||
- `RESULT_SET requires context.order.InternalOID` or `testSiteID`: include those fields in the context passed to `RuleEngineService::run()`.
|
||||
- `TEST_INSERT` failures mean the provided `TestSiteCode` does not exist or the rule attempted to insert a duplicate test; check `testdefsite` and existing `patres` rows.
|
||||
- `COMMENT_INSERT requires comment`: ensure the action provides text.
|
||||
1. `ConditionExprCompiled` must be present.
|
||||
2. Rule must be linked to the target `TestSiteID`.
|
||||
3. Trigger event must match `EventCode`.
|
||||
4. Context must include required identifiers for write actions.
|
||||
|
||||
@ -2,201 +2,148 @@
|
||||
layout: clqms-post.njk
|
||||
tags: clqms
|
||||
title: "Audit Logging"
|
||||
description: "Unified audit logging model, event catalog, and capture rules for CLQMS."
|
||||
description: "Audit logging concept and example API contracts for CLQMS"
|
||||
date: 2026-03-17
|
||||
order: 9
|
||||
---
|
||||
# Audit Logging Strategy
|
||||
|
||||
## Overview
|
||||
# Audit Logging: Concept and Example API Contract
|
||||
|
||||
This document defines how CLQMS should capture audit and operational logs across four tables:
|
||||
## Concept
|
||||
|
||||
- `logpatient` — patient, visit, and ADT activity
|
||||
- `logorder` — orders, tests, specimens, results, and QC
|
||||
- `logmaster` — master data and configuration changes
|
||||
- `logsystem` — sessions, security, import/export, and system operations
|
||||
CLQMS uses four audit tables so domain activity is separated but consistent:
|
||||
|
||||
The intent is to audit all domains, including master data changes, and to standardize event capture so reporting and compliance are consistent.
|
||||
- `logpatient` - patient, identity, consent, insurance, and visit/ADT activity
|
||||
- `logorder` - orders, specimens, results, and QC activity
|
||||
- `logmaster` - configuration and master data changes
|
||||
- `logsystem` - sessions, security, integration jobs, and system operations
|
||||
|
||||
## Table Ownership
|
||||
The goal is traceability: who did what, when, where, and why.
|
||||
|
||||
| Event | Table |
|
||||
| --- | --- |
|
||||
| Patient registered/updated/merged | `logpatient` |
|
||||
| Insurance/consent changed | `logpatient` |
|
||||
| Patient visit (admit/transfer/discharge) | `logpatient` |
|
||||
| Order created/cancelled | `logorder` |
|
||||
| Sample received/rejected | `logorder` |
|
||||
| Result entered/verified/amended | `logorder` |
|
||||
| Result released/retracted/corrected | `logorder` |
|
||||
| QC result recorded | `logorder` |
|
||||
| Test panel added/removed | `logmaster` |
|
||||
| Reference range changed | `logmaster` |
|
||||
| Analyzer config updated | `logmaster` |
|
||||
| User role changed | `logmaster` |
|
||||
| User login/logout | `logsystem` |
|
||||
| Import/export job start/end | `logsystem` |
|
||||
### Shared Audit Fields
|
||||
|
||||
## Standard Log Schema (Shared Columns)
|
||||
All log records should carry the same core metadata:
|
||||
|
||||
Use a shared schema for all four tables to keep instrumentation and reporting consistent. The legacy names below match existing patterns and can be reused.
|
||||
- identity: `UserID`, `SessionID`, `SiteID`
|
||||
- event classification: `EventID`, `ActivityID`
|
||||
- change details: `TblName`, `RecID`, `FldName`, `FldValuePrev`, `FldValueNew`
|
||||
- time/context: `LogDate`, optional `Context` JSON, optional `IpAddress`
|
||||
|
||||
| Column | Description |
|
||||
| --- | --- |
|
||||
| `LogID` (PK) | Auto increment primary key per table (e.g., `LogPatientID`) |
|
||||
| `TblName` | Source table name |
|
||||
| `RecID` | Record ID of the entity |
|
||||
| `FldName` | Field name that changed (nullable for bulk events) |
|
||||
| `FldValuePrev` | Previous value (string or JSON) |
|
||||
| `FldValueNew` | New value (string or JSON) |
|
||||
| `UserID` | Acting user ID (nullable for system actions) |
|
||||
| `SiteID` | Site context |
|
||||
| `DIDType` | Device identifier type |
|
||||
| `DID` | Device identifier |
|
||||
| `MachineID` | Workstation or host identifier |
|
||||
| `SessionID` | Session identifier |
|
||||
| `AppID` | Client application ID |
|
||||
| `ProcessID` | Process/workflow identifier |
|
||||
| `WebPageID` | UI page/context (nullable) |
|
||||
| `EventID` | Event code (see catalog) |
|
||||
| `ActivityID` | Action code (create/update/delete/read/etc.) |
|
||||
| `Reason` | User/system reason |
|
||||
| `LogDate` | Timestamp of event |
|
||||
| `Context` | JSON metadata (optional but recommended) |
|
||||
| `IpAddress` | Remote IP (optional but recommended) |
|
||||
### Category Reference (Simplified)
|
||||
|
||||
Recommended: keep a JSON string in `Context` for extra details (e.g., route, request id, batch id, error message). Use size limits to avoid oversized rows.
|
||||
- **Patient (`logpatient`)**: register/update/merge, consent/insurance changes, ADT events
|
||||
- **Order (`logorder`)**: create/cancel/reopen, specimen lifecycle, result verify/amend/release,
|
||||
QC record/override
|
||||
- **Master (`logmaster`)**: test definitions, reference ranges, analyzer/integration config,
|
||||
user/role/permission changes
|
||||
- **System (`logsystem`)**: login/logout/failures, token lifecycle, import/export jobs,
|
||||
background process events
|
||||
|
||||
## Event Catalog
|
||||
Recommended `ActivityID` values:
|
||||
`CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`,
|
||||
`AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT`.
|
||||
|
||||
### logpatient
|
||||
---
|
||||
|
||||
**Patient core**
|
||||
## Example API Contract (Reference Pattern)
|
||||
|
||||
- Register patient
|
||||
- Update demographics
|
||||
- Merge/unmerge/split
|
||||
- Identity changes (MRN, external identifiers)
|
||||
- Consent grant/revoke/update
|
||||
- Insurance add/update/remove
|
||||
- Patient record view (if required by compliance)
|
||||
These contracts are a reference pattern for standardizing audit capture and reporting.
|
||||
|
||||
**Visit/ADT**
|
||||
### 1) Write Audit Event
|
||||
|
||||
- Admit, transfer, discharge
|
||||
- Bed/ward/unit changes
|
||||
- Visit status updates
|
||||
```http
|
||||
POST /api/audit/log
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Other**
|
||||
Purpose: write one normalized audit event, then route it to the correct log table.
|
||||
|
||||
- Patient notes/attachments added/removed
|
||||
- Patient alerts/flags changes
|
||||
Request example:
|
||||
|
||||
### logorder
|
||||
```json
|
||||
{
|
||||
"domain": "order",
|
||||
"TblName": "patres",
|
||||
"RecID": "12345",
|
||||
"FldName": "Result",
|
||||
"FldValuePrev": "1.0",
|
||||
"FldValueNew": "1.2",
|
||||
"EventID": "RESULT_VERIFIED",
|
||||
"ActivityID": "VERIFY",
|
||||
"Reason": "Auto verification rule passed",
|
||||
"UserID": "u001",
|
||||
"SiteID": "s01",
|
||||
"SessionID": "sess-8f31",
|
||||
"LogDate": "2026-03-17T10:21:33Z",
|
||||
"Context": {
|
||||
"order_id": "OID-7788",
|
||||
"test_code": "GLU",
|
||||
"request_id": "req-2a9"
|
||||
},
|
||||
"IpAddress": "10.2.4.8"
|
||||
}
|
||||
```
|
||||
|
||||
**Orders/tests**
|
||||
Success response example:
|
||||
|
||||
- Create/cancel/reopen order
|
||||
- Add/remove tests
|
||||
- Priority changes
|
||||
- Order comments added/removed
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Audit event stored",
|
||||
"data": {
|
||||
"table": "logorder",
|
||||
"logId": 556901
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Specimen lifecycle**
|
||||
Error response example:
|
||||
|
||||
- Collected, labeled, received, rejected
|
||||
- Centrifuged, aliquoted, stored
|
||||
- Disposed/expired
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Missing required field: EventID",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
**Results**
|
||||
### 2) Query Audit Logs
|
||||
|
||||
- Result entered/updated
|
||||
- Verified/amended
|
||||
- Released/retracted/corrected
|
||||
- Result comments/interpretation changes
|
||||
- Auto-verification override
|
||||
```http
|
||||
GET /api/audit/logs?domain=order&recId=12345&eventId=RESULT_VERIFIED&from=2026-03-01&to=2026-03-31
|
||||
```
|
||||
|
||||
**QC**
|
||||
Purpose: retrieve normalized audit history with filters for investigation and compliance.
|
||||
|
||||
- QC result recorded
|
||||
- QC failure/override
|
||||
Success response example:
|
||||
|
||||
### logmaster
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"logId": 556901,
|
||||
"table": "logorder",
|
||||
"EventID": "RESULT_VERIFIED",
|
||||
"ActivityID": "VERIFY",
|
||||
"TblName": "patres",
|
||||
"RecID": "12345",
|
||||
"FldName": "Result",
|
||||
"FldValuePrev": "1.0",
|
||||
"FldValueNew": "1.2",
|
||||
"UserID": "u001",
|
||||
"SiteID": "s01",
|
||||
"LogDate": "2026-03-17T10:21:33Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Value sets**
|
||||
---
|
||||
|
||||
- Create/update/retire value set items
|
||||
## Capture Rules (Short)
|
||||
|
||||
**Test definitions**
|
||||
|
||||
- Test definition updates (units, methods, ranges)
|
||||
- Reference range changes
|
||||
- Formula/delta check changes
|
||||
- Test panel membership add/remove
|
||||
|
||||
**Infrastructure**
|
||||
|
||||
- Analyzer/instrument config changes
|
||||
- Host app integration config
|
||||
- Coding system changes
|
||||
|
||||
**Users/roles**
|
||||
|
||||
- User create/disable/reset
|
||||
- Role changes
|
||||
- Permission changes
|
||||
|
||||
**Sites/workstations**
|
||||
|
||||
- Site/location/workstation CRUD
|
||||
|
||||
### logsystem
|
||||
|
||||
**Sessions & security**
|
||||
|
||||
- Login/logout
|
||||
- Failed login attempts
|
||||
- Lockouts/password resets
|
||||
- Token issue/refresh/revoke
|
||||
- Authorization failures
|
||||
|
||||
**Import/export**
|
||||
|
||||
- Import/export job start/end
|
||||
- Batch ID, source, record counts, status
|
||||
|
||||
**System operations**
|
||||
|
||||
- Background jobs start/end
|
||||
- Integration sync runs
|
||||
- System config changes
|
||||
- Service errors that affect data integrity
|
||||
|
||||
## Activity & Event Codes
|
||||
|
||||
Use consistent `ActivityID` and `EventID` values. Recommended defaults:
|
||||
|
||||
- `ActivityID`: `CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`, `AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT`
|
||||
- `EventID`: domain-specific codes (e.g., `PATIENT_REGISTERED`, `ORDER_CREATED`, `RESULT_VERIFIED`, `QC_RECORDED`)
|
||||
|
||||
## Capture Guidelines
|
||||
|
||||
- Always capture `UserID`, `SessionID`, `SiteID`, and `LogDate` when available.
|
||||
- If the action is system-driven, set `UserID` to `SYSTEM` (or null) and add context in `Context`.
|
||||
- Store payload diffs in `FldValuePrev` and `FldValueNew` for single-field changes; for multi-field changes, put a JSON diff in `Context` and leave `FldName` null.
|
||||
- For bulk operations, store batch metadata in `Context` (`batch_id`, `record_count`, `source`).
|
||||
- Do not log secrets, tokens, or full PHI when not required. Mask or omit sensitive fields.
|
||||
|
||||
## Retention & Governance
|
||||
|
||||
- Define retention policy per table (e.g., 7 years for patient/order, 2 years for system).
|
||||
- Archive before purge; record purge activity in `logsystem`.
|
||||
- Restrict write/delete permissions to service accounts only.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
1. Create the four tables with shared schema (or migrate existing log tables to match).
|
||||
2. Add a single audit service with helpers to build a normalized payload.
|
||||
3. Instrument controllers/services for each event category above.
|
||||
4. Add automated tests for representative audit writes.
|
||||
5. Document `EventID` codes used by each endpoint/service.
|
||||
- Always include `UserID`, `SessionID`, `SiteID`, `EventID`, `ActivityID`, and `LogDate`.
|
||||
- For multi-field updates, keep `FldName` null and store the diff in `Context`.
|
||||
- Do not log secrets/tokens; mask sensitive values in payloads.
|
||||
- For system-driven actions, use `UserID = SYSTEM` (or null) and explain in `Context`.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user