Appearance
Client Groups — API Analysis
Seven endpoints on the platform administration application, following the pattern the other cross-tenant catalogues use (see VAT Rate Administration): a flat collection under v1/admin, no paging (the catalogue and any one group's membership are both small), writes that return the identifier only.
The project has no shared API-conventions document yet, so the two envelope shapes these endpoints use are stated once here and not repeated per endpoint. Success:
json
{
"data": {},
"status": 200,
"message": "OK",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b21"
}Error:
json
{
"errorCode": "ERR_CLIENT_GROUP_FULL",
"errorMessage": "This group already holds its maximum number of clients.",
"status": 409,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b21"
}Authentication and permission failures are answered by the platform application's shared handling and are not restated per endpoint.
Permission codes: platform.client-groups.read (list groups, read one group's members), platform.client-groups.write (create, rename, resize, delete a group; add or remove a member).
The member picker on the manage-groups screen searches the existing platform client catalogue — see Client Management — API Analysis, GET /v1/clients — rather than a client list documented here; this file only adds the group and membership operations.
📥 GET /v1/admin/client-groups
Returns every client group, for the manage-groups screen's list view.
Authorization
Requires permission code platform.client-groups.read, platform-scoped.
Request Headers
None beyond the platform application's standard bearer authentication.
Request Body
None.
Request Parameters
None.
Request Logic
- Reads
platform.client_groupand the active-row count per group fromplatform.client_group_membership; writes nothing.
sql
SELECT g.id, g.name, g.max_client_count,
COUNT(m.id) FILTER (WHERE m.deleted_at IS NULL) AS member_count
FROM platform.client_group g
LEFT JOIN platform.client_group_membership m ON m.client_group_id = g.id
WHERE g.deleted_at IS NULL
GROUP BY g.id
ORDER BY g.name ASC;Transactional Operations
N/A — read-only.
✅ Success Response (200)
json
{
"data": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc20",
"name": "Plzeňský kraj",
"maxClientCount": 25,
"memberCount": 8
}
],
"status": 200,
"message": "OK",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
id | clientGroup.id |
name | clientGroup.name |
maxClientCount | clientGroup.maxClientCount |
memberCount | Derived: count of active clientGroupMembership rows for this group |
❌ Error Responses
None beyond the shared authentication/permission handling.
📤 POST /v1/admin/client-groups
Creates a new client group.
Authorization
Requires permission code platform.client-groups.write, platform-scoped.
Request Headers
None beyond the shared bearer authentication.
Request Body
json
{
"name": "Plzeňský kraj",
"maxClientCount": 25
}Request Parameters
None — body only.
Request Logic
- Inserts one row into
platform.client_group.
sql
INSERT INTO platform.client_group (name, max_client_count, created_by, updated_by)
VALUES (@name, @maxClientCount, @actor, @actor)
RETURNING id;Transactional Operations
Single-row insert; no related write. The insert plus its audit-log entry commit together.
✅ Success Response (201)
json
{
"data": { "id": "018fa51f-fda1-79f4-8461-2cb8f1cabc20" },
"status": 201,
"message": "Created",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
id | clientGroup.id, generated in code |
❌ Error Responses
400 ERR_CLIENT_GROUP_NAME_REQUIRED
Thrown when name is missing or empty.
json
{
"errorCode": "ERR_CLIENT_GROUP_NAME_REQUIRED",
"errorMessage": "A client group needs a name.",
"status": 400,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}400 ERR_CLIENT_GROUP_CAPACITY_REQUIRED
Thrown when maxClientCount is missing, not a positive integer, or zero — every group states a real cap, never "unlimited".
json
{
"errorCode": "ERR_CLIENT_GROUP_CAPACITY_REQUIRED",
"errorMessage": "maxClientCount must be a positive integer.",
"status": 400,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}📝 PATCH /v1/admin/client-groups/:id
Renames a group and/or changes its capacity.
Authorization
Requires permission code platform.client-groups.write, platform-scoped.
Request Headers
None beyond the shared bearer authentication.
Request Body
json
{
"name": "Plzeňský kraj",
"maxClientCount": 30
}Both fields optional; only the fields present are changed.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation / Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
id | Path | UUID | Yes | - | The group to update. | Must reference an active, existing group. | clientGroup.id |
Request Logic
- Reads the current active member count, then updates
platform.client_group.
sql
UPDATE platform.client_group
SET name = COALESCE(@name, name),
max_client_count = COALESCE(@maxClientCount, max_client_count),
updated_at = now(),
updated_by = @actor
WHERE id = @id AND deleted_at IS NULL
RETURNING id;Transactional Operations
The member-count read and the update happen inside the same transaction, so a concurrent membership add cannot slip past the capacity check.
✅ Success Response (200)
json
{
"data": { "id": "018fa51f-fda1-79f4-8461-2cb8f1cabc20" },
"status": 200,
"message": "OK",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
id | clientGroup.id |
❌ Error Responses
404 ERR_CLIENT_GROUP_NOT_FOUND
Thrown when id does not reference an active group.
json
{
"errorCode": "ERR_CLIENT_GROUP_NOT_FOUND",
"errorMessage": "Client group not found.",
"status": 404,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}409 ERR_CLIENT_GROUP_CAPACITY_BELOW_MEMBERS
Thrown when the requested maxClientCount is lower than the group's current active member count.
json
{
"errorCode": "ERR_CLIENT_GROUP_CAPACITY_BELOW_MEMBERS",
"errorMessage": "maxClientCount cannot be lower than the group's current member count.",
"status": 409,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}🗑️ DELETE /v1/admin/client-groups/:id
Deletes a client group. Blocked while it holds any active member.
Authorization
Requires permission code platform.client-groups.write, platform-scoped.
Request Headers
None beyond the shared bearer authentication.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation / Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
id | Path | UUID | Yes | - | The group to delete. | Must reference an active, existing group. | clientGroup.id |
Request Logic
- Checks for any active
platform.client_group_membershiprow before soft-deleting the group.
sql
SELECT COUNT(*) FROM platform.client_group_membership
WHERE client_group_id = @id AND deleted_at IS NULL;
UPDATE platform.client_group
SET deleted_at = now(), updated_at = now(), updated_by = @actor
WHERE id = @id AND deleted_at IS NULL;Transactional Operations
The member-count check and the soft-delete happen inside the same transaction, so a concurrent membership add cannot race a delete into an inconsistent state.
✅ Success Response (200)
json
{
"data": { "id": "018fa51f-fda1-79f4-8461-2cb8f1cabc20" },
"status": 200,
"message": "OK",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
id | clientGroup.id |
❌ Error Responses
404 ERR_CLIENT_GROUP_NOT_FOUND
Thrown when id does not reference an active group.
json
{
"errorCode": "ERR_CLIENT_GROUP_NOT_FOUND",
"errorMessage": "Client group not found.",
"status": 404,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}409 ERR_CLIENT_GROUP_HAS_MEMBERS
Thrown when the group holds at least one active member. The response lists the blocking members so the operator can remove them first.
json
{
"errorCode": "ERR_CLIENT_GROUP_HAS_MEMBERS",
"errorMessage": "This group still has members; remove them before deleting the group.",
"status": 409,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31",
"memberCount": 8
}📥 GET /v1/admin/client-groups/:id/members
Returns the clients currently in a group, for the group's detail view.
Authorization
Requires permission code platform.client-groups.read, platform-scoped.
Request Headers
None beyond the shared bearer authentication.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation / Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
id | Path | UUID | Yes | - | The group whose members to list. | Must reference an active, existing group. | clientGroup.id |
Request Logic
- Reads active
platform.client_group_membershiprows joined toplatform.clients.
sql
SELECT c.id, c.name, c.ico, m.id AS membership_id, m.created_at AS joined_at
FROM platform.client_group_membership m
JOIN platform.clients c ON c.id = m.client_id
WHERE m.client_group_id = @id AND m.deleted_at IS NULL
ORDER BY c.name ASC;Transactional Operations
N/A — read-only.
✅ Success Response (200)
json
{
"data": [
{
"clientId": "018ed0b3-c298-7c7a-96d5-8b36f5a7f8d2",
"clientName": "Město Plzeň",
"clientIco": "00075370",
"joinedAt": "2026-09-16T00:00:00Z"
}
],
"status": 200,
"message": "OK",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
clientId | client.id |
clientName | client.name |
clientIco | client.ico |
joinedAt | clientGroupMembership.createdAt |
❌ Error Responses
404 ERR_CLIENT_GROUP_NOT_FOUND
Thrown when id does not reference an active group.
json
{
"errorCode": "ERR_CLIENT_GROUP_NOT_FOUND",
"errorMessage": "Client group not found.",
"status": 404,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}📤 POST /v1/admin/client-groups/:id/members
Adds a client to a group.
Authorization
Requires permission code platform.client-groups.write, platform-scoped.
Request Headers
None beyond the shared bearer authentication.
Request Body
json
{
"clientId": "018ed0b3-c298-7c7a-96d5-8b36f5a7f8d2"
}Request Parameters
| Name | In | Type | Required | Default | Description | Validation / Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
id | Path | UUID | Yes | - | The group to add the client to. | Must reference an active, existing group. | clientGroup.id |
Request Logic
- Reads the group's
maxClientCountand current active member count, checks the client isn't already an active member, then inserts.
sql
SELECT max_client_count FROM platform.client_group WHERE id = @id AND deleted_at IS NULL;
SELECT COUNT(*) FROM platform.client_group_membership WHERE client_group_id = @id AND deleted_at IS NULL;
SELECT 1 FROM platform.client_group_membership
WHERE client_group_id = @id AND client_id = @clientId AND deleted_at IS NULL;
INSERT INTO platform.client_group_membership (client_id, client_group_id, created_by, updated_by)
VALUES (@clientId, @id, @actor, @actor)
RETURNING id;Transactional Operations
The capacity check, the duplicate check and the insert happen inside one transaction, so two concurrent adds cannot both slip past a group's last free slot.
✅ Success Response (201)
json
{
"data": { "id": "018fa51f-fda1-79f4-8461-2cb8f1cabc21" },
"status": 201,
"message": "Created",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
id | clientGroupMembership.id, generated in code |
❌ Error Responses
404 ERR_CLIENT_GROUP_NOT_FOUND
Thrown when id does not reference an active group.
json
{
"errorCode": "ERR_CLIENT_GROUP_NOT_FOUND",
"errorMessage": "Client group not found.",
"status": 404,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}404 ERR_CLIENT_NOT_FOUND
Thrown when clientId does not reference an active client.
json
{
"errorCode": "ERR_CLIENT_NOT_FOUND",
"errorMessage": "Client not found.",
"status": 404,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}409 ERR_CLIENT_ALREADY_IN_GROUP
Thrown when the client is already an active member of this group.
json
{
"errorCode": "ERR_CLIENT_ALREADY_IN_GROUP",
"errorMessage": "This client is already in the group.",
"status": 409,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}409 ERR_CLIENT_GROUP_FULL
Thrown when the group's active member count already equals its maxClientCount.
json
{
"errorCode": "ERR_CLIENT_GROUP_FULL",
"errorMessage": "This group already holds its maximum number of clients.",
"status": 409,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}🗑️ DELETE /v1/admin/client-groups/:id/members/:clientId
Removes a client from a group.
Authorization
Requires permission code platform.client-groups.write, platform-scoped.
Request Headers
None beyond the shared bearer authentication.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation / Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
id | Path | UUID | Yes | - | The group to remove the client from. | Must reference an active, existing group. | clientGroup.id |
clientId | Path | UUID | Yes | - | The client to remove. | Must reference an active membership in this group. | client.id |
Request Logic
- Soft-deletes the active membership row for this (group, client) pair.
sql
UPDATE platform.client_group_membership
SET deleted_at = now(), updated_at = now(), updated_by = @actor
WHERE client_group_id = @id AND client_id = @clientId AND deleted_at IS NULL
RETURNING id;Transactional Operations
Single-row update; no related write.
✅ Success Response (200)
json
{
"data": { "id": "018fa51f-fda1-79f4-8461-2cb8f1cabc21" },
"status": 200,
"message": "OK",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
id | clientGroupMembership.id |
❌ Error Responses
404 ERR_CLIENT_GROUP_MEMBERSHIP_NOT_FOUND
Thrown when no active membership matches the (group, client) pair.
json
{
"errorCode": "ERR_CLIENT_GROUP_MEMBERSHIP_NOT_FOUND",
"errorMessage": "This client is not a member of this group.",
"status": 404,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b31"
}