Files
professional_management/professional-platform-openapi_3.yaml
T
2026-08-27 21:52:21 -04:00

3849 lines
121 KiB
YAML

openapi: 3.1.0
info:
title: Professional Management Platform API
version: 1.0.0-milestone.3
summary: Platform access, RBAC, engineering clients, and engineering project management.
description: |
Executable API contract for Milestones 1 through 3 of the Professional Management Platform.
Tenant-scoped operations require `X-Organization-Id`. Cross-tenant resources are
reported as not found. Resource creation and material commands require an
`Idempotency-Key`. Mutable resources use ETags and require `If-Match`.
Error responses use RFC 9457 Problem Details extended with stable `code`,
`requestId`, and optional field-level `errors`.
contact:
name: Platform API Team
servers:
- url: https://api.example.com/api/v1
description: Production
- url: https://sandbox-api.example.com/api/v1
description: Sandbox
tags:
- name: Authentication
- name: Sessions
- name: Current User
- name: Organizations
- name: Membership Invitations
- name: Memberships
- name: Roles
- name: Permissions
- name: Engineering Clients
- name: Engineering Client Contacts
- name: Engineering Projects
paths:
/auth/register:
post:
tags: [Authentication]
operationId: registerUser
summary: Register a user identity
description: |
Creates a global user identity. When public registration is disabled, this
operation returns `REGISTRATION_DISABLED`; invitation acceptance remains
available to authenticated identities created through the configured onboarding flow.
security: []
parameters:
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
responses:
'201':
description: User identity created; email verification may still be required.
headers:
Location:
$ref: '#/components/headers/Location'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/auth/login:
post:
tags: [Authentication]
operationId: login
summary: Authenticate with email and password
security: []
parameters:
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: Authentication succeeded.
headers:
Cache-Control:
schema:
type: string
const: no-store
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/TokenPairResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/auth/logout:
post:
tags: [Authentication]
operationId: logout
summary: Revoke the current session
parameters:
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
responses:
'204':
description: Current session revoked.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
/auth/token/refresh:
post:
tags: [Authentication]
operationId: refreshAccessToken
summary: Rotate a refresh token and issue a new token pair
description: Reuse of a rotated refresh token revokes its token family and session.
security: []
parameters:
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshTokenRequest'
responses:
'200':
description: Token rotated.
headers:
Cache-Control:
schema:
type: string
const: no-store
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/TokenPairResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
/auth/token/revoke:
post:
tags: [Authentication]
operationId: revokeRefreshToken
summary: Revoke one refresh-token family
parameters:
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshTokenRequest'
responses:
'204':
description: Token family revoked or already revoked.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
/auth/token/revoke-all:
post:
tags: [Authentication]
operationId: revokeAllSessions
summary: Revoke all sessions for the current user
parameters:
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
responses:
'204':
description: All sessions revoked, including the current session.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
/auth/sessions:
get:
tags: [Sessions]
operationId: listSessions
summary: List sessions for the current user
parameters:
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
responses:
'200':
description: Sessions returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/SessionCollectionResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
/auth/sessions/{sessionId}:
delete:
tags: [Sessions]
operationId: revokeSession
summary: Revoke a specific session
parameters:
- $ref: '#/components/parameters/SessionId'
- $ref: '#/components/parameters/RequestId'
responses:
'204':
description: Session revoked or already revoked.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
/me:
get:
tags: [Current User]
operationId: getCurrentUser
summary: Get the current user
parameters:
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: Current user returned.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
patch:
tags: [Current User]
operationId: updateCurrentUser
summary: Update the current user's profile
parameters:
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IfMatch'
requestBody:
required: true
content:
application/merge-patch+json:
schema:
$ref: '#/components/schemas/UpdateCurrentUserRequest'
responses:
'200':
description: Current user updated.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/me/organizations:
get:
tags: [Current User]
operationId: listCurrentUserOrganizations
summary: List organizations accessible to the current user
parameters:
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
responses:
'200':
description: Accessible organizations returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/OrganizationCollectionResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
/organizations:
post:
tags: [Organizations]
operationId: createOrganization
summary: Create an organization
x-authorization-policy: authenticated_user_may_create_organization
x-audit-action: organizations.create
description: |
Atomically creates the organization, enables its initial profession modules,
creates an active owner membership, assigns the immutable Owner system role,
and writes audit and outbox records.
parameters:
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrganizationRequest'
responses:
'201':
description: Organization created.
headers:
Location:
$ref: '#/components/headers/Location'
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/OrganizationResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/organizations/{organizationId}:
parameters:
- $ref: '#/components/parameters/OrganizationId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
get:
tags: [Organizations]
operationId: getOrganization
summary: Get an organization
x-required-permissions: [organizations.read]
responses:
'200':
description: Organization returned.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/OrganizationResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
patch:
tags: [Organizations]
operationId: updateOrganization
summary: Update organization settings
x-required-permissions: [organizations.update]
x-audit-action: organizations.update
parameters:
- $ref: '#/components/parameters/IfMatch'
requestBody:
required: true
content:
application/merge-patch+json:
schema:
$ref: '#/components/schemas/UpdateOrganizationRequest'
responses:
'200':
description: Organization updated.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/OrganizationResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/membership-invitations:
get:
tags: [Membership Invitations]
operationId: listMembershipInvitations
summary: List membership invitations
x-required-permissions: [members.read]
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- name: status
in: query
schema:
$ref: '#/components/schemas/InvitationStatus'
responses:
'200':
description: Invitations returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/InvitationCollectionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'429':
$ref: '#/components/responses/RateLimited'
post:
tags: [Membership Invitations]
operationId: createMembershipInvitation
summary: Invite a person to the current organization
x-required-permissions: [members.invite]
x-audit-action: memberships.invite
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateInvitationRequest'
responses:
'201':
description: Invitation created.
headers:
Location:
$ref: '#/components/headers/Location'
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/InvitationResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/membership-invitations/accept:
post:
tags: [Membership Invitations]
operationId: acceptMembershipInvitation
summary: Accept an invitation for the current user
x-authorization-policy: invitation_email_must_match_current_user
x-audit-action: memberships.accept_invitation
description: |
The invitation token is sent in the request body to avoid path and access-log
disclosure. Acceptance atomically creates the membership, copies valid intended
roles, marks the invitation accepted, and writes audit and outbox records.
parameters:
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AcceptInvitationRequest'
responses:
'201':
description: Invitation accepted and membership created.
headers:
Location:
$ref: '#/components/headers/Location'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/membership-invitations/{invitationId}:
get:
tags: [Membership Invitations]
operationId: getMembershipInvitation
summary: Get a membership invitation
x-required-permissions: [members.read]
parameters:
- $ref: '#/components/parameters/InvitationId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: Invitation returned.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/InvitationResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
/membership-invitations/{invitationId}/revoke:
post:
tags: [Membership Invitations]
operationId: revokeMembershipInvitation
summary: Revoke a pending invitation
x-required-permissions: [members.invite]
x-audit-action: memberships.revoke_invitation
parameters:
- $ref: '#/components/parameters/InvitationId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
responses:
'200':
description: Invitation revoked or already revoked.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/InvitationResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/membership-invitations/{invitationId}/resend:
post:
tags: [Membership Invitations]
operationId: resendMembershipInvitation
summary: Rotate the token and resend a pending invitation
x-required-permissions: [members.invite]
x-audit-action: memberships.resend_invitation
parameters:
- $ref: '#/components/parameters/InvitationId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
responses:
'200':
description: Invitation token rotated and delivery queued.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/InvitationResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/memberships:
get:
tags: [Memberships]
operationId: listMemberships
summary: List memberships in the current organization
x-required-permissions: [members.read]
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- name: status
in: query
schema:
$ref: '#/components/schemas/MembershipStatus'
- name: userId
in: query
schema:
$ref: '#/components/schemas/Uuid'
responses:
'200':
description: Memberships returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipCollectionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'429':
$ref: '#/components/responses/RateLimited'
/memberships/{membershipId}:
get:
tags: [Memberships]
operationId: getMembership
summary: Get a membership
x-required-permissions: [members.read]
parameters:
- $ref: '#/components/parameters/MembershipId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: Membership returned.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
/memberships/{membershipId}/deactivate:
post:
tags: [Memberships]
operationId: deactivateMembership
summary: Deactivate a membership
description: |
Rejected when the member is the last active organization Owner or manages any
active engineering project. Those responsibilities must be reassigned first.
x-required-permissions: [members.update]
x-audit-action: memberships.deactivate
parameters:
- $ref: '#/components/parameters/MembershipId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ReasonRequest'
responses:
'200':
description: Membership deactivated or already inactive.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/memberships/{membershipId}/reactivate:
post:
tags: [Memberships]
operationId: reactivateMembership
summary: Reactivate an inactive membership
x-required-permissions: [members.update]
x-audit-action: memberships.reactivate
parameters:
- $ref: '#/components/parameters/MembershipId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
responses:
'200':
description: Membership reactivated or already active.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/memberships/{membershipId}/roles:
put:
tags: [Memberships, Roles]
operationId: replaceMembershipRoles
summary: Replace all roles assigned to a membership
x-required-permissions: [roles.manage]
x-audit-action: memberships.replace_roles
description: |
The replacement is atomic. Every supplied role must belong to the current
organization. The operation rejects removal of the last active Owner.
parameters:
- $ref: '#/components/parameters/MembershipId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IfMatch'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ReplaceMembershipRolesRequest'
responses:
'200':
description: Membership roles replaced.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/roles:
get:
tags: [Roles]
operationId: listRoles
summary: List roles in the current organization
x-required-permissions: [roles.read]
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
responses:
'200':
description: Roles returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/RoleCollectionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'429':
$ref: '#/components/responses/RateLimited'
post:
tags: [Roles]
operationId: createRole
summary: Create a custom role
x-required-permissions: [roles.manage]
x-audit-action: roles.create
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateRoleRequest'
responses:
'201':
description: Role created.
headers:
Location:
$ref: '#/components/headers/Location'
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/RoleResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/roles/{roleId}:
parameters:
- $ref: '#/components/parameters/RoleId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
get:
tags: [Roles]
operationId: getRole
summary: Get a role
x-required-permissions: [roles.read]
responses:
'200':
description: Role returned.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/RoleResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
patch:
tags: [Roles]
operationId: updateRole
summary: Update a custom role
x-required-permissions: [roles.manage]
x-audit-action: roles.update
description: Immutable system roles cannot be modified.
parameters:
- $ref: '#/components/parameters/IfMatch'
requestBody:
required: true
content:
application/merge-patch+json:
schema:
$ref: '#/components/schemas/UpdateRoleRequest'
responses:
'200':
description: Role updated.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/RoleResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/roles/{roleId}/deactivate:
post:
tags: [Roles]
operationId: deactivateRole
summary: Deactivate a custom role
x-required-permissions: [roles.manage]
x-audit-action: roles.deactivate
description: |
Prevents future assignment of the role without deleting historical assignments.
Immutable system roles cannot be deactivated.
parameters:
- $ref: '#/components/parameters/RoleId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
responses:
'200':
description: Role deactivated or already inactive.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/RoleResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/roles/{roleId}/reactivate:
post:
tags: [Roles]
operationId: reactivateRole
summary: Reactivate an inactive custom role
x-required-permissions: [roles.manage]
x-audit-action: roles.reactivate
parameters:
- $ref: '#/components/parameters/RoleId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
responses:
'200':
description: Role reactivated or already active.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/RoleResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/permissions:
get:
tags: [Permissions]
operationId: listPermissions
summary: List registered permissions available to the organization
x-required-permissions: [roles.read]
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- name: profession
in: query
schema:
$ref: '#/components/schemas/Profession'
responses:
'200':
description: Permissions returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/PermissionCollectionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/clients:
get:
tags: [Engineering Clients]
operationId: listEngineeringClients
summary: List engineering clients
description: Archived clients are excluded unless `status=archived` is requested explicitly.
x-required-profession: engineering
x-required-permissions: [engineering.clients.read]
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- name: clientType
in: query
schema:
$ref: '#/components/schemas/EngineeringClientType'
- name: status
in: query
schema:
$ref: '#/components/schemas/EngineeringClientStatus'
- name: q
in: query
description: Case-insensitive search across display name and legal name.
schema:
type: string
minLength: 2
maxLength: 200
- name: sort
in: query
schema:
type: string
enum: [displayName, -displayName, createdAt, -createdAt]
default: displayName
responses:
'200':
description: Engineering clients returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientCollectionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
post:
tags: [Engineering Clients]
operationId: createEngineeringClient
summary: Create an engineering client
x-required-profession: engineering
x-required-permissions: [engineering.clients.create]
x-audit-action: engineering.clients.create
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateEngineeringClientRequest'
responses:
'201':
description: Engineering client created.
headers:
Location:
$ref: '#/components/headers/Location'
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/clients/{clientId}:
parameters:
- $ref: '#/components/parameters/ClientId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
get:
tags: [Engineering Clients]
operationId: getEngineeringClient
summary: Get an engineering client
x-required-profession: engineering
x-required-permissions: [engineering.clients.read]
responses:
'200':
description: Engineering client returned.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
patch:
tags: [Engineering Clients]
operationId: updateEngineeringClient
summary: Update an active engineering client
description: Status changes are not accepted here; use archive and restore commands.
x-required-profession: engineering
x-required-permissions: [engineering.clients.update]
x-audit-action: engineering.clients.update
parameters:
- $ref: '#/components/parameters/IfMatch'
requestBody:
required: true
content:
application/merge-patch+json:
schema:
$ref: '#/components/schemas/UpdateEngineeringClientRequest'
responses:
'200':
description: Engineering client updated.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/clients/{clientId}/archive:
post:
tags: [Engineering Clients]
operationId: archiveEngineeringClient
summary: Archive an engineering client
description: |
Archiving removes the client from default active lists without deleting client,
contact, project, billing, audit, or document history. The command is rejected
while the client has any project in `draft` or `active` status.
x-required-profession: engineering
x-required-permissions: [engineering.clients.archive]
x-audit-action: engineering.clients.archive
parameters:
- $ref: '#/components/parameters/ClientId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ReasonRequest'
responses:
'200':
description: Engineering client archived or already archived.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/clients/{clientId}/restore:
post:
tags: [Engineering Clients]
operationId: restoreEngineeringClient
summary: Restore an archived engineering client
description: Restore is rejected when organization policy or retention rules prohibit it.
x-required-profession: engineering
x-required-permissions: [engineering.clients.archive]
x-audit-action: engineering.clients.restore
parameters:
- $ref: '#/components/parameters/ClientId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
responses:
'200':
description: Engineering client restored or already active.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/clients/{clientId}/projects:
get:
tags: [Engineering Clients]
operationId: listEngineeringClientProjects
summary: List projects belonging to an engineering client
description: This is a client-scoped projection; full project representations arrive in Milestone 3.
x-required-profession: engineering
x-required-permissions: [engineering.clients.read, engineering.projects.read]
parameters:
- $ref: '#/components/parameters/ClientId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- name: status
in: query
schema:
$ref: '#/components/schemas/EngineeringProjectStatus'
- name: sort
in: query
schema:
type: string
enum: [projectNumber, -projectNumber, createdAt, -createdAt]
default: -createdAt
responses:
'200':
description: Client projects returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectSummaryCollectionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/clients/{clientId}/contacts:
get:
tags: [Engineering Client Contacts]
operationId: listEngineeringClientContacts
summary: List contacts for an engineering client
description: Archived contacts are excluded unless `status=archived` is requested explicitly.
x-required-profession: engineering
x-required-permissions: [engineering.clients.read]
parameters:
- $ref: '#/components/parameters/ClientId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- name: contactType
in: query
schema:
$ref: '#/components/schemas/EngineeringContactType'
- name: status
in: query
schema:
$ref: '#/components/schemas/EngineeringContactStatus'
- name: isPrimary
in: query
schema:
type: boolean
- name: sort
in: query
schema:
type: string
enum: [name, -name, createdAt, -createdAt]
default: name
responses:
'200':
description: Client contacts returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientContactCollectionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
post:
tags: [Engineering Client Contacts]
operationId: createEngineeringClientContact
summary: Create a contact for an engineering client
description: |
When `isPrimary=true`, any current primary contact of the same contact type
is demoted atomically in the same transaction.
x-required-profession: engineering
x-required-permissions: [engineering.clients.update]
x-audit-action: engineering.client_contacts.create
parameters:
- $ref: '#/components/parameters/ClientId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateEngineeringClientContactRequest'
responses:
'201':
description: Client contact created.
headers:
Location:
$ref: '#/components/headers/Location'
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientContactResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/clients/{clientId}/contacts/{contactId}:
parameters:
- $ref: '#/components/parameters/ClientId'
- $ref: '#/components/parameters/ContactId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
get:
tags: [Engineering Client Contacts]
operationId: getEngineeringClientContact
summary: Get an engineering client contact
x-required-profession: engineering
x-required-permissions: [engineering.clients.read]
responses:
'200':
description: Client contact returned.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientContactResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
patch:
tags: [Engineering Client Contacts]
operationId: updateEngineeringClientContact
summary: Update an active engineering client contact
description: |
When `isPrimary=true`, any current primary contact of the resulting contact
type is demoted atomically. Status is not patchable.
x-required-profession: engineering
x-required-permissions: [engineering.clients.update]
x-audit-action: engineering.client_contacts.update
parameters:
- $ref: '#/components/parameters/IfMatch'
requestBody:
required: true
content:
application/merge-patch+json:
schema:
$ref: '#/components/schemas/UpdateEngineeringClientContactRequest'
responses:
'200':
description: Client contact updated.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientContactResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
delete:
tags: [Engineering Client Contacts]
operationId: archiveEngineeringClientContact
summary: Archive an engineering client contact
description: |
This operation is a recoverable logical archive, not a physical delete. Historical
references remain intact. Archiving a primary contact clears its primary flag.
Repeating the operation for an archived contact returns 204.
x-required-profession: engineering
x-required-permissions: [engineering.clients.update]
x-audit-action: engineering.client_contacts.archive
parameters:
- $ref: '#/components/parameters/IfMatch'
- $ref: '#/components/parameters/IdempotencyKey'
responses:
'204':
description: Client contact archived or already archived.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/clients/{clientId}/contacts/{contactId}/restore:
post:
tags: [Engineering Client Contacts]
operationId: restoreEngineeringClientContact
summary: Restore an archived engineering client contact
description: The parent client must be active. Restored contacts are not primary by default.
x-required-profession: engineering
x-required-permissions: [engineering.clients.update]
x-audit-action: engineering.client_contacts.restore
parameters:
- $ref: '#/components/parameters/ClientId'
- $ref: '#/components/parameters/ContactId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
responses:
'200':
description: Client contact restored or already active.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringClientContactResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/projects:
get:
tags: [Engineering Projects]
operationId: listEngineeringProjects
summary: List engineering projects
description: |
Archived projects are excluded unless `status=archived` is requested explicitly.
Permission scope is enforced in the query: `assigned` resolves through active project
membership or the project-manager pointer; `organization` resolves across the tenant.
x-required-profession: engineering
x-required-permissions: [engineering.projects.read]
x-permission-scope-policy: engineering_project_assignment
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- name: clientId
in: query
schema:
$ref: '#/components/schemas/Uuid'
- name: status
in: query
schema:
$ref: '#/components/schemas/EngineeringProjectStatus'
- name: discipline
in: query
schema:
$ref: '#/components/schemas/EngineeringDiscipline'
- name: projectManagerUserId
in: query
schema:
$ref: '#/components/schemas/Uuid'
- name: q
in: query
description: Case-insensitive search across project number, project name, and client name.
schema:
type: string
minLength: 2
maxLength: 200
- name: sort
in: query
description: Supported deterministic sort. Null date values are always placed last.
schema:
type: string
enum:
- projectNumber
- -projectNumber
- name
- -name
- startDate
- -startDate
- expectedCompletionDate
- -expectedCompletionDate
- createdAt
- -createdAt
default: -createdAt
responses:
'200':
description: Engineering projects returned.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectCollectionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
post:
tags: [Engineering Projects]
operationId: createEngineeringProject
summary: Create an engineering project in draft status
description: |
`projectNumber` is immutable and unique case-insensitively within the organization.
The referenced client must be active. A supplied project manager must have an active
membership in the same organization.
x-required-profession: engineering
x-required-permissions: [engineering.projects.create]
x-audit-action: engineering.projects.create
parameters:
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateEngineeringProjectRequest'
responses:
'201':
description: Engineering project created.
headers:
Location:
$ref: '#/components/headers/Location'
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/projects/{projectId}:
parameters:
- $ref: '#/components/parameters/ProjectId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
get:
tags: [Engineering Projects]
operationId: getEngineeringProject
summary: Get an engineering project
x-required-profession: engineering
x-required-permissions: [engineering.projects.read]
x-permission-scope-policy: engineering_project_assignment
responses:
'200':
description: Engineering project returned.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
patch:
tags: [Engineering Projects]
operationId: updateEngineeringProject
summary: Update editable engineering project fields
description: |
`projectNumber`, `status`, completion fields, and archive fields are not patchable.
`clientId` may change only while the project is `draft` and has no dependent records.
Changing `projectManagerUserId` changes assigned-scope access and is audited.
x-required-profession: engineering
x-required-permissions: [engineering.projects.update]
x-audit-action: engineering.projects.update
parameters:
- $ref: '#/components/parameters/IfMatch'
requestBody:
required: true
content:
application/merge-patch+json:
schema:
$ref: '#/components/schemas/UpdateEngineeringProjectRequest'
responses:
'200':
description: Engineering project updated.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/projects/{projectId}/activate:
post:
tags: [Engineering Projects]
operationId: activateEngineeringProject
summary: Activate a draft engineering project
description: |
Transition: `draft → active`. The client and project manager must both be active.
When `startDate` is absent from both the project and request, the server uses the
current date in the organization's configured time zone.
x-required-profession: engineering
x-required-permissions: [engineering.projects.activate]
x-audit-action: engineering.projects.activate
parameters:
- $ref: '#/components/parameters/ProjectId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ActivateEngineeringProjectRequest'
responses:
'200':
description: Engineering project activated or idempotent result replayed.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/projects/{projectId}/close:
post:
tags: [Engineering Projects]
operationId: closeEngineeringProject
summary: Close an active engineering project
description: |
Transition: `active → closed`. When `completedDate` is omitted, the server uses
the current date in the organization's configured time zone. The completed date
cannot precede the project start date.
x-required-profession: engineering
x-required-permissions: [engineering.projects.close]
x-audit-action: engineering.projects.close
parameters:
- $ref: '#/components/parameters/ProjectId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CloseEngineeringProjectRequest'
responses:
'200':
description: Engineering project closed or idempotent result replayed.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/projects/{projectId}/archive:
post:
tags: [Engineering Projects]
operationId: archiveEngineeringProject
summary: Archive a draft or closed engineering project
description: |
Transition: `draft|closed → archived`. Active projects must be closed first.
The prior status is retained so restore is deterministic. Related records and
audit history are never physically deleted.
x-required-profession: engineering
x-required-permissions: [engineering.projects.archive]
x-audit-action: engineering.projects.archive
parameters:
- $ref: '#/components/parameters/ProjectId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ReasonRequest'
responses:
'200':
description: Engineering project archived or idempotent result replayed.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/projects/{projectId}/restore:
post:
tags: [Engineering Projects]
operationId: restoreEngineeringProject
summary: Restore an archived engineering project
description: |
Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`.
Restore never reactivates a project implicitly. The referenced client must be active.
x-required-profession: engineering
x-required-permissions: [engineering.projects.archive]
x-audit-action: engineering.projects.restore
parameters:
- $ref: '#/components/parameters/ProjectId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
responses:
'200':
description: Engineering project restored or idempotent result replayed.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
'428':
$ref: '#/components/responses/PreconditionRequired'
'429':
$ref: '#/components/responses/RateLimited'
/engineering/projects/{projectId}/summary:
get:
tags: [Engineering Projects]
operationId: getEngineeringProjectSummary
summary: Get the engineering project dashboard summary
description: |
Returns a purpose-built read model. Counts are permission-filtered and include
only records visible to the caller. Modules not yet enabled return zero counts,
not omitted fields, preserving the response shape.
x-required-profession: engineering
x-required-permissions: [engineering.projects.read]
x-permission-scope-policy: engineering_project_assignment
parameters:
- $ref: '#/components/parameters/ProjectId'
- $ref: '#/components/parameters/OrganizationContext'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: Project summary returned.
headers:
ETag:
$ref: '#/components/headers/ETag'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/json:
schema:
$ref: '#/components/schemas/EngineeringProjectDashboardResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
OrganizationContext:
name: X-Organization-Id
in: header
required: true
description: Active organization context for the tenant-scoped request.
schema:
$ref: '#/components/schemas/Uuid'
RequestId:
name: X-Request-Id
in: header
required: false
description: Client-generated request identifier. The server generates one when omitted.
schema:
$ref: '#/components/schemas/Uuid'
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: |
Unique key for replay-safe execution. Reuse with a different normalized request
returns `IDEMPOTENCY_KEY_CONFLICT`.
schema:
type: string
minLength: 16
maxLength: 128
IfMatch:
name: If-Match
in: header
required: true
description: ETag returned by the latest representation of the resource.
schema:
type: string
minLength: 3
maxLength: 128
Limit:
name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 25
Cursor:
name: cursor
in: query
required: false
schema:
type: string
minLength: 1
maxLength: 2048
OrganizationId:
name: organizationId
in: path
required: true
schema:
$ref: '#/components/schemas/Uuid'
SessionId:
name: sessionId
in: path
required: true
schema:
$ref: '#/components/schemas/Uuid'
InvitationId:
name: invitationId
in: path
required: true
schema:
$ref: '#/components/schemas/Uuid'
MembershipId:
name: membershipId
in: path
required: true
schema:
$ref: '#/components/schemas/Uuid'
RoleId:
name: roleId
in: path
required: true
schema:
$ref: '#/components/schemas/Uuid'
ClientId:
name: clientId
in: path
required: true
schema:
$ref: '#/components/schemas/Uuid'
ContactId:
name: contactId
in: path
required: true
schema:
$ref: '#/components/schemas/Uuid'
ProjectId:
name: projectId
in: path
required: true
schema:
$ref: '#/components/schemas/Uuid'
headers:
RequestId:
description: Request identifier used for logs, audit, and diagnostics.
schema:
$ref: '#/components/schemas/Uuid'
ETag:
description: Strong validator for optimistic concurrency.
schema:
type: string
examples: ['"6"']
Location:
description: Canonical URI of the created resource.
schema:
type: string
format: uri-reference
RetryAfter:
description: Seconds or HTTP date after which the client may retry.
schema:
oneOf:
- type: integer
minimum: 0
- type: string
responses:
BadRequest:
description: Request is malformed or required organization context is missing.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
examples:
organizationContextRequired:
value:
type: https://api.example.com/problems/organization-context-required
title: Organization context required
status: 400
detail: X-Organization-Id is required for this operation.
code: ORGANIZATION_CONTEXT_REQUIRED
requestId: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff
Unauthorized:
description: Authentication is missing, invalid, expired, or revoked.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
examples:
invalidToken:
value:
type: https://api.example.com/problems/auth-token-invalid
title: Authentication failed
status: 401
detail: The access token is invalid.
code: AUTH_TOKEN_INVALID
requestId: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff
Forbidden:
description: The authenticated actor is not permitted to perform the operation.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
NotFound:
description: Resource not found, including cross-tenant resource access.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
examples:
notFound:
value:
type: https://api.example.com/problems/resource-not-found
title: Resource not found
status: 404
detail: The requested resource was not found.
code: RESOURCE_NOT_FOUND
requestId: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff
Conflict:
description: Conflict with an existing resource, state, idempotency record, or version.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
ValidationError:
description: Request is structurally valid but fails field or business validation.
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
examples:
invalidEmail:
value:
type: https://api.example.com/problems/validation-error
title: Request validation failed
status: 422
detail: One or more fields are invalid.
code: VALIDATION_ERROR
requestId: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff
errors:
- field: email
code: INVALID_FORMAT
message: Must be a valid email address.
PreconditionRequired:
description: "`If-Match` is required for this mutation."
headers:
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
RateLimited:
description: Request rate limit exceeded.
headers:
Retry-After:
$ref: '#/components/headers/RetryAfter'
X-Request-Id:
$ref: '#/components/headers/RequestId'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
schemas:
Uuid:
type: string
format: uuid
description: UUIDv7 serialized in canonical lowercase form.
examples: [0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d]
Timestamp:
type: string
format: date-time
examples: ['2026-08-26T12:00:00Z']
Date:
type: string
format: date
examples: ['2026-08-26']
Email:
type: string
format: email
maxLength: 320
CountryCode:
type: string
pattern: '^[A-Z]{2}$'
examples: [MA]
CurrencyCode:
type: string
pattern: '^[A-Z]{3}$'
examples: [MAD]
Profession:
type: string
enum: [engineering, legal, healthcare]
UserStatus:
type: string
enum: [active, inactive, pending_verification]
OrganizationStatus:
type: string
enum: [active, suspended, pending_deletion]
MembershipStatus:
type: string
enum: [active, inactive, pending]
InvitationStatus:
type: string
enum: [pending, accepted, revoked, expired]
description: Derived from invitation timestamps and expiry.
RoleStatus:
type: string
enum: [active, inactive]
description: Inactive roles retain assignments for history but grant no permissions and cannot be newly assigned.
EngineeringClientType:
type: string
enum: [corporate, government, individual]
EngineeringClientStatus:
type: string
enum: [active, archived]
EngineeringContactType:
type: string
enum: [technical, billing, executive, site, contract, other]
EngineeringContactStatus:
type: string
enum: [active, archived]
EngineeringProjectStatus:
type: string
enum: [draft, active, closed, archived]
EngineeringProjectRestorableStatus:
type: string
enum: [draft, closed]
EngineeringDiscipline:
type: string
enum:
- civil
- structural
- mechanical
- electrical
- geotechnical
- environmental
- transportation
- water_resources
- surveying
- multidisciplinary
- other
Problem:
type: object
additionalProperties: true
required: [type, title, status, code, requestId]
properties:
type:
type: string
format: uri-reference
title:
type: string
status:
type: integer
minimum: 400
maximum: 599
detail:
type: string
instance:
type: string
format: uri-reference
code:
type: string
pattern: '^[A-Z][A-Z0-9_]+$'
description: Stable machine-readable application error code.
requestId:
$ref: '#/components/schemas/Uuid'
errors:
type: array
items:
$ref: '#/components/schemas/FieldError'
FieldError:
type: object
additionalProperties: false
required: [field, code, message]
properties:
field:
type: string
code:
type: string
message:
type: string
PaginationMeta:
type: object
additionalProperties: false
required: [nextCursor, hasMore]
properties:
nextCursor:
type: [string, 'null']
hasMore:
type: boolean
CollectionMeta:
type: object
additionalProperties: false
required: [pagination]
properties:
pagination:
$ref: '#/components/schemas/PaginationMeta'
RegisterRequest:
type: object
additionalProperties: false
required: [email, password, firstName, lastName]
properties:
email:
$ref: '#/components/schemas/Email'
password:
type: string
minLength: 12
maxLength: 128
writeOnly: true
firstName:
type: string
minLength: 1
maxLength: 100
lastName:
type: string
minLength: 1
maxLength: 100
LoginRequest:
type: object
additionalProperties: false
required: [email, password]
properties:
email:
$ref: '#/components/schemas/Email'
password:
type: string
minLength: 1
maxLength: 128
writeOnly: true
RefreshTokenRequest:
type: object
additionalProperties: false
required: [refreshToken]
properties:
refreshToken:
type: string
minLength: 32
maxLength: 4096
writeOnly: true
TokenPair:
type: object
additionalProperties: false
required: [accessToken, refreshToken, tokenType, expiresIn, sessionId]
properties:
accessToken:
type: string
readOnly: true
refreshToken:
type: string
readOnly: true
tokenType:
type: string
const: Bearer
expiresIn:
type: integer
minimum: 1
description: Access-token lifetime in seconds.
sessionId:
$ref: '#/components/schemas/Uuid'
TokenPairResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/TokenPair'
User:
type: object
additionalProperties: false
required: [id, email, firstName, lastName, status, version, createdAt, updatedAt]
properties:
id:
$ref: '#/components/schemas/Uuid'
email:
$ref: '#/components/schemas/Email'
firstName:
type: string
lastName:
type: string
phone:
type: [string, 'null']
maxLength: 32
avatarUrl:
type: [string, 'null']
format: uri
status:
$ref: '#/components/schemas/UserStatus'
version:
type: integer
minimum: 1
createdAt:
$ref: '#/components/schemas/Timestamp'
updatedAt:
$ref: '#/components/schemas/Timestamp'
UserResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/User'
UpdateCurrentUserRequest:
type: object
additionalProperties: false
minProperties: 1
properties:
firstName:
type: string
minLength: 1
maxLength: 100
lastName:
type: string
minLength: 1
maxLength: 100
phone:
type: [string, 'null']
maxLength: 32
avatarUrl:
type: [string, 'null']
format: uri
Session:
type: object
additionalProperties: false
required: [id, current, createdAt, lastActiveAt, expiresAt]
properties:
id:
$ref: '#/components/schemas/Uuid'
current:
type: boolean
deviceName:
type: [string, 'null']
maxLength: 200
ipAddress:
type: [string, 'null']
description: Redacted or omitted according to privacy policy.
userAgent:
type: [string, 'null']
maxLength: 512
createdAt:
$ref: '#/components/schemas/Timestamp'
lastActiveAt:
$ref: '#/components/schemas/Timestamp'
expiresAt:
$ref: '#/components/schemas/Timestamp'
revokedAt:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
SessionCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Session'
meta:
$ref: '#/components/schemas/CollectionMeta'
Organization:
type: object
additionalProperties: false
required: [id, name, slug, status, countryCode, timezone, currencyCode, professions, version, createdAt, updatedAt]
properties:
id:
$ref: '#/components/schemas/Uuid'
name:
type: string
minLength: 1
maxLength: 200
slug:
type: string
pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
minLength: 3
maxLength: 80
status:
$ref: '#/components/schemas/OrganizationStatus'
countryCode:
$ref: '#/components/schemas/CountryCode'
timezone:
type: string
description: IANA time-zone identifier.
examples: [Africa/Casablanca]
currencyCode:
$ref: '#/components/schemas/CurrencyCode'
professions:
type: array
minItems: 1
uniqueItems: true
items:
$ref: '#/components/schemas/Profession'
version:
type: integer
minimum: 1
createdAt:
$ref: '#/components/schemas/Timestamp'
updatedAt:
$ref: '#/components/schemas/Timestamp'
CreateOrganizationRequest:
type: object
additionalProperties: false
required: [name, slug, countryCode, timezone, currencyCode, professions]
properties:
name:
type: string
minLength: 1
maxLength: 200
slug:
type: string
pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
minLength: 3
maxLength: 80
countryCode:
$ref: '#/components/schemas/CountryCode'
timezone:
type: string
minLength: 1
maxLength: 100
currencyCode:
$ref: '#/components/schemas/CurrencyCode'
professions:
type: array
minItems: 1
uniqueItems: true
items:
$ref: '#/components/schemas/Profession'
UpdateOrganizationRequest:
type: object
additionalProperties: false
minProperties: 1
description: Status and enabled professions change through separately authorized commands.
properties:
name:
type: string
minLength: 1
maxLength: 200
slug:
type: string
pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
minLength: 3
maxLength: 80
countryCode:
$ref: '#/components/schemas/CountryCode'
timezone:
type: string
minLength: 1
maxLength: 100
currencyCode:
$ref: '#/components/schemas/CurrencyCode'
OrganizationResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/Organization'
OrganizationCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Organization'
meta:
$ref: '#/components/schemas/CollectionMeta'
Invitation:
type: object
additionalProperties: false
required: [id, organizationId, email, roleIds, status, invitedByUserId, expiresAt, version, createdAt]
properties:
id:
$ref: '#/components/schemas/Uuid'
organizationId:
$ref: '#/components/schemas/Uuid'
email:
$ref: '#/components/schemas/Email'
roleIds:
type: array
minItems: 1
uniqueItems: true
items:
$ref: '#/components/schemas/Uuid'
status:
$ref: '#/components/schemas/InvitationStatus'
invitedByUserId:
$ref: '#/components/schemas/Uuid'
expiresAt:
$ref: '#/components/schemas/Timestamp'
acceptedAt:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
revokedAt:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
version:
type: integer
minimum: 1
createdAt:
$ref: '#/components/schemas/Timestamp'
CreateInvitationRequest:
type: object
additionalProperties: false
required: [email, roleIds]
properties:
email:
$ref: '#/components/schemas/Email'
roleIds:
type: array
minItems: 1
maxItems: 20
uniqueItems: true
items:
$ref: '#/components/schemas/Uuid'
expiresInDays:
type: integer
minimum: 1
maximum: 30
default: 7
AcceptInvitationRequest:
type: object
additionalProperties: false
required: [token]
properties:
token:
type: string
minLength: 32
maxLength: 4096
writeOnly: true
InvitationResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/Invitation'
InvitationCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Invitation'
meta:
$ref: '#/components/schemas/CollectionMeta'
Membership:
type: object
additionalProperties: false
required: [id, organizationId, user, status, roles, joinedAt, version, createdAt, updatedAt]
properties:
id:
$ref: '#/components/schemas/Uuid'
organizationId:
$ref: '#/components/schemas/Uuid'
user:
$ref: '#/components/schemas/UserSummary'
status:
$ref: '#/components/schemas/MembershipStatus'
roles:
type: array
items:
$ref: '#/components/schemas/RoleSummary'
joinedAt:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
version:
type: integer
minimum: 1
createdAt:
$ref: '#/components/schemas/Timestamp'
updatedAt:
$ref: '#/components/schemas/Timestamp'
UserSummary:
type: object
additionalProperties: false
required: [id, email, firstName, lastName]
properties:
id:
$ref: '#/components/schemas/Uuid'
email:
$ref: '#/components/schemas/Email'
firstName:
type: string
lastName:
type: string
MembershipResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/Membership'
MembershipCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Membership'
meta:
$ref: '#/components/schemas/CollectionMeta'
ReplaceMembershipRolesRequest:
type: object
additionalProperties: false
required: [roleIds]
properties:
roleIds:
type: array
minItems: 1
maxItems: 20
uniqueItems: true
items:
$ref: '#/components/schemas/Uuid'
ReasonRequest:
type: object
additionalProperties: false
properties:
reason:
type: string
maxLength: 500
Role:
type: object
additionalProperties: false
required: [id, organizationId, name, slug, description, status, isSystem, permissions, version, createdAt, updatedAt]
properties:
id:
$ref: '#/components/schemas/Uuid'
organizationId:
$ref: '#/components/schemas/Uuid'
name:
type: string
minLength: 1
maxLength: 100
slug:
type: string
pattern: '^[a-z0-9]+(?:_[a-z0-9]+)*$'
minLength: 2
maxLength: 100
description:
type: [string, 'null']
maxLength: 500
status:
$ref: '#/components/schemas/RoleStatus'
isSystem:
type: boolean
permissions:
type: array
uniqueItems: true
items:
$ref: '#/components/schemas/PermissionGrant'
version:
type: integer
minimum: 1
createdAt:
$ref: '#/components/schemas/Timestamp'
updatedAt:
$ref: '#/components/schemas/Timestamp'
RoleSummary:
type: object
additionalProperties: false
required: [id, name, slug, status, isSystem]
properties:
id:
$ref: '#/components/schemas/Uuid'
name:
type: string
slug:
type: string
status:
$ref: '#/components/schemas/RoleStatus'
isSystem:
type: boolean
CreateRoleRequest:
type: object
additionalProperties: false
required: [name, slug, permissions]
properties:
name:
type: string
minLength: 1
maxLength: 100
slug:
type: string
pattern: '^[a-z0-9]+(?:_[a-z0-9]+)*$'
minLength: 2
maxLength: 100
description:
type: [string, 'null']
maxLength: 500
permissions:
type: array
maxItems: 200
uniqueItems: true
items:
$ref: '#/components/schemas/PermissionGrant'
UpdateRoleRequest:
type: object
additionalProperties: false
minProperties: 1
properties:
name:
type: string
minLength: 1
maxLength: 100
description:
type: [string, 'null']
maxLength: 500
permissions:
type: array
maxItems: 200
uniqueItems: true
items:
$ref: '#/components/schemas/PermissionGrant'
RoleResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/Role'
RoleCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Role'
meta:
$ref: '#/components/schemas/CollectionMeta'
Permission:
type: object
additionalProperties: false
required: [id, code, name, scopeOptions]
properties:
id:
$ref: '#/components/schemas/Uuid'
code:
type: string
pattern: '^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$'
examples: [engineering.projects.create]
name:
type: string
description:
type: [string, 'null']
profession:
oneOf:
- $ref: '#/components/schemas/Profession'
- type: 'null'
scopeOptions:
type: array
minItems: 1
uniqueItems: true
items:
type: string
enum: [assigned, organization]
PermissionGrant:
type: object
additionalProperties: false
required: [permissionId, scope]
properties:
permissionId:
$ref: '#/components/schemas/Uuid'
scope:
type: string
enum: [assigned, organization]
description: The selected scope must be allowed by the referenced permission.
PermissionCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Permission'
meta:
$ref: '#/components/schemas/CollectionMeta'
EngineeringClient:
type: object
additionalProperties: false
required:
- id
- organizationId
- clientType
- displayName
- legalName
- status
- archivedAt
- archivedByUserId
- version
- createdAt
- updatedAt
properties:
id:
$ref: '#/components/schemas/Uuid'
organizationId:
$ref: '#/components/schemas/Uuid'
clientType:
$ref: '#/components/schemas/EngineeringClientType'
displayName:
type: string
minLength: 1
maxLength: 200
legalName:
type: [string, 'null']
minLength: 1
maxLength: 300
status:
$ref: '#/components/schemas/EngineeringClientStatus'
archivedAt:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
archivedByUserId:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
version:
type: integer
minimum: 1
createdAt:
$ref: '#/components/schemas/Timestamp'
updatedAt:
$ref: '#/components/schemas/Timestamp'
CreateEngineeringClientRequest:
type: object
additionalProperties: false
required: [clientType, displayName]
properties:
clientType:
$ref: '#/components/schemas/EngineeringClientType'
displayName:
type: string
minLength: 1
maxLength: 200
legalName:
type: [string, 'null']
minLength: 1
maxLength: 300
allOf:
- if:
properties:
clientType:
enum: [corporate, government]
required: [clientType]
then:
required: [legalName]
properties:
legalName:
type: string
minLength: 1
maxLength: 300
description: Corporate and government clients require a non-null legal name.
UpdateEngineeringClientRequest:
type: object
additionalProperties: false
minProperties: 1
properties:
clientType:
$ref: '#/components/schemas/EngineeringClientType'
displayName:
type: string
minLength: 1
maxLength: 200
legalName:
type: [string, 'null']
minLength: 1
maxLength: 300
description: The resulting corporate or government client must have a non-null legal name.
EngineeringClientResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/EngineeringClient'
EngineeringClientCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/EngineeringClient'
meta:
$ref: '#/components/schemas/CollectionMeta'
EngineeringClientContact:
type: object
additionalProperties: false
required:
- id
- organizationId
- clientId
- name
- title
- department
- email
- phone
- contactType
- isPrimary
- status
- archivedAt
- archivedByUserId
- version
- createdAt
- updatedAt
properties:
id:
$ref: '#/components/schemas/Uuid'
organizationId:
$ref: '#/components/schemas/Uuid'
clientId:
$ref: '#/components/schemas/Uuid'
name:
type: string
minLength: 1
maxLength: 200
title:
type: [string, 'null']
maxLength: 150
department:
type: [string, 'null']
maxLength: 150
email:
oneOf:
- $ref: '#/components/schemas/Email'
- type: 'null'
phone:
type: [string, 'null']
maxLength: 32
contactType:
$ref: '#/components/schemas/EngineeringContactType'
isPrimary:
type: boolean
status:
$ref: '#/components/schemas/EngineeringContactStatus'
archivedAt:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
archivedByUserId:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
version:
type: integer
minimum: 1
createdAt:
$ref: '#/components/schemas/Timestamp'
updatedAt:
$ref: '#/components/schemas/Timestamp'
allOf:
- if:
properties:
status:
const: archived
required: [status]
then:
properties:
isPrimary:
const: false
CreateEngineeringClientContactRequest:
type: object
additionalProperties: false
required: [name, contactType]
properties:
name:
type: string
minLength: 1
maxLength: 200
title:
type: [string, 'null']
maxLength: 150
department:
type: [string, 'null']
maxLength: 150
email:
$ref: '#/components/schemas/Email'
phone:
type: string
minLength: 3
maxLength: 32
contactType:
$ref: '#/components/schemas/EngineeringContactType'
isPrimary:
type: boolean
default: false
anyOf:
- required: [email]
- required: [phone]
description: At least one of email or phone is required.
UpdateEngineeringClientContactRequest:
type: object
additionalProperties: false
minProperties: 1
properties:
name:
type: string
minLength: 1
maxLength: 200
title:
type: [string, 'null']
maxLength: 150
department:
type: [string, 'null']
maxLength: 150
email:
oneOf:
- $ref: '#/components/schemas/Email'
- type: 'null'
phone:
type: [string, 'null']
minLength: 3
maxLength: 32
contactType:
$ref: '#/components/schemas/EngineeringContactType'
isPrimary:
type: boolean
description: The resulting contact must retain at least one of email or phone.
EngineeringClientContactResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/EngineeringClientContact'
EngineeringClientContactCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/EngineeringClientContact'
meta:
$ref: '#/components/schemas/CollectionMeta'
EngineeringProject:
type: object
additionalProperties: false
required:
- id
- organizationId
- clientId
- projectNumber
- name
- description
- discipline
- status
- projectManagerUserId
- startDate
- expectedCompletionDate
- completedDate
- archivedAt
- archivedByUserId
- archivedFromStatus
- version
- createdAt
- updatedAt
properties:
id:
$ref: '#/components/schemas/Uuid'
organizationId:
$ref: '#/components/schemas/Uuid'
clientId:
$ref: '#/components/schemas/Uuid'
projectNumber:
type: string
pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$'
minLength: 1
maxLength: 100
description: Immutable, organization-unique human project reference.
name:
type: string
minLength: 1
maxLength: 200
description:
type: [string, 'null']
maxLength: 5000
discipline:
$ref: '#/components/schemas/EngineeringDiscipline'
status:
$ref: '#/components/schemas/EngineeringProjectStatus'
projectManagerUserId:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
startDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
expectedCompletionDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
completedDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
archivedAt:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
archivedByUserId:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
archivedFromStatus:
oneOf:
- $ref: '#/components/schemas/EngineeringProjectRestorableStatus'
- type: 'null'
version:
type: integer
minimum: 1
createdAt:
$ref: '#/components/schemas/Timestamp'
updatedAt:
$ref: '#/components/schemas/Timestamp'
allOf:
- if:
properties:
status:
enum: [active, closed]
required: [status]
then:
properties:
projectManagerUserId:
$ref: '#/components/schemas/Uuid'
startDate:
$ref: '#/components/schemas/Date'
- if:
properties:
status:
const: closed
required: [status]
then:
properties:
completedDate:
$ref: '#/components/schemas/Date'
- if:
properties:
status:
enum: [draft, active]
required: [status]
then:
properties:
completedDate:
type: 'null'
- if:
properties:
status:
const: archived
required: [status]
then:
properties:
archivedAt:
$ref: '#/components/schemas/Timestamp'
archivedByUserId:
$ref: '#/components/schemas/Uuid'
archivedFromStatus:
$ref: '#/components/schemas/EngineeringProjectRestorableStatus'
else:
properties:
archivedAt:
type: 'null'
archivedByUserId:
type: 'null'
archivedFromStatus:
type: 'null'
- if:
properties:
status:
const: archived
archivedFromStatus:
const: closed
required: [status, archivedFromStatus]
then:
properties:
projectManagerUserId:
$ref: '#/components/schemas/Uuid'
startDate:
$ref: '#/components/schemas/Date'
completedDate:
$ref: '#/components/schemas/Date'
- if:
properties:
status:
const: archived
archivedFromStatus:
const: draft
required: [status, archivedFromStatus]
then:
properties:
completedDate:
type: 'null'
description: Expected and completed dates may not precede the start date.
CreateEngineeringProjectRequest:
type: object
additionalProperties: false
required: [clientId, projectNumber, name, discipline]
properties:
clientId:
$ref: '#/components/schemas/Uuid'
projectNumber:
type: string
pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$'
minLength: 1
maxLength: 100
name:
type: string
minLength: 1
maxLength: 200
description:
type: [string, 'null']
maxLength: 5000
discipline:
$ref: '#/components/schemas/EngineeringDiscipline'
projectManagerUserId:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
startDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
expectedCompletionDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
description: Expected completion date may not precede start date.
UpdateEngineeringProjectRequest:
type: object
additionalProperties: false
minProperties: 1
properties:
clientId:
$ref: '#/components/schemas/Uuid'
name:
type: string
minLength: 1
maxLength: 200
description:
type: [string, 'null']
maxLength: 5000
discipline:
$ref: '#/components/schemas/EngineeringDiscipline'
projectManagerUserId:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
startDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
expectedCompletionDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
description: The resulting dates and manager assignment must satisfy the project's current state rules.
ActivateEngineeringProjectRequest:
type: object
additionalProperties: false
properties:
startDate:
$ref: '#/components/schemas/Date'
CloseEngineeringProjectRequest:
type: object
additionalProperties: false
properties:
completedDate:
$ref: '#/components/schemas/Date'
reason:
type: string
maxLength: 500
EngineeringProjectResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/EngineeringProject'
EngineeringProjectCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/EngineeringProject'
meta:
$ref: '#/components/schemas/CollectionMeta'
EngineeringProjectSummary:
type: object
additionalProperties: false
required:
- id
- clientId
- projectNumber
- name
- discipline
- status
- projectManagerUserId
- startDate
- expectedCompletionDate
- completedDate
- version
- createdAt
- updatedAt
properties:
id:
$ref: '#/components/schemas/Uuid'
clientId:
$ref: '#/components/schemas/Uuid'
projectNumber:
type: string
minLength: 1
maxLength: 100
name:
type: string
minLength: 1
maxLength: 200
discipline:
$ref: '#/components/schemas/EngineeringDiscipline'
status:
$ref: '#/components/schemas/EngineeringProjectStatus'
projectManagerUserId:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
startDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
expectedCompletionDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
completedDate:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
version:
type: integer
minimum: 1
createdAt:
$ref: '#/components/schemas/Timestamp'
updatedAt:
$ref: '#/components/schemas/Timestamp'
EngineeringProjectSummaryCollectionResponse:
type: object
additionalProperties: false
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/EngineeringProjectSummary'
meta:
$ref: '#/components/schemas/CollectionMeta'
EngineeringClientSummary:
type: object
additionalProperties: false
required: [id, clientType, displayName, legalName, status]
properties:
id:
$ref: '#/components/schemas/Uuid'
clientType:
$ref: '#/components/schemas/EngineeringClientType'
displayName:
type: string
legalName:
type: [string, 'null']
status:
$ref: '#/components/schemas/EngineeringClientStatus'
EngineeringProjectActivitySummary:
type: object
additionalProperties: false
required:
- projectMemberCount
- phaseCount
- siteCount
- openTaskCount
- designCount
- designsUnderReviewCount
- inspectionCount
- upcomingInspectionCount
- documentCount
- lastActivityAt
properties:
projectMemberCount:
type: integer
minimum: 0
phaseCount:
type: integer
minimum: 0
siteCount:
type: integer
minimum: 0
openTaskCount:
type: integer
minimum: 0
designCount:
type: integer
minimum: 0
designsUnderReviewCount:
type: integer
minimum: 0
inspectionCount:
type: integer
minimum: 0
upcomingInspectionCount:
type: integer
minimum: 0
documentCount:
type: integer
minimum: 0
lastActivityAt:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
EngineeringProjectDashboard:
type: object
additionalProperties: false
required: [project, client, projectManager, activity]
properties:
project:
$ref: '#/components/schemas/EngineeringProject'
client:
$ref: '#/components/schemas/EngineeringClientSummary'
projectManager:
oneOf:
- $ref: '#/components/schemas/UserSummary'
- type: 'null'
activity:
$ref: '#/components/schemas/EngineeringProjectActivitySummary'
EngineeringProjectDashboardResponse:
type: object
additionalProperties: false
required: [data]
properties:
data:
$ref: '#/components/schemas/EngineeringProjectDashboard'
security:
- bearerAuth: []