Add support for archived departments with schema updates, API integration, and filtering logic

- Added `archived` column and index to `departments` table, ensuring schema initialization via `departments_schema_bootstrap`.
- Updated OpenAPI spec to include `archived` attribute and `filters=archived` query parameter with superuser access control.
- Enhanced `Departments` API to support archived department filtering and retrieval.
- Modified `ApiFixtures`, `departments_o`, and related tests to validate behavior for archived departments.
- Added unit and API tests to ensure correct handling of archived departments and filter enforceability.
This commit is contained in:
Jeppe Bundgaard
2026-05-07 13:50:32 +02:00
parent a4c2b4e95a
commit 0cbc3e9aa5
8 changed files with 295 additions and 7 deletions
+12 -1
View File
@@ -3395,7 +3395,7 @@ paths:
tags:
- Departments
summary: List departments
description: Retrieve a list of all visible departments
description: Retrieve visible, active departments by default. Superuser department access may filter archived departments with `filters=archived:1`.
operationId: listDepartments
parameters:
- name: id
@@ -3406,6 +3406,11 @@ paths:
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/PerPageParam'
- $ref: '#/components/parameters/SearchParam'
- name: filters
in: query
schema:
type: string
description: Comma-separated field filters. `archived:1` is only honored for users with superuser department access.
responses:
'200':
description: Departments retrieved successfully
@@ -15607,6 +15612,8 @@ components:
type: integer
visible:
type: boolean
archived:
type: boolean
dimension:
type: integer
branding:
@@ -15677,6 +15684,8 @@ components:
type: integer
visible:
type: boolean
archived:
type: boolean
longitude:
type: number
format: float
@@ -15697,6 +15706,8 @@ components:
type: string
visible:
type: boolean
archived:
type: boolean
longitude:
type: number
format: float
@@ -76,11 +76,13 @@ class department_daily_report_complaints_schema_bootstrap
dimension INT NOT NULL DEFAULT 0,
branding INT NOT NULL DEFAULT 0,
visible TINYINT(1) NOT NULL DEFAULT 1,
archived TINYINT(1) NOT NULL DEFAULT 0,
longitude DECIMAL(10,7) NOT NULL DEFAULT 0,
latitude DECIMAL(10,7) NOT NULL DEFAULT 0,
order_priority INT NOT NULL DEFAULT 0,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_departments_archived (archived)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
@@ -0,0 +1,89 @@
<?php
namespace classes;
/**
* Ensures additive schema for department lifecycle metadata.
*/
class departments_schema_bootstrap
{
private static bool $initialized = false;
private const ARCHIVED_INDEX = 'idx_departments_archived';
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'departments')) {
return;
}
if (!self::columnExists($db, 'departments', 'archived')) {
$db->query(
"ALTER TABLE departments
ADD COLUMN archived TINYINT(1) NOT NULL DEFAULT 0
AFTER visible"
);
}
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
$db->query(
"ALTER TABLE departments
ADD INDEX " . self::ARCHIVED_INDEX . " (archived)"
);
}
self::$initialized = true;
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function indexExists(object $db, string $table, string $index): bool
{
$table = self::escapeIdentifier($table);
$index = self::escapeIdentifier($index);
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function escapeIdentifier(string $value): string
{
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
}
}
@@ -3,6 +3,7 @@
namespace objects;
use classes\db;
use classes\departments_schema_bootstrap;
use classes\object_property;
use classes\slack;
use classes\stripe;
@@ -20,6 +21,7 @@ class departments_o extends db
public department_variables_o $variables; // The department variables object
public object_property $dimension; // The dimension of the department
public object_property $visible; // The visibility of the department
public object_property $archived; // Whether the department is archived
public object_property $branding; // The branding of the department
public object_property $longitude; // The longitude of the department (Can be null)
public object_property $latitude; // The latitude of the department (Can be null)
@@ -29,6 +31,7 @@ class departments_o extends db
public function structure(): void
{
departments_schema_bootstrap::ensureTables();
$this->setTable('departments');
}
@@ -103,6 +106,7 @@ class departments_o extends db
$this->dimension = new object_property($this->table, $this->id, 'dimension', 'int', false);
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
@@ -156,6 +160,7 @@ class departments_o extends db
'description' => $department['description'],
'id' => $department['id'],
'visible' => $department['visible'],
'archived' => $department['archived'] ?? 0,
];
}, $departments);
}
+63 -3
View File
@@ -17,6 +17,59 @@ class departmentsRoute
{
use route_t;
private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): string
{
global $response;
$filters = $response->getRequestParameter('filters') ?? [];
if (is_string($filters) || is_array($filters)) {
$filters = $departments->filter_string_to_array($filters);
} else {
$filters = [];
}
$archived = 0;
if (
$canListArchived
&& array_key_exists('archived', $filters)
&& self::isTruthyBooleanValue($filters['archived'])
) {
$archived = 1;
}
unset($filters['visible'], $filters['archived']);
$filters['visible'] = 1;
$filters['archived'] = $archived;
return $departments->array_to_filters($filters);
}
private static function isTruthyBooleanValue(mixed $value): bool
{
if (is_array($value)) {
foreach ($value as $singleValue) {
if (self::isTruthyBooleanValue($singleValue)) {
return true;
}
}
return false;
}
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int)$value === 1;
}
if (is_string($value)) {
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
}
return false;
}
public function run(): void
{
$this->get('/departments', function () {
@@ -49,6 +102,7 @@ class departmentsRoute
'description',
'economic_department_id',
'visible',
'archived',
'longitude',
'latitude',
])
@@ -63,6 +117,7 @@ class departmentsRoute
'updated_at' => (string)$department['updated_at'],
'dimension' => (int)$department['dimension'],
'branding' => (int)$department['branding'],
'archived' => (bool)(int)($department['archived'] ?? 0),
'longitude' => (float)$department['longitude'],
'latitude' => (float)$department['latitude'],
'order_priority' => (int)$department['order_priority'],
@@ -73,9 +128,10 @@ class departmentsRoute
}
return $tmp_department;
},
$departments_o->forceRestrictFilters([
'visible' => 1, // Only show visible departments, this is to prevent showing internal system departments to the end-user.
])
$this->buildDepartmentListFilters(
$departments_o,
$user->hasPermission('superuser_fetch_department')
)
)
);
} else {
@@ -160,6 +216,10 @@ class departmentsRoute
if (self::isParametersSet(['order_priority'])) {
$department->order_priority->set((int)self::getParameter('order_priority'));
}
if (self::isParametersSet(['archived'])) {
$department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived')));
}
$department->objectChanged();
// Log the incident
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
// Return a success message
@@ -20,6 +20,11 @@ it('lists only visible departments and can return a single department with the s
'name' => 'Hidden Department',
'visible' => 0,
]);
$archivedDepartment = api_fixtures()->createDepartment([
'name' => 'Archived Department',
'visible' => 1,
'archived' => 1,
]);
$webhookDepartment = api_fixtures()->createDepartment([
'name' => 'Webhook Department',
'slack_webhook' => 'https://hooks.slack.test/example',
@@ -41,7 +46,8 @@ it('lists only visible departments and can return a single department with the s
expect($departmentIds)
->toContain($visibleDepartment['id'])
->toContain($webhookDepartment['id'])
->not->toContain($hiddenDepartment['id']);
->not->toContain($hiddenDepartment['id'])
->not->toContain($archivedDepartment['id']);
$singleResponse = api_client()->get('/departments?id=' . $webhookDepartment['id'], $session['headers']);
@@ -56,6 +62,79 @@ it('lists only visible departments and can return a single department with the s
->toHaveKey('slack_webhook', 'https://hooks.slack.test/example');
});
it('allows superusers to filter archived departments', function (): void {
api_test_covers('GET /departments', 'happy');
$session = api_fixtures()->createUserSession([
'list_departments',
'superuser_fetch_department',
]);
$activeDepartment = api_fixtures()->createDepartment([
'name' => 'Active Department',
'visible' => 1,
'archived' => 0,
]);
$archivedDepartment = api_fixtures()->createDepartment([
'name' => 'Archived Department',
'visible' => 1,
'archived' => 1,
]);
$response = api_client()->get('/departments?filters=archived:1', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$departmentIds = array_map(
static fn(array $department): int => (int)($department['id'] ?? 0),
is_array($response->data()) ? $response->data() : []
);
expect($departmentIds)
->toContain($archivedDepartment['id'])
->not->toContain($activeDepartment['id']);
foreach ($response->data() as $department) {
expect((bool)($department['archived'] ?? false))->toBeTrue();
}
});
it('does not allow regular department listings to reveal archived departments through filters', function (): void {
api_test_covers('GET /departments', 'auth');
$session = api_fixtures()->createUserSession(['list_departments']);
$activeDepartment = api_fixtures()->createDepartment([
'name' => 'Regular Active Department',
'visible' => 1,
'archived' => 0,
]);
$archivedDepartment = api_fixtures()->createDepartment([
'name' => 'Regular Archived Department',
'visible' => 1,
'archived' => 1,
]);
$response = api_client()->get('/departments?filters=archived:1', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$departmentIds = array_map(
static fn(array $department): int => (int)($department['id'] ?? 0),
is_array($response->data()) ? $response->data() : []
);
expect($departmentIds)
->toContain($activeDepartment['id'])
->not->toContain($archivedDepartment['id']);
});
it('rejects department listing when the permission is missing', function (): void {
api_test_covers('GET /departments', 'auth');
@@ -137,6 +216,7 @@ it('updates departments through the real endpoint', function (): void {
'name' => 'Updated Department',
'description' => 'Updated description',
'order_priority' => 5,
'archived' => true,
], $session['headers']);
$response
@@ -151,6 +231,7 @@ it('updates departments through the real endpoint', function (): void {
expect($row['name'] ?? null)->toBe('Updated Department');
expect($row['description'] ?? null)->toBe('Updated description');
expect((int)($row['order_priority'] ?? 0))->toBe(5);
expect((int)($row['archived'] ?? 0))->toBe(1);
});
it('rejects invalid department update requests', function (): void {
@@ -128,6 +128,7 @@ final class ApiFixtures
'dimension' => (int)($attributes['dimension'] ?? 0),
'branding' => (int)($attributes['branding'] ?? 0),
'visible' => (int)($attributes['visible'] ?? 1),
'archived' => (int)($attributes['archived'] ?? 0),
'latitude' => $attributes['latitude'] ?? 0.0,
'longitude' => $attributes['longitude'] ?? 0.0,
'order_priority' => (int)($attributes['order_priority'] ?? 0),
@@ -19,6 +19,8 @@ final class ApiSchemaBootstrap
$this->execute($name, $sql);
}
$this->ensureDepartmentArchiveSchema();
foreach ($this->viewStatements() as $name => $sql) {
$this->execute($name, $sql);
}
@@ -85,13 +87,15 @@ CREATE TABLE IF NOT EXISTS `departments` (
`dimension` INT NOT NULL DEFAULT 0,
`branding` INT NOT NULL DEFAULT 0,
`visible` TINYINT(1) NOT NULL DEFAULT 1,
`archived` TINYINT(1) NOT NULL DEFAULT 0,
`latitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
`longitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
`order_priority` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_departments_visible` (`visible`)
KEY `idx_departments_visible` (`visible`),
KEY `idx_departments_archived` (`archived`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'department_variables' => <<<'SQL'
@@ -486,4 +490,39 @@ SQL,
);
}
}
private function ensureDepartmentArchiveSchema(): void
{
if (!$this->columnExists('departments', 'archived')) {
$this->execute(
'departments.archived',
'ALTER TABLE `departments` ADD COLUMN `archived` TINYINT(1) NOT NULL DEFAULT 0 AFTER `visible`'
);
}
if (!$this->indexExists('departments', 'idx_departments_archived')) {
$this->execute(
'departments.idx_departments_archived',
'ALTER TABLE `departments` ADD INDEX `idx_departments_archived` (`archived`)'
);
}
}
private function columnExists(string $table, string $column): bool
{
$table = $this->db->real_escape_string($table);
$column = $this->db->real_escape_string($column);
$result = $this->db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
return $result !== false && $result->num_rows > 0;
}
private function indexExists(string $table, string $index): bool
{
$table = $this->db->real_escape_string($table);
$index = $this->db->real_escape_string($index);
$result = $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
return $result !== false && $result->num_rows > 0;
}
}