openapi: 3.1.0 info: # Do not change the title, if the title changes, the import paths will be broken title: Api version: 0.1.0 description: Tx OS API specification servers: - url: /api description: Base API path tags: - name: health description: Health operations - name: auth description: Authentication - name: apps description: App management - name: services description: Internal services (khidmati) - name: orders description: Service orders workflow - name: notifications description: Notifications - name: users description: User management - name: roles description: Role assignment - name: stats description: Dashboard stats - name: storage description: Object storage upload and serving endpoints - name: audit description: Admin audit log - name: system description: System metadata (version, update availability) paths: /healthz: get: operationId: healthCheck tags: [health] summary: Health check responses: "200": description: Healthy content: application/json: schema: $ref: "#/components/schemas/HealthStatus" /system/version: get: operationId: getSystemVersion tags: [system] summary: Get current and latest system version description: | Returns the local build version (read from version.json embedded at build time) and, if `LATEST_VERSION_URL` is configured on the server, the latest published version fetched from that URL. Admin only. Never performs an update — pure read. responses: "200": description: System version info content: application/json: schema: $ref: "#/components/schemas/SystemVersionResponse" # Auth /auth/register: post: operationId: register tags: [auth] summary: Register a new user requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RegisterBody" responses: "201": description: Registered content: application/json: schema: $ref: "#/components/schemas/AuthUser" "400": description: Validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /auth/login: post: operationId: login tags: [auth] summary: Login requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LoginBody" responses: "200": description: Logged in content: application/json: schema: $ref: "#/components/schemas/AuthUser" "401": description: Unauthorized content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /auth/logout: post: operationId: logout tags: [auth] summary: Logout responses: "200": description: Logged out content: application/json: schema: $ref: "#/components/schemas/SuccessResponse" /auth/forgot-password: post: operationId: forgotPassword tags: [auth] summary: Request a password reset link requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ForgotPasswordBody" responses: "200": description: Reset request accepted content: application/json: schema: $ref: "#/components/schemas/ForgotPasswordResponse" "400": description: Validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /auth/reset-password: post: operationId: resetPassword tags: [auth] summary: Set a new password using a reset token requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ResetPasswordBody" responses: "200": description: Password updated content: application/json: schema: $ref: "#/components/schemas/SuccessResponse" "400": description: Invalid or expired token content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /auth/reset-password/verify: post: operationId: verifyResetToken tags: [auth] summary: Check whether a reset token is valid requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/VerifyResetTokenBody" responses: "200": description: Token check result content: application/json: schema: $ref: "#/components/schemas/VerifyResetTokenResponse" /auth/admin/users/{id}/issue-reset-link: post: operationId: adminIssueResetLink tags: [auth] summary: Admin generates a one-time password reset link for a user parameters: - name: id in: path required: true schema: type: integer responses: "200": description: Reset link issued content: application/json: schema: $ref: "#/components/schemas/AdminResetLinkResponse" "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: User not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /auth/me: get: operationId: getMe tags: [auth] summary: Get current user responses: "200": description: Current user content: application/json: schema: $ref: "#/components/schemas/AuthUser" "401": description: Unauthorized content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /auth/me/language: patch: operationId: updateLanguage tags: [auth] summary: Update preferred language requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateLanguageBody" responses: "200": description: Updated content: application/json: schema: $ref: "#/components/schemas/AuthUser" /auth/me/clock-style: patch: operationId: updateClockStyle tags: [auth] summary: Update preferred home-screen clock style requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateClockStyleBody" responses: "200": description: Updated content: application/json: schema: $ref: "#/components/schemas/AuthUser" "400": description: Validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /auth/me/notification-preferences: patch: operationId: updateNotificationPreferences tags: [auth] summary: Update notification sound, vibration & mute preferences requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateNotificationPreferencesBody" responses: "200": description: Updated content: application/json: schema: $ref: "#/components/schemas/AuthUser" "400": description: Validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /auth/me/clock-hour12: patch: operationId: updateClockHour12 tags: [auth] summary: Update preferred 12/24-hour clock format requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateClockHour12Body" responses: "200": description: Updated content: application/json: schema: $ref: "#/components/schemas/AuthUser" "400": description: Validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" # Apps /apps: get: operationId: listApps tags: [apps] summary: List all active apps responses: "200": description: Apps list content: application/json: schema: type: array items: $ref: "#/components/schemas/App" post: operationId: createApp tags: [apps] summary: Create a new app (admin) requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateAppBody" responses: "201": description: Created content: application/json: schema: $ref: "#/components/schemas/App" /apps/{id}: get: operationId: getApp tags: [apps] summary: Get app by ID parameters: - name: id in: path required: true schema: type: integer responses: "200": description: App content: application/json: schema: $ref: "#/components/schemas/App" patch: operationId: updateApp tags: [apps] summary: Update app (admin) parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateAppBody" responses: "200": description: Updated content: application/json: schema: $ref: "#/components/schemas/App" delete: operationId: deleteApp tags: [apps] summary: Delete app (admin) parameters: - name: id in: path required: true schema: type: integer - name: force in: query required: false description: Force deletion of an app that has dependent records (records an audit log entry). schema: type: boolean default: false responses: "204": description: Deleted "409": description: App has dependent records; pass force=true to confirm deletion. content: application/json: schema: $ref: "#/components/schemas/AppDeletionConflict" /apps/{id}/permissions: get: operationId: listAppPermissions tags: [apps] summary: List the permissions that gate this app (admin) description: | Returns the permissions currently required for users to see this app in their launcher. An app with no rows here is unrestricted (visible to anyone). When more than one permission is configured, holding any one of them is sufficient. parameters: - name: id in: path required: true schema: type: integer responses: "200": description: Permissions gating the app content: application/json: schema: type: array items: $ref: "#/components/schemas/Permission" "404": description: App not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" post: operationId: addAppPermission tags: [apps] summary: Add a required permission to this app (admin) description: | Adds a row to `app_permissions` for the (app_id, permission_id) pair. Uses `ON CONFLICT DO NOTHING` against the composite primary key so re-adding an existing pair is a no-op. parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AddAppPermissionBody" responses: "201": description: Permission requirement added (or already present) content: application/json: schema: $ref: "#/components/schemas/AppPermissionLink" "404": description: App or permission not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /apps/{id}/permissions/impact-preview: post: operationId: previewAppPermissionsImpact tags: [apps] summary: Preview the impact of changing the permissions required for an app (admin) description: | Given a candidate set of permission IDs that would gate this app, returns how many users would lose visibility of the app (excluding admins, who always see every app), the count of users who currently see the app, and the groups that grant the app via `group_apps`. A member of any returned group always sees the app regardless of permission changes, so listing them lets the admin see which populations are protected from the loss. Mirrors `POST /roles/{id}/permissions/impact-preview` so the admin UI can show the same kind of warning before committing the change. parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AppPermissionsImpactBody" responses: "200": description: Impact preview for the proposed permission set content: application/json: schema: $ref: "#/components/schemas/AppPermissionsImpact" "400": description: Invalid request body content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: App not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /apps/{id}/permissions/{permissionId}: delete: operationId: removeAppPermission tags: [apps] summary: Remove a required permission from this app (admin) parameters: - name: id in: path required: true schema: type: integer - name: permissionId in: path required: true schema: type: integer responses: "204": description: Removed (or no row existed for the pair) "404": description: App not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /apps/{id}/open: post: operationId: logAppOpen tags: [apps] summary: Log an app open event for the current user parameters: - name: id in: path required: true schema: type: integer responses: "204": description: Logged "404": description: App not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /me/app-order: put: operationId: updateMyAppOrder tags: [apps] summary: Set the current user's preferred home apps order requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMyAppOrderBody" responses: "200": description: Updated apps in new order content: application/json: schema: type: array items: $ref: "#/components/schemas/App" "400": description: Validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" # Settings /settings: get: operationId: getAppSettings tags: [settings] summary: Get application settings (public) responses: "200": description: Current settings content: application/json: schema: $ref: "#/components/schemas/AppSettings" patch: operationId: updateAppSettings tags: [settings] summary: Update application settings (admin) requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateAppSettingsBody" responses: "200": description: Updated settings content: application/json: schema: $ref: "#/components/schemas/AppSettings" "400": description: Invalid input content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" # Storage /storage/uploads/request-url: post: operationId: requestUploadUrl tags: [storage] summary: Request a presigned URL for file upload requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RequestUploadUrlBody" responses: "200": description: Presigned upload URL generated content: application/json: schema: $ref: "#/components/schemas/RequestUploadUrlResponse" "400": description: Invalid input content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" # Services /services: get: operationId: listServices tags: [services] summary: List all services responses: "200": description: Services list content: application/json: schema: type: array items: $ref: "#/components/schemas/Service" post: operationId: createService tags: [services] summary: Create a service (admin) requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateServiceBody" responses: "201": description: Created content: application/json: schema: $ref: "#/components/schemas/Service" /services/{id}: get: operationId: getService tags: [services] summary: Get service by ID parameters: - name: id in: path required: true schema: type: integer responses: "200": description: Service content: application/json: schema: $ref: "#/components/schemas/Service" patch: operationId: updateService tags: [services] summary: Update service (admin) parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateServiceBody" responses: "200": description: Updated content: application/json: schema: $ref: "#/components/schemas/Service" delete: operationId: deleteService tags: [services] summary: Delete service (admin) parameters: - name: id in: path required: true schema: type: integer - name: force in: query required: false description: Force deletion of a service that has existing orders (records an audit log entry). schema: type: boolean default: false responses: "204": description: Deleted "409": description: Service has dependent records; pass force=true to confirm deletion. content: application/json: schema: $ref: "#/components/schemas/ServiceDeletionConflict" /orders: post: operationId: createServiceOrder tags: [orders] summary: Place a service order requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateServiceOrderBody" responses: "201": description: Created content: application/json: schema: $ref: "#/components/schemas/ServiceOrder" "400": description: Validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /orders/my: get: operationId: listMyServiceOrders tags: [orders] summary: List orders placed by the current user responses: "200": description: My orders content: application/json: schema: type: array items: $ref: "#/components/schemas/ServiceOrder" /orders/incoming: get: operationId: listIncomingServiceOrders tags: [orders] summary: List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled. responses: "200": description: Incoming orders content: application/json: schema: type: array items: $ref: "#/components/schemas/ServiceOrder" "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /orders/{id}/status: patch: operationId: updateServiceOrderStatus tags: [orders] summary: Update order status. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time); pending/received are restore-from-cancelled transitions allowed for owner or admin. parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateServiceOrderStatusBody" responses: "200": description: Updated order content: application/json: schema: $ref: "#/components/schemas/ServiceOrder" "400": description: Invalid transition or validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "403": description: Forbidden — caller is not the assignee, owner, or admin per the transition rules content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Order not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /orders/{id}/confirm-receipt: patch: operationId: confirmServiceOrderReceipt tags: [orders] summary: Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned. parameters: - name: id in: path required: true schema: type: integer responses: "200": description: Order received content: application/json: schema: $ref: "#/components/schemas/ServiceOrder" "409": description: Invalid transition content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /service-categories: get: operationId: listServiceCategories tags: [services] summary: List service categories responses: "200": description: Categories content: application/json: schema: type: array items: $ref: "#/components/schemas/ServiceCategory" # Notifications /notifications: get: operationId: listNotifications tags: [notifications] summary: List notifications for current user responses: "200": description: Notifications content: application/json: schema: type: array items: $ref: "#/components/schemas/Notification" /notifications/{id}/read: patch: operationId: markNotificationRead tags: [notifications] summary: Mark notification as read parameters: - name: id in: path required: true schema: type: integer responses: "200": description: Marked as read content: application/json: schema: $ref: "#/components/schemas/Notification" /notifications/read-all: post: operationId: markAllNotificationsRead tags: [notifications] summary: Mark all notifications as read responses: "200": description: Done content: application/json: schema: $ref: "#/components/schemas/SuccessResponse" /push/vapid-public-key: get: operationId: getPushVapidPublicKey tags: [notifications] summary: Get the server's VAPID public key for Web Push subscriptions responses: "200": description: VAPID public key content: application/json: schema: type: object properties: publicKey: type: string required: [publicKey] "503": description: Web Push not configured on this server content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /push/subscribe: post: operationId: subscribePush tags: [notifications] summary: Register a Web Push subscription for the current user requestBody: required: true content: application/json: schema: type: object properties: endpoint: type: string keys: type: object properties: p256dh: type: string auth: type: string required: [p256dh, auth] required: [endpoint, keys] responses: "200": description: Subscribed content: application/json: schema: $ref: "#/components/schemas/SuccessResponse" delete: operationId: deletePushSubscription tags: [notifications] summary: Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe) parameters: - in: query name: endpoint required: true schema: type: string description: The push endpoint URL to unsubscribe responses: "200": description: Unsubscribed content: application/json: schema: $ref: "#/components/schemas/SuccessResponse" /push/unsubscribe: post: operationId: unsubscribePush tags: [notifications] summary: Remove a Web Push subscription for the current user requestBody: required: true content: application/json: schema: type: object properties: endpoint: type: string required: [endpoint] responses: "200": description: Unsubscribed content: application/json: schema: $ref: "#/components/schemas/SuccessResponse" # Users (admin) /users: get: operationId: listUsers tags: [users] summary: List all users (admin) parameters: - in: query name: q required: false schema: type: string description: Free-text search across username, email, and display names - in: query name: groupId required: false schema: type: integer description: Filter to users that belong to the given group - in: query name: status required: false schema: type: string enum: ["active", "disabled"] description: Filter by active/disabled state responses: "200": description: Users content: application/json: schema: type: array items: $ref: "#/components/schemas/UserProfile" post: operationId: createUser tags: [users] summary: Create a user (admin) requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateUserBody" responses: "201": description: Created user content: application/json: schema: $ref: "#/components/schemas/UserProfile" "409": description: Username already taken /users/{id}: get: operationId: getUser tags: [users] summary: Get user by ID (admin) parameters: - name: id in: path required: true schema: type: integer responses: "200": description: User content: application/json: schema: $ref: "#/components/schemas/UserProfile" patch: operationId: updateUser tags: [users] summary: Update user (admin) parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateUserBody" responses: "200": description: Updated content: application/json: schema: $ref: "#/components/schemas/UserProfile" delete: operationId: deleteUser tags: [users] summary: Delete user (admin) parameters: - name: id in: path required: true schema: type: integer - name: force in: query required: false description: Force deletion of a user that owns dependent records (records an audit log entry). schema: type: boolean default: false responses: "204": description: Deleted "409": description: User has dependent records; pass force=true to confirm deletion. content: application/json: schema: $ref: "#/components/schemas/UserDeletionConflict" /orders/bulk-delete: post: operationId: bulkDeleteServiceOrders tags: [orders] summary: Delete multiple service orders in one request description: | Deletes several orders in one round-trip. Authorization is still enforced per id using the same rules as DELETE /orders/{id}, so the client cannot bypass the permission check by batching. Returns a summary of which ids were deleted and which were rejected. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BulkDeleteServiceOrdersBody" responses: "200": description: Bulk delete summary content: application/json: schema: $ref: "#/components/schemas/BulkDeleteServiceOrdersResponse" "400": description: Validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /orders/{id}: delete: operationId: deleteServiceOrder tags: [orders] summary: Delete a service order description: | Permanently deletes the order. Allowed when: - the caller is an admin (any order, any status); or - the caller owns the order AND it is in a terminal status (completed/cancelled); or - the caller holds the `orders.receive` permission AND the order is currently visible in their own incoming view — i.e. an unclaimed pending order, or one they have themselves claimed and is still active (received/preparing). Receivers cannot delete another receiver's claimed order, nor terminal orders. parameters: - name: id in: path required: true schema: type: integer responses: "204": description: Deleted "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Order not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /users/{id}/roles: post: operationId: addUserRole tags: [roles] summary: Idempotently add a role to a user (admin) parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AddUserRoleBody" responses: "200": description: Role added (or already present) content: application/json: schema: $ref: "#/components/schemas/UserProfile" "400": description: Validation error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: User or role not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /users/{id}/roles/{roleName}: delete: operationId: removeUserRole tags: [roles] summary: Idempotently remove a role from a user (admin) parameters: - name: id in: path required: true schema: type: integer - name: roleName in: path required: true schema: type: string responses: "200": description: Role removed (or already absent) content: application/json: schema: $ref: "#/components/schemas/UserProfile" "404": description: User not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" # Permissions (admin) /permissions: get: operationId: listPermissions tags: [roles] summary: List all available permissions (admin) responses: "200": description: Permissions content: application/json: schema: type: array items: $ref: "#/components/schemas/Permission" "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" # Roles (admin) /roles: get: operationId: listRoles tags: [users] summary: List roles (admin) responses: "200": description: Roles content: application/json: schema: type: array items: $ref: "#/components/schemas/Role" post: operationId: createRole tags: [users] summary: Create role (admin) requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateRoleBody" responses: "201": description: Created content: application/json: schema: $ref: "#/components/schemas/Role" "400": description: Invalid request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Name already taken content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /roles/{id}: patch: operationId: updateRole tags: [users] summary: Update role name and descriptions (admin) parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateRoleBody" responses: "200": description: Updated content: application/json: schema: $ref: "#/components/schemas/Role" "400": description: Invalid request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Name already taken content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" delete: operationId: deleteRole tags: [users] summary: Delete role (admin) parameters: - name: id in: path required: true schema: type: integer responses: "204": description: Deleted "400": description: Cannot delete a system role content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Role is in use content: application/json: schema: $ref: "#/components/schemas/RoleDeletionConflict" /roles/{id}/usage: get: operationId: getRoleUsage tags: [roles] summary: Count users and groups currently assigned to a role (admin) parameters: - name: id in: path required: true schema: type: integer responses: "200": description: Counts of users and groups using the role content: application/json: schema: $ref: "#/components/schemas/RoleUsage" "404": description: Role not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /roles/{id}/audit: get: operationId: getRolePermissionAudit tags: [roles] summary: List recent permission-change audit entries for a role (admin) description: | Returns permission-change audit records for the given role, newest first. Supports offset-based pagination via `limit`/`offset` and optional filters by acting user (`actorUserId`) and a date range (`from`/`to`, inclusive UTC days). The response includes the total matching count and the next offset to load (or `null` when the end has been reached). Each entry captures the actor (if known), the previous permission IDs, the new permission IDs, and the timestamp. parameters: - name: id in: path required: true schema: type: integer - in: query name: limit required: false schema: type: integer minimum: 1 maximum: 200 default: 10 - in: query name: offset required: false schema: type: integer minimum: 0 default: 0 - in: query name: actorUserId required: false schema: type: integer minimum: 0 description: | Restrict results to entries authored by the given user. Use `0` or omit to return entries from any actor. - in: query name: from required: false schema: type: string format: date description: Start date (inclusive, YYYY-MM-DD UTC). - in: query name: to required: false schema: type: string format: date description: End date (inclusive, YYYY-MM-DD UTC). responses: "200": description: A page of permission-change audit entries for the role content: application/json: schema: $ref: "#/components/schemas/RolePermissionAuditList" "400": description: Invalid filter parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Role not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /roles/{id}/audit.csv: get: operationId: exportRolePermissionAuditCsv tags: [roles] summary: Download permission-change audit entries for a role as CSV (admin) description: | Returns the same audit rows as `GET /roles/{id}/audit` but as a downloadable CSV file with permission IDs resolved to names. Honors the optional `actorUserId`, `from`, and `to` filters identically. Pagination parameters are NOT accepted: the file contains all matching rows up to a server-side cap (currently 10,000 — narrow the date range for a longer history). parameters: - name: id in: path required: true schema: type: integer - in: query name: actorUserId required: false schema: type: integer minimum: 0 description: | Restrict results to entries authored by the given user. Use `0` or omit to return entries from any actor. - in: query name: from required: false schema: type: string format: date description: Start date (inclusive, YYYY-MM-DD UTC). - in: query name: to required: false schema: type: string format: date description: End date (inclusive, YYYY-MM-DD UTC). responses: "200": description: | UTF-8 CSV (with BOM) containing columns: timestamp, actor_username, added_permissions, removed_permissions. content: text/csv: schema: type: string format: binary "400": description: Invalid filter parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Role not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /roles/{id}/permissions/impact-preview: post: operationId: previewRolePermissionsImpact tags: [roles] summary: Preview the impact of changing a role's permission set (admin) description: | Given a candidate set of permission IDs for the role, returns the list of permissions that would be removed and, for each one, how many users would lose the permission (because they hold this role directly or via a group and have no other role granting the permission), plus the groups that currently grant this role. parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RolePermissionsImpactBody" responses: "200": description: Impact preview for the proposed permission set content: application/json: schema: $ref: "#/components/schemas/RolePermissionsImpact" "400": description: Invalid request body content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Role was not found. Unknown permission IDs in the request body are tolerated and simply do not appear in `removed`. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /roles/{id}/permissions: get: operationId: getRolePermissions tags: [roles] summary: List permissions assigned to a role (admin) parameters: - name: id in: path required: true schema: type: integer responses: "200": description: Permissions assigned to the role content: application/json: schema: type: array items: $ref: "#/components/schemas/Permission" "404": description: Role not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" put: operationId: replaceRolePermissions tags: [roles] summary: Atomically replace the permissions assigned to a role (admin) parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReplaceRolePermissionsBody" responses: "200": description: Updated permissions list for the role content: application/json: schema: type: array items: $ref: "#/components/schemas/Permission" "400": description: Cannot modify a system role's permissions, or invalid request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Role or one of the permissions was not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" # Groups (admin) /groups: get: operationId: listGroups tags: [users] summary: List groups (admin) parameters: - in: query name: q required: false schema: type: string responses: "200": description: Groups content: application/json: schema: type: array items: $ref: "#/components/schemas/Group" post: operationId: createGroup tags: [users] summary: Create group (admin) requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateGroupBody" responses: "201": description: Created content: application/json: schema: $ref: "#/components/schemas/Group" /groups/{id}: get: operationId: getGroup tags: [users] summary: Get group with members and apps (admin) parameters: - name: id in: path required: true schema: type: integer responses: "200": description: Group detail content: application/json: schema: $ref: "#/components/schemas/GroupDetail" "404": description: Not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" patch: operationId: updateGroup tags: [users] summary: Update group (admin) parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateGroupBody" responses: "200": description: Updated content: application/json: schema: $ref: "#/components/schemas/GroupDetail" delete: operationId: deleteGroup tags: [users] summary: Delete group (admin) parameters: - name: id in: path required: true schema: type: integer - name: force in: query required: false description: Force deletion of a non-empty group (records an audit log entry). schema: type: boolean default: false responses: "204": description: Deleted "400": description: Cannot delete a system group content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Group is not empty; pass force=true to confirm deletion. content: application/json: schema: $ref: "#/components/schemas/GroupDeletionConflict" /users/{id}/audit: get: operationId: getUserPermissionAudit tags: [users] summary: List recent permission-change audit entries for a user (admin) description: | Returns permission-change audit records for the given user, newest first. Mirrors the role audit endpoint shape and supports the same `limit`/`offset`/`actorUserId`/`from`/`to` filters. Each entry captures the actor (if known), the previous and new id sets, and the change kind (`user.roles` or `user.groups`). parameters: - name: id in: path required: true schema: type: integer - in: query name: limit required: false schema: type: integer minimum: 1 maximum: 200 default: 10 - in: query name: offset required: false schema: type: integer minimum: 0 default: 0 - in: query name: actorUserId required: false schema: type: integer minimum: 0 - in: query name: from required: false schema: type: string format: date - in: query name: to required: false schema: type: string format: date responses: "200": description: A page of permission-change audit entries for the user content: application/json: schema: $ref: "#/components/schemas/PermissionAuditList" "400": description: Invalid filter parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: User not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /groups/{id}/audit: get: operationId: getGroupPermissionAudit tags: [users] summary: List recent permission-change audit entries for a group (admin) description: | Returns permission-change audit records for the given group, newest first. Captures changes to the group's user, role and app sets via the discriminator field `changeKind`. parameters: - name: id in: path required: true schema: type: integer - in: query name: limit required: false schema: type: integer minimum: 1 maximum: 200 default: 10 - in: query name: offset required: false schema: type: integer minimum: 0 default: 0 - in: query name: actorUserId required: false schema: type: integer minimum: 0 - in: query name: from required: false schema: type: string format: date - in: query name: to required: false schema: type: string format: date responses: "200": description: A page of permission-change audit entries for the group content: application/json: schema: $ref: "#/components/schemas/PermissionAuditList" "400": description: Invalid filter parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Group not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /apps/{id}/audit: get: operationId: getAppPermissionAudit tags: [apps] summary: List recent permission-change audit entries for an app (admin) description: | Returns permission-change audit records for the given app, newest first. Captures changes to the set of permissions required to open the app (`changeKind: app.permissions`). parameters: - name: id in: path required: true schema: type: integer - in: query name: limit required: false schema: type: integer minimum: 1 maximum: 200 default: 10 - in: query name: offset required: false schema: type: integer minimum: 0 default: 0 - in: query name: actorUserId required: false schema: type: integer minimum: 0 - in: query name: from required: false schema: type: string format: date - in: query name: to required: false schema: type: string format: date responses: "200": description: A page of permission-change audit entries for the app content: application/json: schema: $ref: "#/components/schemas/PermissionAuditList" "400": description: Invalid filter parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: App not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" # Stats /stats/home: get: operationId: getHomeStats tags: [stats] summary: Get home screen stats responses: "200": description: Stats content: application/json: schema: $ref: "#/components/schemas/HomeStats" /stats/admin: get: operationId: getAdminStats tags: [stats] summary: Get admin dashboard trend stats parameters: - in: query name: range required: false schema: type: string enum: ["7d", "30d", "90d", "custom"] default: "7d" description: Time range for trend stats. Use "custom" with from/to. - in: query name: from required: false schema: type: string format: date description: Start date (inclusive, YYYY-MM-DD UTC) when range=custom - in: query name: to required: false schema: type: string format: date description: End date (inclusive, YYYY-MM-DD UTC) when range=custom responses: "200": description: Admin stats content: application/json: schema: $ref: "#/components/schemas/AdminStats" "400": description: Invalid range parameters (e.g. range=custom with missing/malformed from/to or inverted dates) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /stats/admin/app-opens/by-app/{appId}: get: operationId: getAdminAppOpensByApp tags: [stats] summary: Recent opens of a single app within the selected range parameters: - in: path name: appId required: true schema: type: integer - in: query name: range required: false schema: type: string enum: ["7d", "30d", "90d", "custom"] default: "7d" - in: query name: from required: false schema: type: string format: date - in: query name: to required: false schema: type: string format: date - in: query name: limit required: false schema: type: integer minimum: 1 maximum: 200 default: 100 - in: query name: offset required: false schema: type: integer minimum: 0 default: 0 responses: "200": description: Recent opens for the app content: application/json: schema: $ref: "#/components/schemas/AdminAppOpensByApp" "400": description: Invalid range parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: App not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /stats/admin/app-opens/by-user/{userId}: get: operationId: getAdminAppOpensByUser tags: [stats] summary: Recent app opens by a single user within the selected range parameters: - in: path name: userId required: true schema: type: integer - in: query name: range required: false schema: type: string enum: ["7d", "30d", "90d", "custom"] default: "7d" - in: query name: from required: false schema: type: string format: date - in: query name: to required: false schema: type: string format: date - in: query name: limit required: false schema: type: integer minimum: 1 maximum: 200 default: 100 - in: query name: offset required: false schema: type: integer minimum: 0 default: 0 responses: "200": description: Recent app opens by the user content: application/json: schema: $ref: "#/components/schemas/AdminAppOpensByUser" "400": description: Invalid range parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: User not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/audit-logs: get: operationId: listAuditLogs tags: [audit] summary: List audit log entries (admin) description: | Returns recent entries from the audit log, newest first. Admin only. Filter by action (exact match) and/or a date range (inclusive). parameters: - in: query name: action required: false schema: type: string description: Exact action name to filter by (e.g. "group.force_delete"). - in: query name: forcedOnly required: false schema: type: boolean default: false description: | When true, restrict results to forced deletions only — i.e. rows with action `group.force_delete`, `user.force_delete`, `app.force_delete`, `service.force_delete`, or any `group.delete`/`user.delete`/`app.delete` row whose metadata contains `force: true`. Overrides the `action` filter when set. - in: query name: from required: false schema: type: string format: date description: Start date (inclusive, YYYY-MM-DD UTC). - in: query name: to required: false schema: type: string format: date description: End date (inclusive, YYYY-MM-DD UTC). - in: query name: targetType required: false schema: type: string description: | Restrict results to entries whose `target_type` exactly matches (e.g. "group", "app", "role", "user", "service", "settings"). Combines with `targetId` and the other filters. - in: query name: targetId required: false schema: type: integer minimum: 1 description: | Restrict results to entries whose `target_id` exactly matches. Typically used together with `targetType` to focus on the history of a single entity. - in: query name: actorUserId required: false schema: type: integer minimum: 1 description: | Restrict results to entries written by a specific acting user (matches `actor_user_id`). Combines with the other filters. - in: query name: limit required: false schema: type: integer minimum: 1 maximum: 200 default: 50 - in: query name: offset required: false schema: type: integer minimum: 0 default: 0 responses: "200": description: Page of audit log entries content: application/json: schema: $ref: "#/components/schemas/AuditLogList" "400": description: Invalid filter parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/audit-logs/export: get: operationId: exportAuditLogsCsv tags: [audit] summary: Export filtered audit log as CSV (admin) description: | Streams the filtered audit log as a CSV download. Honors the same `action`, `forcedOnly`, `from`, `to`, `targetType`, `targetId`, and `actorUserId` filters as the list endpoint, so an export always matches whatever the admin is currently viewing. Columns: action, actor_username, target_type, target_id, created_at, metadata. Capped at 50,000 rows per export to bound response size. parameters: - in: query name: action required: false schema: type: string description: Exact action name to filter by (e.g. "group.force_delete"). - in: query name: forcedOnly required: false schema: type: boolean default: false description: | When true, restrict the export to forced deletions only. See the list endpoint for the exact set of matching rows. Overrides the `action` filter when set. - in: query name: from required: false schema: type: string format: date description: Start date (inclusive, YYYY-MM-DD UTC). - in: query name: to required: false schema: type: string format: date description: End date (inclusive, YYYY-MM-DD UTC). - in: query name: targetType required: false schema: type: string description: | Restrict the export to entries whose `target_type` exactly matches. Combines with `targetId` and the other filters. - in: query name: targetId required: false schema: type: integer minimum: 1 description: | Restrict the export to entries whose `target_id` exactly matches. - in: query name: actorUserId required: false schema: type: integer minimum: 1 description: | Restrict the export to entries written by a specific acting user. responses: "200": description: CSV file download content: text/csv: schema: type: string format: binary "400": description: Invalid filter parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/apps/{id}/dependents/groups: get: operationId: getAdminAppDependentGroups tags: [apps] summary: List groups granting access to an app description: | Returns the groups behind the inline " groups" badge on the admin Apps row, paginated. Admin only. parameters: - in: path name: id required: true schema: { type: integer } - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset required: false schema: { type: integer, minimum: 0, default: 0 } responses: "200": description: Paged list of groups granting the app content: application/json: schema: $ref: "#/components/schemas/AppDependentGroupsPage" "404": description: App not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/apps/{id}/dependents/restrictions: get: operationId: getAdminAppDependentRestrictions tags: [apps] summary: List permission restrictions on an app description: | Returns the legacy permissions gating an app, with the role names currently holding each permission, paginated. Admin only. parameters: - in: path name: id required: true schema: { type: integer } - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset required: false schema: { type: integer, minimum: 0, default: 0 } responses: "200": description: Paged list of restrictions on the app content: application/json: schema: $ref: "#/components/schemas/AppDependentRestrictionsPage" "404": description: App not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/apps/{id}/dependents/opens: get: operationId: getAdminAppDependentOpens tags: [apps] summary: List recent opens for an app (no time filter) description: | Returns every recorded open for the app newest-first, paginated. Unlike /stats/admin/app-opens/by-app/{appId}, this endpoint does not apply a date range so the badge total and the list match. parameters: - in: path name: id required: true schema: { type: integer } - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset required: false schema: { type: integer, minimum: 0, default: 0 } responses: "200": description: Paged list of opens content: application/json: schema: $ref: "#/components/schemas/AppDependentOpensPage" "404": description: App not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/services/{id}/dependents/orders: get: operationId: getAdminServiceDependentOrders tags: [services] summary: List orders placed against a service description: | Returns the service orders behind the inline " orders" badge on the admin Services row, paginated, newest-first. Admin only. parameters: - in: path name: id required: true schema: { type: integer } - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset required: false schema: { type: integer, minimum: 0, default: 0 } responses: "200": description: Paged list of orders for the service content: application/json: schema: $ref: "#/components/schemas/ServiceDependentOrdersPage" "404": description: Service not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" # ----- Notes (in-app messaging) ----- /notes/my: get: operationId: listMyNotesAlias tags: [notes] summary: Alias of GET /notes — list the caller's own notes parameters: - in: query name: archived required: false schema: { type: boolean, default: false } responses: "200": description: The caller's notes content: application/json: schema: type: array items: { $ref: "#/components/schemas/MyNote" } /notes/sent: get: operationId: listSentNotes tags: [notes] summary: List notes the caller has sent responses: "200": description: Sent notes with per-recipient status content: application/json: schema: type: array items: { $ref: "#/components/schemas/SentNote" } /notes/received: get: operationId: listReceivedNotes tags: [notes] summary: List notes sent to the caller parameters: - in: query name: archived required: false schema: { type: boolean, default: false } responses: "200": description: Received notes with sender + status content: application/json: schema: type: array items: { $ref: "#/components/schemas/ReceivedNote" } /notes/{id}: get: operationId: getNoteDetail tags: [notes] summary: Get a note's full thread (note + recipients + replies) parameters: - in: path name: id required: true schema: { type: integer } responses: "200": description: Note thread, scoped to caller (sender, recipient, or admin) content: application/json: schema: { $ref: "#/components/schemas/NoteThread" } "403": description: Not a participant content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } "404": description: Note not found content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } /notes/{id}/send: post: operationId: sendNote tags: [notes] summary: Owner sends an existing note to a list of recipients parameters: - in: path name: id required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [recipientUserIds] properties: recipientUserIds: type: array items: { type: integer } responses: "200": description: Sent (idempotent on existing recipients) content: application/json: schema: { $ref: "#/components/schemas/SendNoteResult" } /notes/{id}/read: post: operationId: markNoteRead tags: [notes] summary: Recipient marks their copy of a note read parameters: - in: path name: id required: true schema: { type: integer } responses: "200": description: Marked read content: application/json: schema: { $ref: "#/components/schemas/SuccessResponse" } /notes/{id}/archive: post: operationId: archiveReceivedNote tags: [notes] summary: Recipient archives or unarchives their copy of a note parameters: - in: path name: id required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [archived] properties: archived: { type: boolean } responses: "200": description: Archive state updated content: application/json: schema: { $ref: "#/components/schemas/SuccessResponse" } /notes/{id}/reply: post: operationId: replyToNote tags: [notes] summary: Sender or recipient adds a reply to a note's thread parameters: - in: path name: id required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [content] properties: content: { type: string } recipientUserId: type: integer description: Required when the note owner replies and there is more than one recipient. responses: "201": description: Reply created content: application/json: schema: { $ref: "#/components/schemas/NoteReply" } # ----- Note folders (user-defined) ----- /note-folders: get: operationId: listNoteFolders tags: [notes] summary: List the caller's note folders with per-tab note counts parameters: - in: query name: archived required: false description: Tab scope for noteCount (false = My Notes, true = Archive) schema: { type: boolean, default: false } responses: "200": description: Folders owned by the caller content: application/json: schema: type: array items: { $ref: "#/components/schemas/NoteFolder" } post: operationId: createNoteFolder tags: [notes] summary: Create a new note folder owned by the caller requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string, minLength: 1, maxLength: 80 } responses: "201": description: Folder created content: application/json: schema: { $ref: "#/components/schemas/NoteFolder" } "400": description: Invalid folder name (empty or too long) content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } "409": description: Duplicate folder name for this user content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } /note-folders/{id}: patch: operationId: updateNoteFolder tags: [notes] summary: Rename a note folder parameters: - in: path name: id required: true schema: { type: integer } - in: query name: archived required: false description: Tab scope for the returned noteCount (false = My Notes, true = Archive) schema: { type: boolean, default: false } requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string, minLength: 1, maxLength: 80 } responses: "200": description: Folder updated content: application/json: schema: { $ref: "#/components/schemas/NoteFolder" } "404": description: Folder not found or not owned by caller content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } "409": description: Duplicate folder name for this user content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } delete: operationId: deleteNoteFolder tags: [notes] summary: Delete a note folder. Notes inside are preserved (folderId set to null). parameters: - in: path name: id required: true schema: { type: integer } responses: "200": description: Folder deleted content: application/json: schema: { $ref: "#/components/schemas/SuccessResponse" } /admin/users/{id}/dependents/notes: get: operationId: getAdminUserDependentNotes tags: [users] summary: List notes owned by a user parameters: - in: path name: id required: true schema: { type: integer } - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset required: false schema: { type: integer, minimum: 0, default: 0 } responses: "200": description: Paged list of notes content: application/json: schema: $ref: "#/components/schemas/UserDependentNotesPage" "404": description: User not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/users/{id}/dependents/orders: get: operationId: getAdminUserDependentOrders tags: [users] summary: List service orders placed by a user parameters: - in: path name: id required: true schema: { type: integer } - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: offset required: false schema: { type: integer, minimum: 0, default: 0 } responses: "200": description: Paged list of orders content: application/json: schema: $ref: "#/components/schemas/UserDependentOrdersPage" "404": description: User not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" components: schemas: HealthStatus: type: object properties: status: type: string required: - status SystemVersionInfo: type: object properties: version: type: string build: type: string required: - version - build SystemVersionResponse: type: object properties: current: $ref: "#/components/schemas/SystemVersionInfo" latest: oneOf: - $ref: "#/components/schemas/SystemVersionInfo" - type: "null" status: type: string enum: [up-to-date, update-available, not-configured, error] errorMessage: type: ["string", "null"] checkedAt: type: ["string", "null"] format: date-time configured: type: boolean startedAt: type: string format: date-time description: | ISO-8601 timestamp captured when the API process booted. Frozen for the lifetime of the process — used by the admin System Updates panel as visible proof that the container actually restarted (independent of build/version changes). required: - current - latest - status - errorMessage - checkedAt - configured - startedAt ErrorResponse: type: object properties: error: type: string required: - error SuccessResponse: type: object properties: success: type: boolean required: - success RegisterBody: type: object properties: username: type: string email: type: string password: type: string displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] preferredLanguage: type: string default: "ar" required: - username - email - password CreateUserBody: type: object properties: username: type: string email: type: string password: type: string displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] preferredLanguage: type: string default: "ar" groupIds: type: array description: Initial group memberships in addition to the auto-assigned Everyone group. items: type: integer required: - username - email - password LoginBody: type: object properties: username: type: string password: type: string required: - username - password ForgotPasswordBody: type: object properties: identifier: type: string description: Username or email required: - identifier ForgotPasswordResponse: type: object properties: success: type: boolean required: - success ResetPasswordBody: type: object properties: token: type: string newPassword: type: string minLength: 6 required: - token - newPassword VerifyResetTokenBody: type: object properties: token: type: string required: - token AdminResetLinkResponse: type: object properties: resetUrl: type: string expiresAt: type: string format: date-time required: - resetUrl - expiresAt VerifyResetTokenResponse: type: object properties: valid: type: boolean required: - valid UpdateLanguageBody: type: object properties: language: type: string required: - language ClockStyle: type: string enum: [full, digital, digital-no-seconds, analog, minimal] description: Per-user home-screen clock style preference UpdateClockStyleBody: type: object properties: clockStyle: oneOf: - $ref: "#/components/schemas/ClockStyle" - type: "null" description: Clock style; pass null to clear and use the default required: - clockStyle NotificationSoundId: type: string enum: [ding, chime, bell, knock, pop, alert, beep, soft] UpdateNotificationPreferencesBody: type: object properties: notificationSoundOrder: $ref: "#/components/schemas/NotificationSoundId" notificationSoundMeeting: $ref: "#/components/schemas/NotificationSoundId" notificationSoundNote: $ref: "#/components/schemas/NotificationSoundId" notifyOrdersEnabled: type: boolean notifyMeetingsEnabled: type: boolean notifyNotesEnabled: type: boolean vibrationEnabledOrder: type: boolean vibrationEnabledMeeting: type: boolean vibrationEnabledNote: type: boolean notificationsMuted: type: boolean UpdateClockHour12Body: type: object properties: clockHour12: type: ["boolean", "null"] description: Whether to use 12-hour (AM/PM) clock format; null clears and uses the default (24-hour) required: - clockHour12 AuthUser: type: object properties: id: type: integer username: type: string email: type: string displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] preferredLanguage: type: string clockStyle: oneOf: - $ref: "#/components/schemas/ClockStyle" - type: "null" clockHour12: type: ["boolean", "null"] avatarUrl: type: ["string", "null"] isActive: type: boolean notificationSoundOrder: $ref: "#/components/schemas/NotificationSoundId" notificationSoundMeeting: $ref: "#/components/schemas/NotificationSoundId" notificationSoundNote: $ref: "#/components/schemas/NotificationSoundId" notifyOrdersEnabled: type: boolean notifyMeetingsEnabled: type: boolean notifyNotesEnabled: type: boolean vibrationEnabledOrder: type: boolean vibrationEnabledMeeting: type: boolean vibrationEnabledNote: type: boolean notificationsMuted: type: boolean roles: type: array items: type: string groups: type: array items: $ref: "#/components/schemas/GroupSummary" createdAt: type: string format: date-time required: - id - username - email - preferredLanguage - isActive - notificationSoundOrder - notificationSoundMeeting - notificationSoundNote - notifyOrdersEnabled - notifyMeetingsEnabled - notifyNotesEnabled - vibrationEnabledOrder - vibrationEnabledMeeting - vibrationEnabledNote - notificationsMuted - roles - groups - createdAt UserProfile: type: object properties: id: type: integer username: type: string email: type: string displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] preferredLanguage: type: string clockStyle: oneOf: - $ref: "#/components/schemas/ClockStyle" - type: "null" clockHour12: type: ["boolean", "null"] avatarUrl: type: ["string", "null"] isActive: type: boolean roles: type: array items: type: string directRoles: type: array items: type: string description: | Roles assigned directly to the user (user_roles table only). The `roles` field above is the effective set (direct + inherited via group membership). The admin Users page uses the difference to mark a role as "inherited from group X" and disables the toggle so the admin doesn't try to remove a role that won't disappear. groups: type: array items: $ref: "#/components/schemas/GroupSummary" createdAt: type: string format: date-time noteCount: type: integer description: | Dependent record counts. Populated only by the admin list endpoint (GET /users) so the delete dialog can warn before the first click. Omitted from single-user responses (GET /users/:id, PATCH, etc). orderCount: type: integer description: | Number of service orders owned by this user. Populated only by the admin list endpoint (GET /users). required: - id - username - email - preferredLanguage - isActive - roles - groups - createdAt UpdateUserBody: type: object properties: displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] isActive: type: boolean preferredLanguage: type: string groupIds: type: array items: type: integer description: If provided, replaces the user's group memberships AddUserRoleBody: type: object properties: roleName: type: string minLength: 1 required: - roleName Role: type: object properties: id: type: integer name: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] isSystem: type: boolean createdAt: type: string format: date-time userCount: type: integer description: | Number of users assigned this role. Populated only by the admin list endpoint (GET /admin/roles) so the admin list can show the same dependency counts inline as the Apps/Services/Users/Groups panels. Omitted from create/update responses. groupCount: type: integer description: | Number of groups granting this role. Populated only by the admin list endpoint (GET /admin/roles). required: - id - name - isSystem - createdAt CreateRoleBody: type: object properties: name: type: string minLength: 1 descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] required: - name UpdateRoleBody: type: object properties: name: type: string minLength: 1 descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] RoleUsage: type: object properties: userCount: type: integer groupCount: type: integer required: - userCount - groupCount RoleDeletionConflict: type: object properties: error: type: string userCount: type: integer groupCount: type: integer required: - error - userCount - groupCount Permission: type: object properties: id: type: integer name: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] required: - id - name AddAppPermissionBody: type: object properties: permissionId: type: integer description: ID of the permission to require for this app. required: - permissionId AppPermissionLink: type: object properties: appId: type: integer permissionId: type: integer required: - appId - permissionId AppPermissionsImpactBody: type: object properties: permissionIds: type: array items: type: integer description: Candidate set of permission IDs that would gate this app. Pass an empty array to model "remove every requirement" (i.e. make the app unrestricted). required: - permissionIds AppPermissionImpactGroup: type: object properties: id: type: integer name: type: string required: - id - name AppPermissionsImpact: type: object properties: affectedUserCount: type: integer description: Distinct non-admin users who currently see the app and would lose visibility under the candidate permission set. Already accounts for users who keep access via `group_apps` group-grants. currentlyVisibleUserCount: type: integer description: Distinct non-admin users who currently see the app under the existing permission set. Useful for showing the warning as a fraction (e.g. "12 of 80 will lose access"). groups: type: array items: $ref: "#/components/schemas/AppPermissionImpactGroup" description: Groups that currently grant this app via `group_apps`. Members of any of these groups keep access regardless of permission changes. noChange: type: boolean description: True when the candidate set is identical to the current permission set so no change would actually be saved. required: - affectedUserCount - currentlyVisibleUserCount - groups - noChange ReplaceRolePermissionsBody: type: object properties: permissionIds: type: array items: type: integer description: Full set of permission IDs the role should grant. Existing assignments not present here are removed. required: - permissionIds RolePermissionsImpactBody: type: object properties: permissionIds: type: array items: type: integer description: Candidate set of permission IDs to evaluate against the role's current assignments. required: - permissionIds RolePermissionImpactGroup: type: object properties: id: type: integer name: type: string required: - id - name RolePermissionImpactItem: type: object properties: permissionId: type: integer permissionName: type: string userCount: type: integer description: Distinct users who currently have this permission via this role and would lose it (no other role they hold grants the permission). groupCount: type: integer description: Number of groups that currently grant this role. groups: type: array items: $ref: "#/components/schemas/RolePermissionImpactGroup" description: Groups that currently grant this role and therefore propagate the removal to their members. required: - permissionId - permissionName - userCount - groupCount - groups RolePermissionsImpact: type: object properties: removed: type: array items: $ref: "#/components/schemas/RolePermissionImpactItem" description: One entry per permission that is currently assigned to the role but not in the candidate set. totalAffectedUsers: type: integer description: Distinct users that would lose at least one permission across all removals. required: - removed - totalAffectedUsers GroupSummary: type: object properties: id: type: integer name: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] required: - id - name Group: type: object properties: id: type: integer name: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] isSystem: type: boolean memberCount: type: integer appCount: type: integer roleCount: type: integer createdAt: type: string format: date-time required: - id - name - isSystem - memberCount - appCount - roleCount - createdAt GroupDeletionConflict: type: object properties: error: type: string memberCount: type: integer appCount: type: integer roleCount: type: integer required: - error - memberCount - appCount - roleCount UserDeletionConflict: type: object properties: error: type: string noteCount: type: integer orderCount: type: integer required: - error - noteCount - orderCount AppDeletionConflict: type: object properties: error: type: string groupCount: type: integer restrictionCount: type: integer openCount: type: integer required: - error - groupCount - restrictionCount - openCount ServiceDeletionConflict: type: object properties: error: type: string orderCount: type: integer required: - error - orderCount GroupDetail: type: object properties: id: type: integer name: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] isSystem: type: boolean appIds: type: array items: type: integer roleIds: type: array items: type: integer userIds: type: array items: type: integer createdAt: type: string format: date-time required: - id - name - isSystem - appIds - roleIds - userIds - createdAt CreateGroupBody: type: object properties: name: type: string minLength: 1 descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] appIds: type: array items: type: integer roleIds: type: array items: type: integer userIds: type: array items: type: integer required: - name UpdateGroupBody: type: object properties: name: type: string minLength: 1 descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] appIds: type: array items: type: integer roleIds: type: array items: type: integer userIds: type: array items: type: integer App: type: object properties: id: type: integer slug: type: string nameAr: type: string nameEn: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] iconName: type: string imageUrl: type: ["string", "null"] description: | Optional uploaded image used in place of the Lucide icon on the launcher tile. Stored as the object-storage path returned by the upload presigner (e.g. "/objects/"). route: type: string externalUrl: type: ["string", "null"] description: | For openMode "external_tab" or "external_iframe": the URL the launcher should open. Ignored when openMode is "internal". openMode: type: string enum: [internal, external_tab, external_iframe] description: | How the launcher opens this app: "internal" navigates to `route` inside the SPA; "external_tab" opens externalUrl in a new tab; "external_iframe" navigates to /embedded/:id, which renders externalUrl inside an iframe shell. color: type: string isActive: type: boolean isSystem: type: boolean sortOrder: type: integer createdAt: type: string format: date-time updatedAt: type: string format: date-time groupCount: type: integer description: | Number of groups granting access to this app. Populated only by the admin list endpoint (GET /admin/apps) so the delete dialog can warn before the first click. Omitted from non-admin and single-app responses. restrictionCount: type: integer description: | Number of legacy permission restrictions for this app. Populated only by the admin list endpoint (GET /admin/apps). openCount: type: integer description: | Number of recorded app-open events for this app. Populated only by the admin list endpoint (GET /admin/apps). required: - id - slug - nameAr - nameEn - iconName - route - openMode - color - isActive - isSystem - sortOrder - createdAt - updatedAt CreateAppBody: type: object properties: slug: type: string nameAr: type: string nameEn: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] iconName: type: string imageUrl: type: ["string", "null"] route: type: string externalUrl: type: ["string", "null"] openMode: type: string enum: [internal, external_tab, external_iframe] color: type: string isActive: type: boolean isSystem: type: boolean sortOrder: type: integer permissionIds: type: array description: Optional set of permission IDs to gate the new app on. Inserted with onConflictDoNothing so duplicates are tolerated. items: type: integer required: - slug - nameAr - nameEn - iconName - route - color UpdateAppBody: type: object properties: nameAr: type: string nameEn: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] iconName: type: string imageUrl: type: ["string", "null"] route: type: string externalUrl: type: ["string", "null"] openMode: type: string enum: [internal, external_tab, external_iframe] color: type: string isActive: type: boolean sortOrder: type: integer Service: type: object properties: id: type: integer categoryId: type: ["integer", "null"] nameAr: type: string nameEn: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] imageUrl: type: ["string", "null"] price: type: ["string", "null"] isAvailable: type: boolean sortOrder: type: integer createdAt: type: string format: date-time updatedAt: type: string format: date-time orderCount: type: integer description: | Number of service_orders rows referencing this service. Populated only by the list endpoint (GET /services) so the admin delete dialog can warn before the first click. Omitted from single-service responses. required: - id - nameAr - nameEn - isAvailable - sortOrder - createdAt - updatedAt CreateServiceBody: type: object properties: categoryId: type: ["integer", "null"] nameAr: type: string nameEn: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] imageUrl: type: ["string", "null"] price: type: ["string", "null"] isAvailable: type: boolean sortOrder: type: integer required: - nameAr - nameEn UpdateServiceBody: type: object properties: categoryId: type: ["integer", "null"] nameAr: type: string nameEn: type: string descriptionAr: type: ["string", "null"] descriptionEn: type: ["string", "null"] imageUrl: type: ["string", "null"] price: type: ["string", "null"] isAvailable: type: boolean sortOrder: type: integer ServiceCategory: type: object properties: id: type: integer nameAr: type: string nameEn: type: string sortOrder: type: integer createdAt: type: string format: date-time required: - id - nameAr - nameEn - sortOrder - createdAt ServiceOrderUser: type: object properties: id: type: integer username: type: string displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] avatarUrl: type: ["string", "null"] required: - id - username ServiceOrderService: type: object properties: id: type: integer nameAr: type: string nameEn: type: string imageUrl: type: ["string", "null"] required: - id - nameAr - nameEn ServiceOrderStatus: type: string enum: [pending, received, preparing, completed, cancelled] ServiceOrder: type: object properties: id: type: integer serviceId: type: integer userId: type: integer assignedTo: type: ["integer", "null"] notes: type: ["string", "null"] status: $ref: "#/components/schemas/ServiceOrderStatus" createdAt: type: string format: date-time updatedAt: type: string format: date-time service: $ref: "#/components/schemas/ServiceOrderService" requester: oneOf: - $ref: "#/components/schemas/ServiceOrderUser" - type: "null" assignee: oneOf: - $ref: "#/components/schemas/ServiceOrderUser" - type: "null" required: - id - serviceId - userId - status - createdAt - updatedAt - service CreateServiceOrderBody: type: object properties: serviceId: type: integer notes: type: ["string", "null"] maxLength: 500 required: - serviceId UpdateServiceOrderStatusBody: type: object properties: status: type: string enum: [pending, received, preparing, completed, cancelled] required: - status BulkDeleteServiceOrdersBody: type: object properties: ids: type: array items: type: integer minItems: 1 maxItems: 200 required: - ids BulkDeleteServiceOrdersResponse: type: object properties: deletedIds: type: array items: type: integer description: Ids that were successfully deleted. failedIds: type: array items: type: integer description: Ids that were skipped (not found, or caller is not authorized). required: - deletedIds - failedIds Notification: type: object properties: id: type: integer userId: type: integer titleAr: type: string titleEn: type: string bodyAr: type: ["string", "null"] bodyEn: type: ["string", "null"] type: type: string isRead: type: boolean relatedId: type: ["integer", "null"] relatedType: type: ["string", "null"] createdAt: type: string format: date-time required: - id - userId - titleAr - titleEn - type - isRead - createdAt AppSettings: type: object required: [siteNameAr, siteNameEn, registrationOpen, footerTextAr, footerTextEn] properties: siteNameAr: type: string siteNameEn: type: string registrationOpen: type: boolean footerTextAr: type: string footerTextEn: type: string updatedAt: type: string format: date-time UpdateMyAppOrderBody: type: object required: [order] properties: order: type: array items: type: integer description: App IDs in the desired display order UpdateAppSettingsBody: type: object properties: siteNameAr: type: string minLength: 1 maxLength: 200 siteNameEn: type: string minLength: 1 maxLength: 200 registrationOpen: type: boolean footerTextAr: type: string minLength: 1 maxLength: 300 footerTextEn: type: string minLength: 1 maxLength: 300 RequestUploadUrlBody: type: object required: [name, size, contentType] properties: name: type: string minLength: 1 size: type: integer minimum: 1 contentType: type: string minLength: 1 RequestUploadUrlResponse: type: object required: [uploadURL, objectPath] properties: uploadURL: type: string format: uri objectPath: type: string metadata: $ref: "#/components/schemas/RequestUploadUrlBody" HomeStats: type: object properties: totalApps: type: integer totalServices: type: integer unreadNotifications: type: integer totalUsers: type: integer required: - totalApps - totalServices - unreadNotifications - totalUsers AdminStats: type: object properties: range: type: string enum: ["7d", "30d", "90d", "custom"] rangeDays: type: integer rangeFrom: type: string description: Start date (YYYY-MM-DD UTC) of the selected window rangeTo: type: string description: End date (YYYY-MM-DD UTC) of the selected window newUsersInRange: type: integer newUsersPrevRange: type: integer activeServices: type: integer inactiveServices: type: integer signupsByDay: type: array items: type: object properties: date: type: string count: type: integer required: - date - count appOpensByDay: type: array items: type: object properties: date: type: string count: type: integer required: - date - count appOpensInRange: type: integer appOpensPrevRange: type: integer servicesCreatedByDay: type: array items: type: object properties: date: type: string count: type: integer required: - date - count servicesCreatedInRange: type: integer topApps: type: array items: $ref: "#/components/schemas/TopAppItem" mostActiveUsers: type: array items: $ref: "#/components/schemas/TopUserItem" required: - range - rangeDays - newUsersInRange - newUsersPrevRange - activeServices - inactiveServices - signupsByDay - appOpensByDay - appOpensInRange - appOpensPrevRange - servicesCreatedByDay - servicesCreatedInRange - topApps - mostActiveUsers TopAppItem: type: object properties: appId: type: integer slug: type: string nameAr: type: string nameEn: type: string iconName: type: string color: type: string count: type: integer required: - appId - slug - nameAr - nameEn - iconName - color - count AppOpenByAppEntry: type: object properties: id: type: integer createdAt: type: string format: date-time userId: type: integer username: type: string displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] avatarUrl: type: ["string", "null"] required: - id - createdAt - userId - username AdminAppOpensByApp: type: object properties: appId: type: integer slug: type: string nameAr: type: string nameEn: type: string iconName: type: string color: type: string range: type: string enum: ["7d", "30d", "90d", "custom"] rangeDays: type: integer rangeFrom: type: string rangeTo: type: string totalCount: type: integer limit: type: integer offset: type: integer nextOffset: type: ["integer", "null"] opens: type: array items: $ref: "#/components/schemas/AppOpenByAppEntry" required: - appId - slug - nameAr - nameEn - iconName - color - range - rangeDays - rangeFrom - rangeTo - totalCount - limit - offset - nextOffset - opens AppOpenByUserEntry: type: object properties: id: type: integer createdAt: type: string format: date-time appId: type: integer slug: type: string nameAr: type: string nameEn: type: string iconName: type: string color: type: string required: - id - createdAt - appId - slug - nameAr - nameEn - iconName - color AdminAppOpensByUser: type: object properties: userId: type: integer username: type: string displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] avatarUrl: type: ["string", "null"] range: type: string enum: ["7d", "30d", "90d", "custom"] rangeDays: type: integer rangeFrom: type: string rangeTo: type: string totalCount: type: integer limit: type: integer offset: type: integer nextOffset: type: ["integer", "null"] opens: type: array items: $ref: "#/components/schemas/AppOpenByUserEntry" required: - userId - username - range - rangeDays - rangeFrom - rangeTo - totalCount - limit - offset - nextOffset - opens # ===== Dependent-item drill-ins ===== # Each " X" badge on the admin Apps / Services / Users rows is now # clickable; the click loads one of the schemas below. They share the # same envelope (totalCount + limit/offset/nextOffset) so the frontend # can use a single LoadMoreSection for them all. AppDependentGroupItem: type: object properties: id: { type: integer } name: { type: string } descriptionAr: { type: ["string", "null"] } descriptionEn: { type: ["string", "null"] } required: [id, name, descriptionAr, descriptionEn] AppDependentGroupsPage: type: object properties: items: type: array items: $ref: "#/components/schemas/AppDependentGroupItem" totalCount: { type: integer } limit: { type: integer } offset: { type: integer } nextOffset: { type: ["integer", "null"] } required: [items, totalCount, limit, offset, nextOffset] AppDependentRestrictionItem: type: object properties: id: { type: integer } name: { type: string } descriptionAr: { type: ["string", "null"] } descriptionEn: { type: ["string", "null"] } roles: type: array description: Names of roles currently holding this permission. items: { type: string } required: [id, name, descriptionAr, descriptionEn, roles] AppDependentRestrictionsPage: type: object properties: items: type: array items: $ref: "#/components/schemas/AppDependentRestrictionItem" totalCount: { type: integer } limit: { type: integer } offset: { type: integer } nextOffset: { type: ["integer", "null"] } required: [items, totalCount, limit, offset, nextOffset] AppDependentOpenItem: type: object properties: id: { type: integer } createdAt: { type: string, format: date-time } userId: { type: integer } username: { type: string } displayNameAr: { type: ["string", "null"] } displayNameEn: { type: ["string", "null"] } avatarUrl: { type: ["string", "null"] } required: [id, createdAt, userId, username, displayNameAr, displayNameEn, avatarUrl] AppDependentOpensPage: type: object properties: items: type: array items: $ref: "#/components/schemas/AppDependentOpenItem" totalCount: { type: integer } limit: { type: integer } offset: { type: integer } nextOffset: { type: ["integer", "null"] } required: [items, totalCount, limit, offset, nextOffset] ServiceDependentOrderItem: type: object properties: id: { type: integer } status: { type: string } notes: { type: ["string", "null"] } createdAt: { type: string, format: date-time } userId: { type: integer } username: { type: string } displayNameAr: { type: ["string", "null"] } displayNameEn: { type: ["string", "null"] } required: [id, status, notes, createdAt, userId, username, displayNameAr, displayNameEn] ServiceDependentOrdersPage: type: object properties: items: type: array items: $ref: "#/components/schemas/ServiceDependentOrderItem" totalCount: { type: integer } limit: { type: integer } offset: { type: integer } nextOffset: { type: ["integer", "null"] } required: [items, totalCount, limit, offset, nextOffset] UserDependentNoteItem: type: object properties: id: { type: integer } title: { type: string } isPinned: { type: boolean } isArchived: { type: boolean } updatedAt: { type: string, format: date-time } required: [id, title, isPinned, isArchived, updatedAt] NoteRecipientStatus: type: string enum: [unread, read, replied, archived] NoteUserSummary: type: object properties: id: { type: integer } username: { type: string } displayNameAr: { type: ["string", "null"] } displayNameEn: { type: ["string", "null"] } avatarUrl: { type: ["string", "null"] } isActive: { type: boolean } required: [id, username, displayNameAr, displayNameEn] NoteRecipientSummary: type: object properties: id: { type: integer } noteId: { type: integer } recipientUserId: { type: integer } status: { $ref: "#/components/schemas/NoteRecipientStatus" } sentAt: { type: string, format: date-time } readAt: { type: ["string", "null"], format: date-time } archivedAt: { type: ["string", "null"], format: date-time } recipient: oneOf: - $ref: "#/components/schemas/NoteUserSummary" - type: "null" required: [id, recipientUserId, status, sentAt, readAt, archivedAt] MyNote: type: object description: A note owned by the caller (My Notes / Archive view). properties: id: { type: integer } userId: { type: integer } title: { type: string } content: { type: string } color: { type: string } isPinned: { type: boolean } isArchived: { type: boolean } folderId: type: ["integer", "null"] description: Owning folder id, or null for Unfiled. createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } labelIds: type: array items: { type: integer } required: [id, userId, title, content, color, isPinned, isArchived, folderId, createdAt, updatedAt, labelIds] NoteFolder: type: object properties: id: { type: integer } userId: { type: integer } name: { type: string } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } noteCount: type: integer description: Number of notes in this folder, scoped to the requested tab (active vs archived). required: [id, userId, name, createdAt, updatedAt, noteCount] SentNote: type: object properties: id: { type: integer } userId: { type: integer } title: { type: string } content: { type: string } color: { type: string } isPinned: { type: boolean } isArchived: { type: boolean } folderId: type: ["integer", "null"] description: Owning folder id, or null for Unfiled. createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } recipients: type: array items: { $ref: "#/components/schemas/NoteRecipientSummary" } replyCount: { type: integer } required: [id, userId, title, content, color, createdAt, updatedAt, recipients, replyCount] ReceivedNote: type: object properties: id: { type: integer } title: { type: string } content: { type: string } color: { type: string } folderId: type: ["integer", "null"] description: Owning folder id on the recipient's side, or null for Unfiled. createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } sender: oneOf: - $ref: "#/components/schemas/NoteUserSummary" - type: "null" recipientRowId: { type: integer } status: { $ref: "#/components/schemas/NoteRecipientStatus" } sentAt: { type: string, format: date-time } readAt: { type: ["string", "null"], format: date-time } archivedAt: { type: ["string", "null"], format: date-time } required: [id, title, content, color, createdAt, updatedAt, recipientRowId, status, sentAt, readAt, archivedAt] NoteReply: type: object properties: id: { type: integer } noteId: { type: integer } senderUserId: { type: integer } recipientUserId: { type: integer } content: { type: string } createdAt: { type: string, format: date-time } sender: oneOf: - $ref: "#/components/schemas/NoteUserSummary" - type: "null" required: [id, noteId, senderUserId, recipientUserId, content, createdAt] NoteThread: type: object properties: id: { type: integer } title: { type: string } content: { type: string } color: { type: string } createdAt: { type: ["string", "null"], format: date-time } updatedAt: { type: ["string", "null"], format: date-time } senderUserId: { type: integer } sender: oneOf: - $ref: "#/components/schemas/NoteUserSummary" - type: "null" isOwner: { type: boolean } isAdmin: { type: boolean } myStatus: oneOf: - $ref: "#/components/schemas/NoteRecipientStatus" - type: "null" recipients: type: array items: { $ref: "#/components/schemas/NoteRecipientSummary" } replies: type: array items: { $ref: "#/components/schemas/NoteReply" } required: [id, title, content, color, senderUserId, isOwner, isAdmin, myStatus, recipients, replies] SendNoteResult: type: object properties: success: { type: boolean } sent: { type: integer } required: [success, sent] UserDependentNotesPage: type: object properties: items: type: array items: $ref: "#/components/schemas/UserDependentNoteItem" totalCount: { type: integer } limit: { type: integer } offset: { type: integer } nextOffset: { type: ["integer", "null"] } required: [items, totalCount, limit, offset, nextOffset] UserDependentOrderItem: type: object properties: id: { type: integer } status: { type: string } notes: { type: ["string", "null"] } createdAt: { type: string, format: date-time } serviceId: { type: integer } serviceNameAr: { type: string } serviceNameEn: { type: string } required: [id, status, notes, createdAt, serviceId, serviceNameAr, serviceNameEn] UserDependentOrdersPage: type: object properties: items: type: array items: $ref: "#/components/schemas/UserDependentOrderItem" totalCount: { type: integer } limit: { type: integer } offset: { type: integer } nextOffset: { type: ["integer", "null"] } required: [items, totalCount, limit, offset, nextOffset] TopUserItem: type: object properties: userId: type: integer username: type: string displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] avatarUrl: type: ["string", "null"] count: type: integer required: - userId - username - count AuditLogActor: type: object properties: id: type: integer username: type: string displayNameAr: type: ["string", "null"] displayNameEn: type: ["string", "null"] avatarUrl: type: ["string", "null"] required: - id - username RolePermissionAuditList: type: object properties: entries: type: array items: $ref: "#/components/schemas/RolePermissionAuditEntry" totalCount: type: integer limit: type: integer offset: type: integer nextOffset: type: ["integer", "null"] required: - entries - totalCount - limit - offset - nextOffset RolePermissionAuditEntry: type: object properties: id: type: integer roleId: type: integer previousPermissionIds: type: array items: type: integer newPermissionIds: type: array items: type: integer addedPermissionIds: type: array items: type: integer removedPermissionIds: type: array items: type: integer createdAt: type: string format: date-time actor: oneOf: - $ref: "#/components/schemas/AuditLogActor" - type: "null" required: - id - roleId - previousPermissionIds - newPermissionIds - addedPermissionIds - removedPermissionIds - createdAt - actor PermissionAuditList: type: object properties: entries: type: array items: $ref: "#/components/schemas/PermissionAuditEntry" totalCount: type: integer limit: type: integer offset: type: integer nextOffset: type: ["integer", "null"] required: - entries - totalCount - limit - offset - nextOffset PermissionAuditEntry: type: object properties: id: type: integer targetKind: type: string enum: [user, group, app] targetId: type: integer changeKind: type: string enum: - user.roles - user.groups - group.users - group.roles - group.apps - app.permissions previousIds: type: array items: type: integer newIds: type: array items: type: integer addedIds: type: array items: type: integer removedIds: type: array items: type: integer createdAt: type: string format: date-time actor: oneOf: - $ref: "#/components/schemas/AuditLogActor" - type: "null" required: - id - targetKind - targetId - changeKind - previousIds - newIds - addedIds - removedIds - createdAt - actor AuditLogEntry: type: object properties: id: type: integer action: type: string targetType: type: string targetId: type: ["integer", "null"] metadata: type: ["object", "null"] additionalProperties: true createdAt: type: string format: date-time actor: oneOf: - $ref: "#/components/schemas/AuditLogActor" - type: "null" required: - id - action - targetType - targetId - metadata - createdAt - actor AuditLogList: type: object properties: entries: type: array items: $ref: "#/components/schemas/AuditLogEntry" totalCount: type: integer limit: type: integer offset: type: integer nextOffset: type: ["integer", "null"] actions: type: array items: type: string description: All distinct action names present in the audit log (for building filter UI). required: - entries - totalCount - limit - offset - nextOffset - actions