diff --git a/openapi.yaml b/openapi.yaml index fb413817..cdae1631 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -102,12 +102,14 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) requestBody: @@ -115,17 +117,7 @@ paths: content: application/json: schema: - type: object - additionalProperties: true - properties: - to: - type: string - description: E.164 phone number of the callee - example: "+4511122233" - from: - type: string - description: E.164 phone number of the caller (sender) - example: "+4599988877" + $ref: '#/components/schemas/BirdVoiceCallCreateRequest' responses: '200': description: Call created @@ -142,19 +134,73 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) - in: query - name: page + name: limit schema: type: integer - required: false + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: status + schema: + type: string + - in: query + name: type + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: direction + schema: + type: string + - in: query + name: id + schema: + type: string + format: uuid + - in: query + name: tag + schema: + oneOf: + - type: string + - type: array + items: + type: string responses: '200': description: A list of calls @@ -162,6 +208,93 @@ paths: application/json: schema: { $ref: '#/components/schemas/BirdVoiceCallListResponse' } + /bird/voice/calls/log: + get: + tags: + - Bird + summary: List workspace call log entries + operationId: birdListVoiceCallsLog + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: channelId + schema: + oneOf: + - type: string + - type: array + items: + type: string + format: uuid + - in: query + name: status + schema: + type: string + - in: query + name: type + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: direction + schema: + type: string + - in: query + name: id + schema: + type: string + format: uuid + - in: query + name: tag + schema: + oneOf: + - type: string + - type: array + items: + type: string + responses: + '200': + description: Workspace call log entries + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallsLogResponse' } + /bird/voice/calls/{id}: get: tags: @@ -173,12 +306,14 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) - in: path @@ -186,12 +321,125 @@ paths: required: true schema: type: string + format: uuid responses: '200': description: Call details content: application/json: schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + patch: + tags: + - Bird + summary: Update a voice call by ID + operationId: birdUpdateVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallUpdateRequest' + responses: + '200': + description: Call update accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + + /bird/voice/calls/{id}/answer: + post: + tags: + - Bird + summary: Answer an incoming voice call by ID + operationId: birdAnswerVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallAnswerRequest' + responses: + '200': + description: Answer command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/ringing: + post: + tags: + - Bird + summary: Mark voice call as ringing by ID + operationId: birdRingingVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRingingRequest' + responses: + '200': + description: Ringing command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } /bird/voice/calls/{id}/hangup: post: @@ -204,90 +452,412 @@ paths: name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) - in: path name: id required: true schema: type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallHangupRequest' responses: '200': description: Hangup requested content: application/json: - schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/playback: + post: + tags: + - Bird + summary: Playback media on a voice call by ID + operationId: birdPlaybackVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallPlaybackRequest' + responses: + '200': + description: Playback command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } /bird/voice/calls/{id}/say: post: tags: - Bird - summary: Say a message on an active voice call and hang up afterwards + summary: Say a message on an active voice call and optionally hang up operationId: birdSayOnVoiceCall parameters: - in: query name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) - in: path name: id required: true schema: type: string - description: Call identifier + format: uuid requestBody: required: true content: application/json: schema: - type: object - required: - - text - properties: - text: - type: string - description: The text message to play via TTS - example: "The gate will open shortly." - locale: - type: string - description: The locale to use for the TTS voice (e.g. en-US) - example: "en-US" - voice: - type: string - description: The voice identifier to use - example: "male" - loop: - type: integer - description: Number of times to loop the message - example: 1 - timeout: - type: integer - description: Timeout in seconds for the TTS action - example: 1 - hangup: - type: boolean - description: Whether to hang up the call after the message finishes playing (defaults to true) - example: true + $ref: '#/components/schemas/BirdVoiceCallSayRequest' responses: '200': - description: TTS action requested + description: Say command accepted content: application/json: - schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/gather: + post: + tags: + - Bird + summary: Gather input from a voice call by ID + operationId: birdGatherVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallGatherRequest' + responses: + '200': + description: Gather command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/bridge: + post: + tags: + - Bird + summary: Bridge a voice call by ID + operationId: birdBridgeVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallBridgeRequest' + responses: + '200': + description: Bridge command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallBridgeResponse' } + + /bird/voice/calls/{id}/record: + post: + tags: + - Bird + summary: Record call audio by ID + operationId: birdRecordVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordRequest' + responses: + '200': + description: Record command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/recordings: + post: + tags: + - Bird + summary: Create a call recording session + operationId: birdCreateVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordingCreateRequest' + responses: + '200': + description: Recording session created + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + get: + tags: + - Bird + summary: List call recordings for a voice call + operationId: birdListVoiceCallRecordings + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + responses: + '200': + description: List of call recordings + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingListResponse' } + + /bird/voice/calls/{id}/recordings/{recordingId}: + get: + tags: + - Bird + summary: Get a single call recording + operationId: birdGetVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: recordingId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Call recording details + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + patch: + tags: + - Bird + summary: Update call recording state + operationId: birdUpdateVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: recordingId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordingUpdateRequest' + responses: + '200': + description: Recording update accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + + /bird/voice/calls/{id}/insights: + get: + tags: + - Bird + summary: Get voice call insights + operationId: birdGetVoiceCallInsights + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Voice call insights + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallInsightsResponse' } /bird/voice/calls/test-outbound: post: @@ -301,12 +871,14 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) requestBody: @@ -314,26 +886,7 @@ paths: content: application/json: schema: - type: object - additionalProperties: true - properties: - from: - type: string - description: Caller E.164 number to use for the test call - example: "+4599988877" - pollIntervalSeconds: - type: integer - minimum: 1 - description: Poll interval while waiting for accepted status - example: 2 - maxPollSeconds: - type: integer - minimum: 5 - description: Max time to wait before timing out - example: 30 - hangupCause: - type: string - description: Optional hangup cause passed through to Bird + $ref: '#/components/schemas/BirdTestOutboundCallRequest' responses: '200': description: Test call created and either hung up or timed out @@ -354,6 +907,7 @@ paths: required: false schema: type: string + format: uuid description: Bird Workspace identifier (optional if configured) - in: query name: page @@ -385,6 +939,7 @@ paths: required: false schema: type: string + format: uuid description: Bird Workspace identifier (optional if configured) - in: path name: id @@ -409,6 +964,7 @@ paths: required: false schema: type: string + format: uuid description: Bird Workspace identifier (optional if configured) - in: path name: id @@ -436,12 +992,14 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) requestBody: @@ -449,16 +1007,14 @@ paths: content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallCreateRequest' responses: '200': description: Flash call created content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallSingleResponse' get: tags: - Bird @@ -469,27 +1025,64 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) - in: query - name: page + name: limit schema: type: integer - required: false + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: status + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: id + schema: + type: string + format: uuid responses: '200': description: A list of flash calls content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallListResponse' /bird/voice/flash-calls/{id}: get: @@ -502,111 +1095,132 @@ paths: name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) - in: path name: id required: true schema: type: string + format: uuid responses: '200': description: Flash call details content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallSingleResponse' post: tags: - Bird summary: Complete/end a flash call by ID - description: Posts a completion/update payload to the flash call resource to finalize verification. operationId: birdEndFlashCall parameters: - in: query name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) - in: path name: id required: true schema: type: string + format: uuid requestBody: - required: false + required: true content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallEndRequest' responses: '200': description: Flash call completed content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallSingleResponse' + + /bird/voice/flash-calls/hangup: + post: + tags: + - Bird + summary: Hang up flash calls using payload criteria + operationId: birdHangupFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupRequest' + responses: + '200': + description: Flash call hangup accepted + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupResponse' /bird/voice/flash-calls/end: post: tags: - Bird - summary: Complete/end a flash call using from/to numbers - description: Ends an ongoing flash call by specifying the originating and destination numbers. + summary: Compatibility alias for flash hangup endpoint + description: Deprecated alias for `/bird/voice/flash-calls/hangup`. + deprecated: true operationId: birdEndFlashCallByNumbers parameters: - in: query name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) requestBody: required: true content: application/json: schema: - type: object - additionalProperties: true - properties: - from: - type: string - description: E.164 formatted caller number - example: "+4599988877" - to: - type: string - description: E.164 formatted callee number - example: "+4511122233" + $ref: '#/components/schemas/BirdFlashCallHangupRequest' responses: '200': - description: Flash call completed (by numbers) + description: Flash call hangup accepted (alias) content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallHangupResponse' # Subusers (public registration + setup) /subusers: @@ -12833,6 +13447,10 @@ components: type: string format: date-time nullable: true + wash_started_at: + type: string + format: date-time + nullable: true order_id: type: integer nullable: true @@ -14199,34 +14817,26 @@ components: properties: id: type: string + format: uuid example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + workspaceId: + type: string + format: uuid + example: "3d5fae4f-9c2d-41aa-9840-28b18e6a94bc" channelId: type: string + format: uuid example: "a2545e48-fe8c-5741-9bdc-42a081076bc9" + callFlowId: + type: string + format: uuid + nullable: true originator: type: object - properties: - number: - type: object - properties: - type: { type: string, example: "pstn" } - number: { type: string, example: "+4532330288" } - countryIsoCode: { type: string, example: "DK" } + additionalProperties: true receiver: type: object - properties: - contact: - type: object - properties: - id: { type: string, example: "18229adf-af8c-404a-b036-ae193c22e33c" } - identifierKey: { type: string, example: "phonenumber" } - identifierValue: { type: string, example: "+4542331128" } - number: - type: object - properties: - type: { type: string, example: "pstn" } - number: { type: string, example: "+4542331128" } - countryIsoCode: { type: string, example: "DK" } + additionalProperties: true from: type: string example: "+4532330288" @@ -14247,29 +14857,139 @@ components: example: 3 hangupCauseCode: type: integer - example: 16 + nullable: true hangupSource: type: string - example: "callee" + nullable: true sipInsights: type: object - properties: - hangupSipCode: { type: string, example: "200" } + additionalProperties: true qualityInsights: type: object - properties: - mos: { type: string, example: "4.50" } - pdd: { type: string, example: "2.15" } + additionalProperties: true price: type: object + additionalProperties: true + createdAt: { type: string, format: date-time, nullable: true } + updatedAt: { type: string, format: date-time, nullable: true } + ringingAt: { type: string, format: date-time, nullable: true } + answeredAt: { type: string, format: date-time, nullable: true } + endedAt: { type: string, format: date-time, nullable: true } + + BirdVoiceCallCommandCondition: + type: object + properties: + variable: { type: string } + operator: { type: string } + value: { type: string } + + BirdVoiceCallCommandResult: + type: object + properties: + id: + type: string + format: uuid + callId: + type: string + format: uuid + callFlowId: + type: string + format: uuid + nullable: true + status: + type: string + command: + type: string + conditions: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCallCommandCondition' + + BirdVoiceCallBridgeResult: + allOf: + - $ref: '#/components/schemas/BirdVoiceCallCommandResult' + - type: object properties: - currencyCode: { type: string, example: "EUR" } - amount: { type: string, example: "0.0079" } - createdAt: { type: string, format: date-time, example: "2026-02-27T13:45:36.216Z" } - updatedAt: { type: string, format: date-time, example: "2026-02-27T13:45:46.532Z" } - ringingAt: { type: string, format: date-time, example: "2026-02-27T13:45:38.367Z" } - answeredAt: { type: string, format: date-time, example: "2026-02-27T13:45:43.359Z" } - endedAt: { type: string, format: date-time, example: "2026-02-27T13:45:46.368Z" } + bridgeCallId: + type: string + format: uuid + nullable: true + + BirdVoiceCallRecording: + type: object + properties: + id: + type: string + format: uuid + callId: + type: string + format: uuid + status: + type: string + example: ongoing + duration: + type: integer + nullable: true + stereo: + type: boolean + nullable: true + mediaUrl: + type: string + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + + BirdVoiceCallInsights: + type: object + description: Voice call insights payload as returned by Bird. + additionalProperties: true + + BirdFlashCall: + type: object + properties: + id: + type: string + format: uuid + workspaceId: + type: string + format: uuid + nullable: true + channelId: + type: string + format: uuid + nullable: true + from: + type: string + nullable: true + to: + type: string + nullable: true + receivedCli: + type: string + nullable: true + result: + type: string + nullable: true + status: + type: string + nullable: true + duration: + type: integer + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true BirdVoiceCallListResponse: type: object @@ -14280,6 +15000,7 @@ components: properties: nextPageToken: type: string + nullable: true example: "WzE3NzIxNTY5NTI0MDUsIjk5ZDU4M2VkLTQyMzAtNDExNy1hOTQ0LTllY2JjNzhmYWJlMSJd" results: type: array @@ -14319,6 +15040,183 @@ components: additionalProperties: true example: [] + BirdVoiceCallCommandResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallCommandResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallBridgeResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallBridgeResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallRecordingListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCallRecording' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallRecordingSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallRecording' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallInsightsResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallInsights' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallsLogResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdTestOutboundCallRequest: + type: object + additionalProperties: false + properties: + from: + type: string + description: Caller E.164 number to use for the test call + example: "+4599988877" + to: + type: string + description: Target E.164 number. Defaults to configured test number if omitted. + example: "+4542331128" + timeout: + type: integer + minimum: 1 + description: Backward-compatible alias mapped to ringTimeout + ringTimeout: + type: integer + minimum: 3 + maximum: 120 + pollIntervalSeconds: + type: integer + minimum: 1 + description: Poll interval while waiting for accepted status + example: 2 + maxPollSeconds: + type: integer + minimum: 5 + description: Max time to wait before timing out + example: 30 + hangupCause: + type: string + enum: [rejected, busy] + description: Optional hangup cause passed through to Bird + BirdTestOutboundCallResponse: type: object properties: @@ -14333,7 +15231,296 @@ components: hangup_sent: { type: boolean, example: true } created_call: { $ref: '#/components/schemas/BirdVoiceCall' } last_call_snapshot: { $ref: '#/components/schemas/BirdVoiceCall' } - hangup_response: { $ref: '#/components/schemas/BirdVoiceCall' } + hangup_response: { $ref: '#/components/schemas/BirdVoiceCallCommandResult' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallCreateRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + maxDuration: { type: integer, minimum: 1 } + sendKeys: { type: string } + record: { type: boolean } + recordStart: { type: string, enum: [record-from-answer, record-from-ringing] } + flowStart: { type: string, enum: [from-answer, from-ringing] } + stereo: { type: boolean } + callFlow: + type: array + items: + type: object + additionalProperties: true + scheduledFor: { type: string, format: date-time } + notification: + type: object + additionalProperties: false + properties: + url: { type: string } + amdSettings: + type: object + additionalProperties: true + tags: + type: array + items: { type: string } + + BirdVoiceCallUpdateRequest: + type: object + additionalProperties: false + properties: + status: + type: string + enum: [completed] + callFlow: + type: array + items: + type: object + additionalProperties: true + + BirdVoiceCallAnswerRequest: + type: object + additionalProperties: false + properties: {} + + BirdVoiceCallRingingRequest: + type: object + additionalProperties: false + properties: {} + + BirdVoiceCallHangupRequest: + type: object + additionalProperties: false + properties: + cause: + type: string + enum: [rejected, busy] + + BirdVoiceCallPlaybackRequest: + type: object + additionalProperties: false + required: [media] + properties: + media: + type: array + minItems: 1 + items: { type: string } + loop: { type: integer, minimum: 0 } + timeout: { type: integer, minimum: 0 } + pauseMilliseconds: { type: integer, minimum: 0, maximum: 30000 } + + BirdVoiceCallSayRequest: + type: object + additionalProperties: false + required: [text] + properties: + text: { type: string } + locale: { type: string } + voice: { type: string } + loop: { type: integer, minimum: 0 } + timeout: { type: integer, minimum: 0 } + hangup: { type: boolean } + + BirdVoiceCallGatherRequest: + type: object + additionalProperties: false + properties: + maxNumKeys: { type: integer, minimum: 1 } + endKey: { type: string, enum: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] } + timeout: { type: integer, minimum: 0 } + retries: { type: integer, minimum: 0 } + input: { type: string, enum: [dtmf, speech, 'dtmf speech'] } + speechLocale: { type: string } + playback: + $ref: '#/components/schemas/BirdVoiceCallPlaybackRequest' + say: + $ref: '#/components/schemas/BirdVoiceCallSayRequest' + + BirdVoiceCallBridgeRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + maxDuration: { type: integer, minimum: 1 } + ringTone: { type: string } + hangupAfterBridge: { type: boolean } + record: { type: boolean } + recordStart: { type: string, enum: [record-from-answer, record-from-ringing] } + recordStereo: { type: boolean } + callFlow: + type: array + items: + type: object + additionalProperties: true + notification: + type: object + additionalProperties: false + properties: + url: { type: string } + amdSettings: + type: object + additionalProperties: true + + BirdVoiceCallRecordRequest: + type: object + additionalProperties: false + properties: + endKey: { type: string, enum: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] } + maxLength: { type: integer, minimum: 1 } + timeout: { type: integer, minimum: 0 } + beep: { type: boolean } + transcribe: { type: boolean } + transcribeLocale: { type: string } + + BirdVoiceCallRecordingCreateRequest: + type: object + additionalProperties: false + properties: + maxLength: { type: integer, minimum: 1 } + stereo: { type: boolean } + + BirdVoiceCallRecordingUpdateRequest: + type: object + additionalProperties: false + required: [status] + properties: + status: + type: string + enum: [paused, ongoing, completed] + + BirdFlashCallCreateRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + + BirdFlashCallEndRequest: + type: object + additionalProperties: false + required: [result] + properties: + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + + BirdFlashCallHangupRequest: + oneOf: + - type: object + additionalProperties: false + required: [result] + properties: + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + - type: object + additionalProperties: false + required: [from, to] + properties: + from: { type: string } + to: { type: string } + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + + BirdFlashCallListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdFlashCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdFlashCallSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdFlashCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdFlashCallHangupResult: + type: object + properties: + id: + type: string + format: uuid + nullable: true + result: + type: string + nullable: true + receivedCli: + type: string + nullable: true + from: + type: string + nullable: true + to: + type: string + nullable: true + + BirdFlashCallHangupResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdFlashCallHangupResult' meta: oneOf: - type: array @@ -14465,3 +15652,5 @@ components: - type: object additionalProperties: true example: [] + + diff --git a/services/nginx/app/classes/bird.php b/services/nginx/app/classes/bird.php index c37ecd95..15778078 100644 --- a/services/nginx/app/classes/bird.php +++ b/services/nginx/app/classes/bird.php @@ -2,12 +2,25 @@ namespace classes; +require_once WD . '/interfaces/bird_i.php'; +require_once WD . '/modules/bird/bird_c.php'; +require_once WD . '/modules/bird/classes/bird_api_client.php'; +require_once WD . '/modules/bird/classes/bird_voice_calls_client.php'; +require_once WD . '/modules/bird/classes/bird_voice_recordings_client.php'; +require_once WD . '/modules/bird/classes/bird_voice_insights_client.php'; +require_once WD . '/modules/bird/classes/bird_flash_calls_client.php'; + use bird\bird_c; +use bird\classes\bird_flash_calls_client; +use bird\classes\bird_voice_calls_client; +use bird\classes\bird_voice_insights_client; +use bird\classes\bird_voice_recordings_client; use Exception; +use interfaces\bird_i; use objects\logs_o; -class bird +class bird implements bird_i { public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28'; public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128'; @@ -28,11 +41,48 @@ class bird */ public bird_c $config; + private ?bird_voice_calls_client $voice_calls_client = null; + private ?bird_voice_recordings_client $voice_recordings_client = null; + private ?bird_voice_insights_client $voice_insights_client = null; + private ?bird_flash_calls_client $flash_calls_client = null; + public function __construct() { $this->config = new bird_c(); } + private function voiceCallsClient(): bird_voice_calls_client + { + if ($this->voice_calls_client === null) { + $this->voice_calls_client = new bird_voice_calls_client($this); + } + return $this->voice_calls_client; + } + + private function voiceRecordingsClient(): bird_voice_recordings_client + { + if ($this->voice_recordings_client === null) { + $this->voice_recordings_client = new bird_voice_recordings_client($this); + } + return $this->voice_recordings_client; + } + + private function voiceInsightsClient(): bird_voice_insights_client + { + if ($this->voice_insights_client === null) { + $this->voice_insights_client = new bird_voice_insights_client($this); + } + return $this->voice_insights_client; + } + + private function flashCallsClient(): bird_flash_calls_client + { + if ($this->flash_calls_client === null) { + $this->flash_calls_client = new bird_flash_calls_client($this); + } + return $this->flash_calls_client; + } + /** * Ensure module is enabled * @throws Exception @@ -279,30 +329,50 @@ class bird public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null { - $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId); - return $this->sendPostRequest($base, $payload); + return $this->voiceCallsClient()->createVoiceCall($workspaceId, $channelId, $payload); } public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null { - $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId); - return $this->sendGetRequest($base, $query); + return $this->voiceCallsClient()->listVoiceCalls($workspaceId, $channelId, $query); } public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null { - $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); - return $this->sendGetRequest($base . '/' . rawurlencode($callId)); + return $this->voiceCallsClient()->getVoiceCall($workspaceId, $channelId, $callId); + } + + public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->updateVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_ANSWER', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->answerVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RINGING', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->ringVoiceCall($workspaceId, $channelId, $callId, $payload); } public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null { - $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); - return $this->sendPostRequest($base . '/' . rawurlencode($callId) . '/hangup', $payload); + return $this->voiceCallsClient()->hangupVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_PLAYBACK', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->playbackVoiceCall($workspaceId, $channelId, $callId, $payload); } public function sayMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null @@ -330,7 +400,7 @@ class bird 'timeout' => 1, ...$payload, ]; - return $this->sendPostRequest($this->sayBase($workspaceId, $channelId, $callId), $tmp); + return $this->voiceCallsClient()->sayVoiceCall($workspaceId, $channelId, $callId, $tmp); } public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null @@ -383,7 +453,55 @@ class bird 'input' => 'dtmf', ...$payload, ]; - return $this->sendPostRequest($this->gatherBase($workspaceId, $channelId, $callId), $tmp); + return $this->voiceCallsClient()->gatherVoiceCall($workspaceId, $channelId, $callId, $tmp); + } + + public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_BRIDGE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->bridgeVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORD', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->recordVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function createVoiceCallRecordingSession(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceRecordingsClient()->createVoiceCallRecordingSession($workspaceId, $channelId, $callId, $payload); + } + + public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceRecordingsClient()->listVoiceCallRecordings($workspaceId, $channelId, $callId, $query); + } + + public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId); + return $this->voiceRecordingsClient()->getVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId); + } + + public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId); + return $this->voiceRecordingsClient()->updateVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId, $payload); + } + + public function getVoiceCallInsights(string $workspaceId, string $channelId, string $callId): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_INSIGHTS_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceInsightsClient()->getVoiceCallInsights($workspaceId, $channelId, $callId); + } + + public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_LOG_LIST', 'workspace=' . $workspaceId); + return $this->voiceInsightsClient()->getVoiceCallsLog($workspaceId, $query); } public function listNumbers(string $workspaceId, array $query = []): array|object|null @@ -406,16 +524,32 @@ class bird public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null { - $base = $this->flashBase($workspaceId, $channelId); $this->logBirdAction('BIRD_FLASH_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId); - return $this->sendPostRequest($base, $payload); + return $this->flashCallsClient()->createFlashCall($workspaceId, $channelId, $payload); + } + + public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null + { + $this->logBirdAction('BIRD_FLASH_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId); + return $this->flashCallsClient()->listFlashCalls($workspaceId, $channelId, $query); } public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null { - $base = $this->flashBase($workspaceId, $channelId); $this->logBirdAction('BIRD_FLASH_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); - return $this->sendGetRequest($base . '/' . rawurlencode($callId)); + return $this->flashCallsClient()->getFlashCall($workspaceId, $channelId, $callId); + } + + public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + $this->logBirdAction('BIRD_FLASH_CALL_END', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->flashCallsClient()->endFlashCall($workspaceId, $channelId, $callId, $payload); + } + + public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null + { + $this->logBirdAction('BIRD_FLASH_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId); + return $this->flashCallsClient()->hangupFlashCall($workspaceId, $channelId, $payload); } public function createOutboundTestCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options = []): array diff --git a/services/nginx/app/classes/selfserve_schema_bootstrap.php b/services/nginx/app/classes/selfserve_schema_bootstrap.php index db3af7f3..015e1109 100644 --- a/services/nginx/app/classes/selfserve_schema_bootstrap.php +++ b/services/nginx/app/classes/selfserve_schema_bootstrap.php @@ -63,6 +63,7 @@ class selfserve_schema_bootstrap machine_relay_enabled_at DATETIME NULL, machine_start_triggered TINYINT(1) NOT NULL DEFAULT 0, machine_start_triggered_at DATETIME NULL, + wash_started_at DATETIME NULL, order_id INT NULL, completed_at DATETIME NULL, metadata_json JSON NULL, @@ -149,6 +150,11 @@ class selfserve_schema_bootstrap 'dynamic_images_vehicle_type', 'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons' ); + self::ensureColumn( + 'selfserve_wash_sessions', + 'wash_started_at', + 'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at' + ); self::$initialized = true; } diff --git a/services/nginx/app/interfaces/bird_i.php b/services/nginx/app/interfaces/bird_i.php new file mode 100644 index 00000000..77d26b9d --- /dev/null +++ b/services/nginx/app/interfaces/bird_i.php @@ -0,0 +1,83 @@ +bird = $bird; + } + + protected function get(string $endpoint, array $query = []): array|object|null + { + return $this->bird->sendGetRequest($endpoint, $query); + } + + protected function post(string $endpoint, array $payload = []): array|object|null + { + return $this->bird->sendPostRequest($endpoint, $payload); + } + + protected function patch(string $endpoint, array $payload = []): array|object|null + { + return $this->bird->sendPatchRequest($endpoint, $payload); + } + + protected function delete(string $endpoint, array $query = []): array|object|null + { + return $this->bird->sendDeleteRequest($endpoint, $query); + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_flash_calls_client.php b/services/nginx/app/modules/bird/classes/bird_flash_calls_client.php new file mode 100644 index 00000000..7d43c009 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_flash_calls_client.php @@ -0,0 +1,36 @@ +post($this->birdFlashCallsBasePath($workspaceId, $channelId), $payload); + } + + public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null + { + return $this->get($this->birdFlashCallsBasePath($workspaceId, $channelId), $query); + } + + public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null + { + return $this->get($this->birdFlashCallPath($workspaceId, $channelId, $callId)); + } + + public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + return $this->post($this->birdFlashCallPath($workspaceId, $channelId, $callId), $payload); + } + + public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null + { + return $this->post($this->birdFlashCallsHangupPath($workspaceId, $channelId), $payload); + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_voice_calls_client.php b/services/nginx/app/modules/bird/classes/bird_voice_calls_client.php new file mode 100644 index 00000000..f2d93fa2 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_voice_calls_client.php @@ -0,0 +1,71 @@ +post($this->birdVoiceCallsBasePath($workspaceId, $channelId), $payload); + } + + public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null + { + return $this->get($this->birdVoiceCallsBasePath($workspaceId, $channelId), $query); + } + + public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null + { + return $this->get($this->birdVoiceCallPath($workspaceId, $channelId, $callId)); + } + + public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->patch($this->birdVoiceCallPath($workspaceId, $channelId, $callId), $payload); + } + + public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'answer'), $payload); + } + + public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'ringing'), $payload); + } + + public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'hangup'), $payload); + } + + public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'playback'), $payload); + } + + public function sayVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'say'), $payload); + } + + public function gatherVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'gather'), $payload); + } + + public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'bridge'), $payload); + } + + public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'record'), $payload); + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_voice_insights_client.php b/services/nginx/app/modules/bird/classes/bird_voice_insights_client.php new file mode 100644 index 00000000..dbc5632d --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_voice_insights_client.php @@ -0,0 +1,21 @@ +get($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'insights')); + } + + public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null + { + return $this->get($this->birdVoiceCallsLogPath($workspaceId), $query); + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_voice_recordings_client.php b/services/nginx/app/modules/bird/classes/bird_voice_recordings_client.php new file mode 100644 index 00000000..499efdbb --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_voice_recordings_client.php @@ -0,0 +1,31 @@ +post($this->birdVoiceCallRecordingsPath($workspaceId, $channelId, $callId), $payload); + } + + public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null + { + return $this->get($this->birdVoiceCallRecordingsPath($workspaceId, $channelId, $callId), $query); + } + + public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null + { + return $this->get($this->birdVoiceCallRecordingPath($workspaceId, $channelId, $callId, $recordingId)); + } + + public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null + { + return $this->patch($this->birdVoiceCallRecordingPath($workspaceId, $channelId, $callId, $recordingId), $payload); + } +} diff --git a/services/nginx/app/modules/bird/helpers/bird_request_schemas.php b/services/nginx/app/modules/bird/helpers/bird_request_schemas.php new file mode 100644 index 00000000..84f647df --- /dev/null +++ b/services/nginx/app/modules/bird/helpers/bird_request_schemas.php @@ -0,0 +1,404 @@ + 'object', + 'additionalProperties' => false, + 'properties' => [], + ]; + } + + public static function voiceCreateCallBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['to'], + 'properties' => [ + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'ringTimeout' => ['type' => 'integer', 'min' => 3, 'max' => 120], + 'maxDuration' => ['type' => 'integer', 'min' => 1], + 'sendKeys' => ['type' => 'string', 'maxLength' => 20, 'pattern' => '^[0-9*#]+$'], + 'record' => ['type' => 'boolean'], + 'recordStart' => ['type' => 'string', 'enum' => ['record-from-answer', 'record-from-ringing']], + 'flowStart' => ['type' => 'string', 'enum' => ['from-answer', 'from-ringing']], + 'stereo' => ['type' => 'boolean'], + 'callFlow' => self::callFlowCommands(), + 'scheduledFor' => ['type' => 'string', 'format' => 'date-time'], + 'notification' => self::notificationSchema(), + 'amdSettings' => self::amdSettingsSchema(), + 'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64]], + ], + ]; + } + + public static function voiceListCallsQuery(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000], + 'pageToken' => ['type' => 'string', 'maxLength' => 8000], + 'startAt' => ['type' => 'string', 'format' => 'date-time'], + 'endAt' => ['type' => 'string', 'format' => 'date-time'], + 'status' => ['type' => 'string', 'enum' => self::callStatuses()], + 'type' => ['type' => 'string', 'enum' => ['pstn', 'sip', 'webrtc']], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'duration' => ['type' => 'integer', 'min' => 0], + 'direction' => ['type' => 'string', 'enum' => ['incoming', 'outgoing']], + 'id' => ['type' => 'string', 'format' => 'uuid'], + 'tag' => ['type' => 'array', 'csv' => true, 'items' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64]], + ], + ]; + } + + public static function voiceUpdateCallBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'status' => ['type' => 'string', 'nullable' => true, 'enum' => ['completed']], + 'callFlow' => self::callFlowCommands(), + ], + ]; + } + + public static function voiceHangupBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'cause' => ['type' => 'string', 'enum' => ['rejected', 'busy']], + ], + ]; + } + + public static function voicePlaybackBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['media'], + 'properties' => [ + 'media' => ['type' => 'array', 'minItems' => 1, 'maxItems' => 80, 'items' => ['type' => 'string', 'minLength' => 1]], + 'loop' => ['type' => 'integer', 'min' => 0], + 'timeout' => ['type' => 'integer', 'min' => 0], + 'pauseMilliseconds' => ['type' => 'integer', 'min' => 0, 'max' => 30000], + ], + ]; + } + + public static function voiceSayBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['text'], + 'properties' => [ + 'text' => ['type' => 'string', 'minLength' => 1], + 'locale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20], + 'voice' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64], + 'loop' => ['type' => 'integer', 'min' => 0], + 'timeout' => ['type' => 'integer', 'min' => 0], + 'hangup' => ['type' => 'boolean'], + ], + ]; + } + + public static function voiceGatherBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'maxNumKeys' => ['type' => 'integer', 'min' => 1], + 'endKey' => ['type' => 'string', 'enum' => self::digitEnums()], + 'timeout' => ['type' => 'integer', 'min' => 0], + 'retries' => ['type' => 'integer', 'min' => 0], + 'input' => ['type' => 'string', 'enum' => ['dtmf', 'speech', 'dtmf speech']], + 'speechLocale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20], + 'playback' => self::voicePlaybackBody(), + 'say' => self::voiceSayBody(), + ], + ]; + } + + public static function voiceBridgeBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['to'], + 'properties' => [ + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'ringTimeout' => ['type' => 'integer', 'min' => 3, 'max' => 120], + 'maxDuration' => ['type' => 'integer', 'min' => 1], + 'ringTone' => ['type' => 'string', 'enum' => ['be', 'ca', 'cn', 'cy', 'cz', 'de', 'dk', 'dz', 'eg', 'fi', 'fr', 'hk', 'hu', 'il', 'in', 'jp', 'ko', 'pk', 'pl', 'ro', 'rs', 'ru', 'sa', 'tr', 'uk', 'us']], + 'hangupAfterBridge' => ['type' => 'boolean'], + 'record' => ['type' => 'boolean'], + 'recordStart' => ['type' => 'string', 'enum' => ['record-from-answer', 'record-from-ringing']], + 'recordStereo' => ['type' => 'boolean'], + 'callFlow' => self::callFlowCommands(), + 'notification' => self::notificationSchema(), + 'amdSettings' => self::amdSettingsSchema(), + ], + ]; + } + + public static function voiceRecordBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'endKey' => ['type' => 'string', 'enum' => self::digitEnums()], + 'maxLength' => ['type' => 'integer', 'min' => 1], + 'timeout' => ['type' => 'integer', 'min' => 0], + 'beep' => ['type' => 'boolean'], + 'transcribe' => ['type' => 'boolean'], + 'transcribeLocale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20], + ], + ]; + } + + public static function voiceRecordingsCreateBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'maxLength' => ['type' => 'integer', 'min' => 1], + 'stereo' => ['type' => 'boolean'], + ], + ]; + } + + public static function voiceRecordingsListQuery(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000], + 'pageToken' => ['type' => 'string', 'maxLength' => 8000], + ], + ]; + } + + public static function voiceRecordingUpdateBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['status'], + 'properties' => [ + 'status' => ['type' => 'string', 'enum' => ['paused', 'ongoing', 'completed']], + ], + ]; + } + + public static function voiceCallsLogQuery(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000], + 'pageToken' => ['type' => 'string', 'maxLength' => 8000], + 'startAt' => ['type' => 'string', 'format' => 'date-time'], + 'endAt' => ['type' => 'string', 'format' => 'date-time'], + 'channelId' => ['type' => 'array', 'csv' => true, 'items' => ['type' => 'string', 'format' => 'uuid']], + 'status' => ['type' => 'string', 'enum' => self::callStatuses()], + 'type' => ['type' => 'string', 'enum' => ['pstn', 'sip', 'webrtc']], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'duration' => ['type' => 'integer', 'min' => 0], + 'direction' => ['type' => 'string', 'enum' => ['incoming', 'outgoing']], + 'id' => ['type' => 'string', 'format' => 'uuid'], + 'tag' => ['type' => 'array', 'csv' => true, 'items' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64]], + ], + ]; + } + + public static function voiceTestOutboundBody(): array + { + $create = self::voiceCreateCallBody(); + $properties = isset($create['properties']) && is_array($create['properties']) ? $create['properties'] : []; + + $properties['timeout'] = ['type' => 'integer', 'min' => 1]; + $properties['pollIntervalSeconds'] = ['type' => 'integer', 'min' => 1]; + $properties['maxPollSeconds'] = ['type' => 'integer', 'min' => 5]; + $properties['hangupCause'] = ['type' => 'string', 'enum' => ['rejected', 'busy']]; + + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $properties, + ]; + } + + public static function flashCreateBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['to'], + 'properties' => [ + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'ringTimeout' => ['type' => 'integer', 'min' => 3, 'max' => 120], + ], + ]; + } + + public static function flashListQuery(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000], + 'pageToken' => ['type' => 'string', 'maxLength' => 8000], + 'startAt' => ['type' => 'string', 'format' => 'date-time'], + 'endAt' => ['type' => 'string', 'format' => 'date-time'], + 'status' => ['type' => 'string', 'enum' => self::callStatuses()], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'duration' => ['type' => 'integer', 'min' => 0], + 'id' => ['type' => 'string', 'format' => 'uuid'], + ], + ]; + } + + public static function flashEndBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['result'], + 'properties' => [ + 'receivedCli' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'result' => ['type' => 'string', 'enum' => ['unknown', 'verified', 'canceled', 'timeout', 'wrong_cli']], + ], + ]; + } + + public static function flashHangupBody(): array + { + return [ + 'oneOf' => [ + [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['result'], + 'properties' => [ + 'receivedCli' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'result' => ['type' => 'string', 'enum' => ['unknown', 'verified', 'canceled', 'timeout', 'wrong_cli']], + ], + ], + [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['from', 'to'], + 'properties' => [ + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'receivedCli' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'result' => ['type' => 'string', 'enum' => ['unknown', 'verified', 'canceled', 'timeout', 'wrong_cli']], + ], + ], + ], + ]; + } + + private static function callFlowCommands(): array + { + return [ + 'type' => 'array', + 'maxItems' => 20, + 'items' => self::callFlowCommandSchema(), + ]; + } + + private static function callFlowCommandSchema(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['command'], + 'properties' => [ + 'command' => [ + 'type' => 'string', + 'enum' => ['answer', 'hangup', 'playback', 'say', 'gather', 'record', 'bridge', 'pause', 'ringing'], + ], + 'conditions' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['variable', 'operator', 'value'], + 'properties' => [ + 'variable' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64], + 'operator' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 16], + 'value' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 255], + ], + ], + ], + 'options' => [ + 'type' => 'object', + 'additionalProperties' => true, + 'properties' => [], + ], + ], + ]; + } + + private static function notificationSchema(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['url'], + 'properties' => [ + 'url' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 2048], + ], + ]; + } + + private static function amdSettingsSchema(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'enabled' => ['type' => 'boolean'], + 'wordCount' => ['type' => 'integer', 'min' => 1], + 'speechTimeout' => ['type' => 'integer', 'min' => 1], + 'speechLocale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20], + 'beepTimeout' => ['type' => 'integer', 'min' => 1], + 'ifMachineNotifyAfter' => ['type' => 'string', 'enum' => ['wordCount', 'beep']], + ], + ]; + } + + private static function callStatuses(): array + { + return ['accepted', 'starting', 'ringing', 'ongoing', 'completed', 'no-answer', 'busy', 'failed', 'cancelled', 'scheduled']; + } + + private static function digitEnums(): array + { + return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#']; + } +} diff --git a/services/nginx/app/modules/bird/helpers/bird_request_validator.php b/services/nginx/app/modules/bird/helpers/bird_request_validator.php new file mode 100644 index 00000000..399231e2 --- /dev/null +++ b/services/nginx/app/modules/bird/helpers/bird_request_validator.php @@ -0,0 +1,272 @@ + self::validateObject($value, $schema, $path, $errors), + 'array' => self::validateArray($value, $schema, $path, $errors), + 'string' => self::validateString($value, $schema, $path, $errors), + 'int', 'integer' => self::validateInteger($value, $schema, $path, $errors), + 'number' => self::validateNumber($value, $schema, $path, $errors), + 'bool', 'boolean' => self::validateBoolean($value, $schema, $path, $errors), + default => $errors[] = $path . ' has unsupported schema type "' . $type . '"', + }; + } + + if (isset($schema['enum']) && is_array($schema['enum'])) { + self::validateEnum($value, $schema['enum'], $path, $errors); + } + } + + private static function validateOneOf(mixed $value, array $schemas, string $path, array &$errors): void + { + $bestErrors = null; + foreach ($schemas as $schema) { + if (!is_array($schema)) { + continue; + } + $tmp = []; + self::validateValue($value, $schema, $path, $tmp); + if ($tmp === []) { + return; + } + if ($bestErrors === null || count($tmp) < count($bestErrors)) { + $bestErrors = $tmp; + } + } + + $errors[] = $path . ' does not match any of the allowed schemas'; + if (is_array($bestErrors)) { + foreach ($bestErrors as $err) { + $errors[] = $err; + } + } + } + + private static function validateObject(mixed $value, array $schema, string $path, array &$errors): void + { + if (!is_array($value) || !self::isAssoc($value)) { + $errors[] = $path . ' must be an object'; + return; + } + + $required = isset($schema['required']) && is_array($schema['required']) ? $schema['required'] : []; + foreach ($required as $requiredKey) { + if (!array_key_exists((string)$requiredKey, $value)) { + $errors[] = $path . '.' . $requiredKey . ' is required'; + } + } + + $properties = isset($schema['properties']) && is_array($schema['properties']) ? $schema['properties'] : []; + $allowAdditional = (bool)($schema['additionalProperties'] ?? true); + + if (!$allowAdditional) { + foreach ($value as $key => $_unused) { + if (!array_key_exists((string)$key, $properties)) { + $errors[] = $path . '.' . $key . ' is not allowed'; + } + } + } + + foreach ($properties as $key => $propertySchema) { + if (!array_key_exists((string)$key, $value)) { + continue; + } + if (!is_array($propertySchema)) { + continue; + } + self::validateValue($value[(string)$key], $propertySchema, $path . '.' . $key, $errors); + } + } + + private static function validateArray(mixed $value, array $schema, string $path, array &$errors): void + { + $arrayValue = $value; + if (is_string($arrayValue) && ($schema['csv'] ?? false)) { + $parts = array_map('trim', explode(',', $arrayValue)); + $arrayValue = array_values(array_filter($parts, static fn($part) => $part !== '')); + } + + if (!is_array($arrayValue) || self::isAssoc($arrayValue)) { + $errors[] = $path . ' must be an array'; + return; + } + + $count = count($arrayValue); + if (isset($schema['minItems']) && is_numeric($schema['minItems']) && $count < (int)$schema['minItems']) { + $errors[] = $path . ' must have at least ' . (int)$schema['minItems'] . ' items'; + } + if (isset($schema['maxItems']) && is_numeric($schema['maxItems']) && $count > (int)$schema['maxItems']) { + $errors[] = $path . ' must have at most ' . (int)$schema['maxItems'] . ' items'; + } + + $itemSchema = isset($schema['items']) && is_array($schema['items']) ? $schema['items'] : null; + if ($itemSchema === null) { + return; + } + + foreach ($arrayValue as $index => $item) { + self::validateValue($item, $itemSchema, $path . '[' . $index . ']', $errors); + } + } + + private static function validateString(mixed $value, array $schema, string $path, array &$errors): void + { + if (!is_string($value)) { + $errors[] = $path . ' must be a string'; + return; + } + + $length = strlen($value); + if (isset($schema['minLength']) && is_numeric($schema['minLength']) && $length < (int)$schema['minLength']) { + $errors[] = $path . ' must be at least ' . (int)$schema['minLength'] . ' characters'; + } + if (isset($schema['maxLength']) && is_numeric($schema['maxLength']) && $length > (int)$schema['maxLength']) { + $errors[] = $path . ' must be at most ' . (int)$schema['maxLength'] . ' characters'; + } + + if (isset($schema['pattern']) && is_string($schema['pattern']) && @preg_match('/' . $schema['pattern'] . '/', $value) !== 1) { + $errors[] = $path . ' has invalid format'; + } + + if (isset($schema['format']) && is_string($schema['format'])) { + if ($schema['format'] === 'uuid' && !self::isUuid($value)) { + $errors[] = $path . ' must be a UUID'; + } + if ($schema['format'] === 'date-time' && strtotime($value) === false) { + $errors[] = $path . ' must be a valid date-time'; + } + } + } + + private static function validateInteger(mixed $value, array $schema, string $path, array &$errors): void + { + $intValue = self::toInt($value); + if ($intValue === null) { + $errors[] = $path . ' must be an integer'; + return; + } + + if (isset($schema['min']) && is_numeric($schema['min']) && $intValue < (int)$schema['min']) { + $errors[] = $path . ' must be at least ' . (int)$schema['min']; + } + if (isset($schema['max']) && is_numeric($schema['max']) && $intValue > (int)$schema['max']) { + $errors[] = $path . ' must be at most ' . (int)$schema['max']; + } + } + + private static function validateNumber(mixed $value, array $schema, string $path, array &$errors): void + { + $numberValue = self::toFloat($value); + if ($numberValue === null) { + $errors[] = $path . ' must be a number'; + return; + } + + if (isset($schema['min']) && is_numeric($schema['min']) && $numberValue < (float)$schema['min']) { + $errors[] = $path . ' must be at least ' . (float)$schema['min']; + } + if (isset($schema['max']) && is_numeric($schema['max']) && $numberValue > (float)$schema['max']) { + $errors[] = $path . ' must be at most ' . (float)$schema['max']; + } + } + + private static function validateBoolean(mixed $value, array $schema, string $path, array &$errors): void + { + if (self::toBool($value) === null) { + $errors[] = $path . ' must be a boolean'; + } + } + + private static function validateEnum(mixed $value, array $allowedValues, string $path, array &$errors): void + { + if (in_array($value, $allowedValues, true)) { + return; + } + + if (is_scalar($value)) { + foreach ($allowedValues as $allowed) { + if (is_scalar($allowed) && (string)$allowed === (string)$value) { + return; + } + } + } + + $errors[] = $path . ' must be one of: ' . implode(', ', array_map(static fn($item) => (string)$item, $allowedValues)); + } + + private static function toInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_string($value) && preg_match('/^-?[0-9]+$/', $value) === 1) { + return (int)$value; + } + return null; + } + + private static function toFloat(mixed $value): ?float + { + if (is_int($value) || is_float($value)) { + return (float)$value; + } + if (is_string($value) && is_numeric($value)) { + return (float)$value; + } + return null; + } + + private static function toBool(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) && ($value === 0 || $value === 1)) { + return $value === 1; + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if ($normalized === 'true' || $normalized === '1') { + return true; + } + if ($normalized === 'false' || $normalized === '0') { + return false; + } + } + return null; + } + + private static function isAssoc(array $array): bool + { + return array_keys($array) !== range(0, count($array) - 1); + } +} + diff --git a/services/nginx/app/modules/bird/interfaces/bird_flash_calls_client_i.php b/services/nginx/app/modules/bird/interfaces/bird_flash_calls_client_i.php new file mode 100644 index 00000000..ccd2ad45 --- /dev/null +++ b/services/nginx/app/modules/bird/interfaces/bird_flash_calls_client_i.php @@ -0,0 +1,17 @@ +birdEncodePathSegment($workspaceId) + . '/channels/' + . $this->birdEncodePathSegment($channelId) + . '/calls'; + } + + protected function birdVoiceCallPath(string $workspaceId, string $channelId, string $callId): string + { + return $this->birdVoiceCallsBasePath($workspaceId, $channelId) + . '/' + . $this->birdEncodePathSegment($callId); + } + + protected function birdVoiceCallCommandPath(string $workspaceId, string $channelId, string $callId, string $command): string + { + return $this->birdVoiceCallPath($workspaceId, $channelId, $callId) + . '/' + . trim($command, '/'); + } + + protected function birdVoiceCallRecordingsPath(string $workspaceId, string $channelId, string $callId): string + { + return $this->birdVoiceCallPath($workspaceId, $channelId, $callId) . '/recordings'; + } + + protected function birdVoiceCallRecordingPath(string $workspaceId, string $channelId, string $callId, string $recordingId): string + { + return $this->birdVoiceCallRecordingsPath($workspaceId, $channelId, $callId) + . '/' + . $this->birdEncodePathSegment($recordingId); + } + + protected function birdVoiceCallsLogPath(string $workspaceId): string + { + return '/workspaces/' . $this->birdEncodePathSegment($workspaceId) . '/channels/calls'; + } + + protected function birdFlashCallsBasePath(string $workspaceId, string $channelId): string + { + return '/workspaces/' + . $this->birdEncodePathSegment($workspaceId) + . '/channels/' + . $this->birdEncodePathSegment($channelId) + . '/flashcalls'; + } + + protected function birdFlashCallPath(string $workspaceId, string $channelId, string $callId): string + { + return $this->birdFlashCallsBasePath($workspaceId, $channelId) + . '/' + . $this->birdEncodePathSegment($callId); + } + + protected function birdFlashCallsHangupPath(string $workspaceId, string $channelId): string + { + return $this->birdFlashCallsBasePath($workspaceId, $channelId) . '/hangup'; + } +} + diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php index 35597e65..f07af6b7 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -147,9 +147,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i if ((int)$lane->getWashStartTime() <= 0) { $lane->setWashStartTime(time()); } + $washStartedAt = (int)$lane->getWashStartTime(); $this->enableCleanerRelayForStartedWash($lane); - $session->markMachineStartTriggered(); + $session->markMachineStartTriggered( + $washStartedAt > 0 ? date('Y-m-d H:i:s', $washStartedAt) : null + ); $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_START_TRIGGERED, $payload + [ 'lane_id' => $laneId, 'reg' => $effectiveReg, diff --git a/services/nginx/app/objects/selfserve_wash_sessions_o.php b/services/nginx/app/objects/selfserve_wash_sessions_o.php index fb68f152..e0192cd0 100644 --- a/services/nginx/app/objects/selfserve_wash_sessions_o.php +++ b/services/nginx/app/objects/selfserve_wash_sessions_o.php @@ -6,6 +6,7 @@ use classes\db; use classes\object_property; use classes\selfserve; use classes\selfserve_schema_bootstrap; +use DateTime; use modules\selfserve\helpers\selfserve_wash_session_status; use traits\db_object_t; @@ -26,6 +27,7 @@ class selfserve_wash_sessions_o extends db public object_property $machine_relay_enabled_at; public object_property $machine_start_triggered; public object_property $machine_start_triggered_at; + public object_property $wash_started_at; public object_property $order_id; public object_property $completed_at; public object_property $metadata_json; @@ -84,6 +86,7 @@ class selfserve_wash_sessions_o extends db $this->machine_relay_enabled_at = new object_property($this->table, $this->id, 'machine_relay_enabled_at', 'datetime', false); $this->machine_start_triggered = new object_property($this->table, $this->id, 'machine_start_triggered', 'bool', false); $this->machine_start_triggered_at = new object_property($this->table, $this->id, 'machine_start_triggered_at', 'datetime', false); + $this->wash_started_at = new object_property($this->table, $this->id, 'wash_started_at', 'datetime', false); $this->order_id = new object_property($this->table, $this->id, 'order_id', 'int', false); $this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'datetime', false); $this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false); @@ -116,11 +119,13 @@ class selfserve_wash_sessions_o extends db $this->machine_relay_enabled_at->set(null); } - public function markMachineStartTriggered(): void + public function markMachineStartTriggered(?string $washStartedAt = null): void { $now = date('Y-m-d H:i:s'); + $resolvedWashStartedAt = $washStartedAt ?? $now; $this->machine_start_triggered->set(true); $this->machine_start_triggered_at->set($now); + $this->wash_started_at->set($resolvedWashStartedAt); if ((bool)$this->machine_relay_enabled->value() !== true) { $this->machine_relay_enabled->set(true); $this->machine_relay_enabled_at->set($now); @@ -189,6 +194,7 @@ class selfserve_wash_sessions_o extends db 'machine_relay_enabled_at' => $this->machine_relay_enabled_at->value() === null ? null : (string)$this->machine_relay_enabled_at->value(), 'machine_start_triggered' => (bool)$this->machine_start_triggered->value(), 'machine_start_triggered_at' => $this->machine_start_triggered_at->value() === null ? null : (string)$this->machine_start_triggered_at->value(), + 'wash_started_at' => $this->wash_started_at->value() === null ? null : (string)$this->wash_started_at->value(), 'order_id' => $this->order_id->value() === null ? null : (int)$this->order_id->value(), 'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(), 'metadata' => (array)($this->metadata_json->value() ?? []), @@ -196,4 +202,26 @@ class selfserve_wash_sessions_o extends db 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), ]; } + + public function getElapsedMinutes(): int + { + $startAt = $this->wash_started_at->value() ?? $this->machine_start_triggered_at->value(); + if ($startAt === null || trim((string)$startAt) === '') { + return 0; + } + + try { + $start = new DateTime((string)$startAt); + $now = new DateTime(); + } catch (\Throwable) { + return 0; + } + + if ($start > $now) { + return 0; + } + + $diff = $start->diff($now); + return (int)(($diff->days * 24 * 60) + ($diff->h * 60) + $diff->i); + } } diff --git a/services/nginx/app/routes/birdVoiceCallsRoute.php b/services/nginx/app/routes/birdVoiceCallsRoute.php index 6f8e536b..ec209a47 100644 --- a/services/nginx/app/routes/birdVoiceCallsRoute.php +++ b/services/nginx/app/routes/birdVoiceCallsRoute.php @@ -2,158 +2,199 @@ namespace routes; +require_once WD . '/classes/bird.php'; +require_once WD . '/traits/route_t.php'; +require_once WD . '/traits/bird_route_helpers_t.php'; +require_once WD . '/traits/bird_route_validation_t.php'; +require_once WD . '/modules/bird/helpers/bird_request_schemas.php'; + +use bird\helpers\bird_request_schemas; use classes\bird; use traits\bird_route_helpers_t; +use traits\bird_route_validation_t; use traits\route_t; class birdVoiceCallsRoute { - use route_t, bird_route_helpers_t; + use route_t, bird_route_helpers_t, bird_route_validation_t; public function run(): void { + // List workspace call log + $this->get('/bird/voice/calls/log', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_log_list'); + + $client = new bird(); + $workspaceId = $this->birdResolveWorkspaceId($client); + + $query = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $query = $this->birdNormalizeCsvField($query, 'channelId'); + $query = $this->birdNormalizeCsvField($query, 'tag'); + $query = $this->birdValidateSchema($query, bird_request_schemas::voiceCallsLogQuery()); + + $res = $client->getVoiceCallsLog($workspaceId, $query); + $response->success($res ?? []); + }, [ + 'modules_bird_voice_calls_log_list' => 'List voice call log entries via Bird', + ]); + // Create a voice call $this->post('/bird/voice/calls', function () { global $response; - // Permission: create/place a voice call via Bird self::requirePermission('modules_bird_voice_calls_create'); + $client = new bird(); - // Require workspace/channel per Bird API docs - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - $res = $client->createVoiceCall($ws, $ch, $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceCreateCallBody()); + + $res = $client->createVoiceCall($workspaceId, $channelId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_calls_create' => 'Create/place a voice call via Bird', ]); - // List voice calls (passthrough of query filters) + // List voice calls $this->get('/bird/voice/calls', function () { global $response; - // Permission: list voice calls via Bird self::requirePermission('modules_bird_voice_calls_list'); + $client = new bird(); - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $query = $this->getParametersAsArray(); - unset($query['workspaceId'], $query['channelId']); - $res = $client->listVoiceCalls($ws, $ch, $query); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $query = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $query = $this->birdNormalizeCsvField($query, 'tag'); + $query = $this->birdValidateSchema($query, bird_request_schemas::voiceListCallsQuery()); + + $res = $client->listVoiceCalls($workspaceId, $channelId, $query); $response->success($res ?? []); }, [ 'modules_bird_voice_calls_list' => 'List voice calls via Bird', ]); - // Get a specific call by ID + // Get a voice call by ID $this->get('/bird/voice/calls/{id}', function () { global $response; - // Permission: get a specific voice call via Bird self::requirePermission('modules_bird_voice_calls_get'); + $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $res = $client->getVoiceCall($ws, $ch, $id); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $res = $client->getVoiceCall($workspaceId, $channelId, $callId); $response->success($res ?? []); }, [ 'modules_bird_voice_calls_get' => 'Get a voice call by ID via Bird', ]); + // Update a voice call by ID + $this->patch('/bird/voice/calls/{id}', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_update'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceUpdateCallBody()); + + $res = $client->updateVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_update' => 'Update a voice call by ID via Bird', + ]); + + // Answer an incoming call by ID + $this->post('/bird/voice/calls/{id}/answer', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_answer'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::noBody()); + + $res = $client->answerVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_answer' => 'Answer a voice call by ID via Bird', + ]); + + // Mark call as ringing by ID + $this->post('/bird/voice/calls/{id}/ringing', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_ringing'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::noBody()); + + $res = $client->ringVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_ringing' => 'Mark a voice call as ringing via Bird', + ]); + // Hang up an active call by ID $this->post('/bird/voice/calls/{id}/hangup', function () { global $response; - // Permission: hang up a specific voice call via Bird self::requirePermission('modules_bird_voice_calls_hangup'); + $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $payload = []; - $cause = $this->normalizeOptionalString($this->fromRequest('cause') ?? $this->fromQuery('cause')); - if ($cause !== '') { - $payload['cause'] = $cause; - } - $res = $client->hangupVoiceCall($ws, $ch, $id, $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceHangupBody()); + + $res = $client->hangupVoiceCall($workspaceId, $channelId, $callId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_calls_hangup' => 'Hang up a voice call by ID via Bird', ]); - // Say a message on an active call and hang up afterwards + // Playback media on an active call by ID + $this->post('/bird/voice/calls/{id}/playback', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_playback'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voicePlaybackBody()); + + $res = $client->playbackVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_playback' => 'Playback media on a voice call by ID via Bird', + ]); + + // Say a message on an active call $this->post('/bird/voice/calls/{id}/say', function () { global $response; - // Permission: say a message on a voice call via Bird self::requirePermission('modules_bird_voice_calls_say'); - $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - // Bird's /say REST API endpoint plays the message. - // To fulfill the requirement of hanging up afterwards, we should ensure the call is terminated. - // Many Bird actions support a 'hangup' field in the payload to terminate the call after the action is complete. - if (!isset($payload['hangup'])) { + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceSayBody()); + if (!array_key_exists('hangup', $payload)) { $payload['hangup'] = true; } - $res = $client->sayMessage($ws, $ch, $id, $payload); + $res = $client->sayMessage($workspaceId, $channelId, $callId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_calls_say' => 'Say a message on a voice call by ID via Bird', @@ -162,56 +203,156 @@ class birdVoiceCallsRoute // Gather input on an active call $this->post('/bird/voice/calls/{id}/gather', function () { global $response; - // Permission: gather input on a voice call via Bird self::requirePermission('modules_bird_voice_calls_gather'); - $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - $res = $client->gatherMessage($ws, $ch, $id, $payload); + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceGatherBody()); + + $res = $client->gatherMessage($workspaceId, $channelId, $callId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_calls_gather' => 'Gather input on a voice call by ID via Bird', ]); + // Bridge current call to another destination + $this->post('/bird/voice/calls/{id}/bridge', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_bridge'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceBridgeBody()); + + $res = $client->bridgeVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_bridge' => 'Bridge a voice call by ID via Bird', + ]); + + // Record call command endpoint + $this->post('/bird/voice/calls/{id}/record', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_record'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceRecordBody()); + + $res = $client->recordVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_record' => 'Record a voice call by ID via Bird', + ]); + + // Start call recording session + $this->post('/bird/voice/calls/{id}/recordings', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_recordings_create'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceRecordingsCreateBody()); + + $res = $client->createVoiceCallRecordingSession($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_recordings_create' => 'Create a voice call recording session via Bird', + ]); + + // List call recordings + $this->get('/bird/voice/calls/{id}/recordings', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_recordings_list'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $query = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $query = $this->birdValidateSchema($query, bird_request_schemas::voiceRecordingsListQuery()); + + $res = $client->listVoiceCallRecordings($workspaceId, $channelId, $callId, $query); + $response->success($res ?? []); + }, [ + 'modules_bird_voice_calls_recordings_list' => 'List voice call recordings via Bird', + ]); + + // Get single call recording + $this->get('/bird/voice/calls/{id}/recordings/{recordingId}', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_recordings_get'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + $recordingId = $this->birdResolveRouteRecordingId(); + + $res = $client->getVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId); + $response->success($res ?? []); + }, [ + 'modules_bird_voice_calls_recordings_get' => 'Get a voice call recording by ID via Bird', + ]); + + // Update single call recording + $this->patch('/bird/voice/calls/{id}/recordings/{recordingId}', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_recordings_update'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + $recordingId = $this->birdResolveRouteRecordingId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceRecordingUpdateBody()); + + $res = $client->updateVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_recordings_update' => 'Update a voice call recording via Bird', + ]); + + // Get call insights + $this->get('/bird/voice/calls/{id}/insights', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_insights_get'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $res = $client->getVoiceCallInsights($workspaceId, $channelId, $callId); + $response->success($res ?? []); + }, [ + 'modules_bird_voice_calls_insights_get' => 'Get voice call insights by call ID via Bird', + ]); + // Place test outbound call and hang up when accepted $this->post('/bird/voice/calls/test-outbound', function () { global $response; self::requirePermission('modules_bird_voice_calls_test_outbound'); + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceTestOutboundBody()); try { - $result = $client->createOutboundTestCallAndHangupWhenAccepted($ws, $ch, $payload); + $result = $client->createOutboundTestCallAndHangupWhenAccepted($workspaceId, $channelId, $payload); $response->success($result); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); diff --git a/services/nginx/app/routes/birdVoiceFlashCallsRoute.php b/services/nginx/app/routes/birdVoiceFlashCallsRoute.php index 6b178c5c..029489c9 100644 --- a/services/nginx/app/routes/birdVoiceFlashCallsRoute.php +++ b/services/nginx/app/routes/birdVoiceFlashCallsRoute.php @@ -2,38 +2,36 @@ namespace routes; +require_once WD . '/classes/bird.php'; +require_once WD . '/traits/route_t.php'; +require_once WD . '/traits/bird_route_helpers_t.php'; +require_once WD . '/traits/bird_route_validation_t.php'; +require_once WD . '/modules/bird/helpers/bird_request_schemas.php'; + +use bird\helpers\bird_request_schemas; use classes\bird; use traits\bird_route_helpers_t; +use traits\bird_route_validation_t; use traits\route_t; class birdVoiceFlashCallsRoute { - use route_t, bird_route_helpers_t; + use route_t, bird_route_helpers_t, bird_route_validation_t; public function run(): void { // Create a flash call $this->post('/bird/voice/flash-calls', function () { global $response; - // Permission: create/place a flash call via Bird self::requirePermission('modules_bird_voice_flash_calls_create'); + $client = new bird(); - // Require workspace/channel per Bird API docs - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - $res = $client->sendPostRequest($base, $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::flashCreateBody()); + + $res = $client->createFlashCall($workspaceId, $channelId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_flash_calls_create' => 'Create/place a flash call via Bird', @@ -42,24 +40,15 @@ class birdVoiceFlashCallsRoute // List flash calls $this->get('/bird/voice/flash-calls', function () { global $response; - // Permission: list flash calls via Bird self::requirePermission('modules_bird_voice_flash_calls_list'); + $client = new bird(); - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $query = $this->getParametersAsArray(); - unset($query['workspaceId'], $query['channelId']); - $res = $client->sendGetRequest($base, $query); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $query = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $query = $this->birdValidateSchema($query, bird_request_schemas::flashListQuery()); + + $res = $client->listFlashCalls($workspaceId, $channelId, $query); $response->success($res ?? []); }, [ 'modules_bird_voice_flash_calls_list' => 'List flash calls via Bird', @@ -68,87 +57,68 @@ class birdVoiceFlashCallsRoute // Get a specific flash call by ID $this->get('/bird/voice/flash-calls/{id}', function () { global $response; - // Permission: get a specific flash call via Bird self::requirePermission('modules_bird_voice_flash_calls_get'); + $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $res = $client->sendGetRequest($base . '/' . rawurlencode($id)); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $res = $client->getFlashCall($workspaceId, $channelId, $callId); $response->success($res ?? []); }, [ 'modules_bird_voice_flash_calls_get' => 'Get a flash call by ID via Bird', ]); - // Complete/end a flash call by ID (POST to the resource) + // Complete/end a flash call by ID $this->post('/bird/voice/flash-calls/{id}', function () { global $response; - // Permission: complete/end a flash call via Bird self::requirePermission('modules_bird_voice_flash_calls_end'); + $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - // Forward any body fields (e.g., result/status) transparently - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - $res = $client->sendPostRequest($base . '/' . rawurlencode($id), $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::flashEndBody()); + + $res = $client->endFlashCall($workspaceId, $channelId, $callId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_flash_calls_end' => 'Complete/end a flash call by ID via Bird', ]); - // Complete/end a flash call by using from/to numbers - $this->post('/bird/voice/flash-calls/end', function () { + // Hang up flash calls by payload + $this->post('/bird/voice/flash-calls/hangup', function () { global $response; - // Permission: complete/end a flash call by numbers via Bird - self::requirePermission('modules_bird_voice_flash_calls_end_by_numbers'); + self::requirePermission('modules_bird_voice_flash_calls_hangup'); + $client = new bird(); - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - // Basic validation hints: expect 'from' and 'to' but let Bird validate strictly - $res = $client->sendPostRequest($base . '/end', $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::flashHangupBody()); + + $res = $client->hangupFlashCall($workspaceId, $channelId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ - 'modules_bird_voice_flash_calls_end_by_numbers' => 'Complete/end a flash call using from/to numbers via Bird', + 'modules_bird_voice_flash_calls_hangup' => 'Hang up flash calls via Bird', + ]); + + // Compatibility alias for flash hangup endpoint + $this->post('/bird/voice/flash-calls/end', function () { + global $response; + self::requirePermission('modules_bird_voice_flash_calls_end_by_numbers'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::flashHangupBody()); + + $res = $client->hangupFlashCall($workspaceId, $channelId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_flash_calls_end_by_numbers' => 'Compatibility alias for hanging up flash calls via Bird', ]); } } diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 94312dc0..014fde68 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -11,6 +11,7 @@ use classes\stripe; use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\helpers\selfserve_lane_port; use modules\selfserve\helpers\selfserve_lane_relay; +use modules\selfserve\helpers\selfserve_wash_session_status; use objects\departments_o; use objects\logs_o; use objects\orders_o; @@ -220,13 +221,30 @@ class moduleSelfServeRoute $customer = $build_customer($customer_number); $vehicle = $build_vehicle($vehicle_id, $session_reg); $machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value(); + $wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value(); + if ($wash_started_at === null) { + $wash_started_at = $machine_start_triggered_at; + } + if ($wash_started_at === null) { + $wash_started_at = $format_wash_started_at($selfserve->lane($lane_id)->getWashStartTime()); + } $machine_relay_enabled = (bool)$session->machine_relay_enabled->value(); $included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null; + $status = $session->status->value(); + $in_progress_statusses = [ + selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value, + selfserve_wash_session_status::READY_FOR_MACHINE_START->value, + selfserve_wash_session_status::MACHINE_STARTED->value, + selfserve_wash_session_status::PENDING_QUESTIONS->value, + selfserve_wash_session_status::MACHINE_NOT_ALLOWED->value + ]; + $in_progress = in_array($status, $in_progress_statusses); $response->success([ 'lane_id' => $lane_id, - 'in_progress' => true, - 'elapsed_minutes' => 0, // TODO: Start when the session was created. + 'status' => (string)$session->status->value(), + 'in_progress' => $in_progress, + 'elapsed_minutes' => $session->getElapsedMinutes(), 'session' => [ 'id' => (int)$session->id, 'status' => (string)$session->status->value(), @@ -234,13 +252,13 @@ class moduleSelfServeRoute 'customer_number' => $customer_number, 'vehicle_id' => $vehicle_id, 'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), - 'included_minutes' => $included_minutes, + 'included_minutes' => $included_minutes ?? 0, 'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(), 'machine_relay_enabled' => $machine_relay_enabled, 'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(), 'machine_start_triggered' => (bool)$session->machine_start_triggered->value(), 'machine_start_triggered_at' => $machine_start_triggered_at, - 'wash_started_at' => $machine_start_triggered_at, + 'wash_started_at' => $wash_started_at, 'created_at' => (string)$session->created_at->value(), 'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(), ], diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php index d700970b..0abb1f73 100644 --- a/services/nginx/app/routes/moduleWeatherAPIRoute.php +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -27,10 +27,17 @@ class moduleWeatherAPIRoute private const DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX = 'departments_weather:refresh_lock:v1:'; private const DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY = 'weather_status_degraded_threshold'; private const DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY = 'weather_status_healthy_threshold'; + private const DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD = 1.0; + private const DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD = 1.3; private const DEPARTMENT_WEATHER_STATUS_SEVERITY = [ - 'healthy' => 1, - 'degraded' => 2, - 'unhealthy' => 3, + /** + * Below 1.0 cars/workhour = red + * 1.0-1.3 cars/workhour = yellow + * Above 1.3 cars/workhour = green + */ + 'healthy' => self::DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD, + 'degraded' => self::DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD, + 'unhealthy' => 0.0, ]; public function run(): void @@ -905,8 +912,8 @@ class moduleWeatherAPIRoute } $target = $this->normalizeDepartmentWeatherTarget([ - 'degraded_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY), - 'healthy_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY), + 'degraded_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD, + 'healthy_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD, ]); if ($target !== null) { $targets_by_department[$department_id] = $target; diff --git a/services/nginx/app/tests/Unit/Bird/BirdModuleClientMappingTest.php b/services/nginx/app/tests/Unit/Bird/BirdModuleClientMappingTest.php new file mode 100644 index 00000000..08fe18e4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdModuleClientMappingTest.php @@ -0,0 +1,283 @@ + */ + public array $calls = []; + + public function __construct() + { + } + + public function sendPostRequest(string $endpoint, array $data): array|object|null + { + $this->calls[] = ['method' => 'POST', 'endpoint' => $endpoint, 'payload' => $data]; + return ['method' => 'POST', 'endpoint' => $endpoint, 'payload' => $data]; + } + + public function sendGetRequest(string $endpoint, array $query = []): array|object|null + { + $this->calls[] = ['method' => 'GET', 'endpoint' => $endpoint, 'payload' => $query]; + return ['method' => 'GET', 'endpoint' => $endpoint, 'payload' => $query]; + } + + public function sendPatchRequest(string $endpoint, array $data = []): array|object|null + { + $this->calls[] = ['method' => 'PATCH', 'endpoint' => $endpoint, 'payload' => $data]; + return ['method' => 'PATCH', 'endpoint' => $endpoint, 'payload' => $data]; + } + + public function sendDeleteRequest(string $endpoint, array $query = []): array|object|null + { + $this->calls[] = ['method' => 'DELETE', 'endpoint' => $endpoint, 'payload' => $query]; + return ['method' => 'DELETE', 'endpoint' => $endpoint, 'payload' => $query]; + } + } +} + +if (!class_exists('BirdEnabledConfigStub')) { + class BirdEnabledConfigStub extends bird_enabled_c + { + public function __construct(private bool $enabled = true) + { + } + + public function isTrue(): bool + { + return $this->enabled; + } + + public function getVariableValue(): mixed + { + return $this->enabled ? 'true' : 'false'; + } + } +} + +if (!class_exists('BirdApiKeyConfigStub')) { + class BirdApiKeyConfigStub extends bird_api_key_c + { + public function __construct(private string $apiKey = 'test-key') + { + } + + public function getVariableValue(): mixed + { + return $this->apiKey; + } + } +} + +if (!class_exists('BirdServerUrlConfigStub')) { + class BirdServerUrlConfigStub extends bird_server_url_c + { + public function __construct(private string $baseUrl = 'https://api.bird.test') + { + } + + public function getVariableValue(): mixed + { + return $this->baseUrl; + } + } +} + +if (!class_exists('BirdModuleConfigStub')) { + class BirdModuleConfigStub extends bird_c + { + public function __construct(string $apiKey = 'test-key', string $baseUrl = 'https://api.bird.test') + { + $this->enabled = new BirdEnabledConfigStub(true); + $this->api_key = new BirdApiKeyConfigStub($apiKey); + $this->server_url = new BirdServerUrlConfigStub($baseUrl); + } + } +} + +if (!class_exists('BirdHttpHarness')) { + class BirdHttpHarness extends bird + { + /** @var array */ + public array $httpCalls = []; + private int $stubStatus; + private string|false|null $stubBody; + + public function __construct(int $status = 200, string|false|null $body = '{"ok":true}', string $apiKey = 'test-key', string $baseUrl = 'https://api.bird.test') + { + $this->stubStatus = $status; + $this->stubBody = $body; + $this->config = new BirdModuleConfigStub($apiKey, $baseUrl); + } + + protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array + { + $this->httpCalls[] = [ + 'method' => strtoupper($method), + 'url' => $url, + 'headers' => $headers, + 'body' => $body, + ]; + + return [ + 'status_code' => $this->stubStatus, + 'body' => $this->stubBody, + ]; + } + } +} + +it('maps voice call facade methods to documented endpoints and methods', function (): void { + $client = new BirdApiTransportCapture(); + + $client->updateVoiceCall('ws', 'ch', 'call', ['status' => 'completed']); + $client->answerVoiceCall('ws', 'ch', 'call'); + $client->ringVoiceCall('ws', 'ch', 'call'); + $client->hangupVoiceCall('ws', 'ch', 'call', ['cause' => 'busy']); + $client->playbackVoiceCall('ws', 'ch', 'call', ['media' => ['https://example.com/a.mp3']]); + $client->sayMessage('ws', 'ch', 'call', ['text' => 'hello']); + $client->gatherMessage('ws', 'ch', 'call', ['maxNumKeys' => 1]); + $client->bridgeVoiceCall('ws', 'ch', 'call', ['to' => '+4511122233']); + $client->recordVoiceCall('ws', 'ch', 'call', ['maxLength' => 60]); + + expect($client->calls[0])->toMatchArray([ + 'method' => 'PATCH', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call', + ]); + expect($client->calls[1])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/answer', + ]); + expect($client->calls[2])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/ringing', + ]); + expect($client->calls[3])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/hangup', + ]); + expect($client->calls[4])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/playback', + ]); + expect($client->calls[5])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/say', + ]); + expect($client->calls[6])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/gather', + ]); + expect($client->calls[7])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/bridge', + ]); + expect($client->calls[8])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/record', + ]); +}); + +it('maps recordings insights log and flash methods to documented resources', function (): void { + $client = new BirdApiTransportCapture(); + + $client->createVoiceCallRecordingSession('ws', 'ch', 'call', ['maxLength' => 30]); + $client->listVoiceCallRecordings('ws', 'ch', 'call', ['limit' => 10]); + $client->getVoiceCallRecording('ws', 'ch', 'call', 'rec'); + $client->updateVoiceCallRecording('ws', 'ch', 'call', 'rec', ['status' => 'completed']); + $client->getVoiceCallInsights('ws', 'ch', 'call'); + $client->getVoiceCallsLog('ws', ['limit' => 25]); + $client->listFlashCalls('ws', 'ch', ['limit' => 5]); + $client->endFlashCall('ws', 'ch', 'flash-call', ['result' => 'verified']); + $client->hangupFlashCall('ws', 'ch', ['from' => '+4511122233', 'to' => '+4544455566']); + + expect($client->calls[0])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings', + ]); + expect($client->calls[1])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings', + ]); + expect($client->calls[2])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings/rec', + ]); + expect($client->calls[3])->toMatchArray([ + 'method' => 'PATCH', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings/rec', + ]); + expect($client->calls[4])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/insights', + ]); + expect($client->calls[5])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/calls', + ]); + expect($client->calls[6])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/ch/flashcalls', + ]); + expect($client->calls[7])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/flashcalls/flash-call', + ]); + expect($client->calls[8])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/flashcalls/hangup', + ]); +}); + +it('encodes path segments before building outbound endpoints', function (): void { + $client = new BirdApiTransportCapture(); + + $client->getVoiceCall('workspace a', 'channel/a', 'call 1'); + $client->getVoiceCallRecording('workspace a', 'channel/a', 'call 1', 'recording/1'); + $client->getFlashCall('workspace a', 'channel/a', 'flash 1'); + + expect($client->calls[0]['endpoint'])->toBe('/workspaces/workspace%20a/channels/channel%2Fa/calls/call%201'); + expect($client->calls[1]['endpoint'])->toBe('/workspaces/workspace%20a/channels/channel%2Fa/calls/call%201/recordings/recording%2F1'); + expect($client->calls[2]['endpoint'])->toBe('/workspaces/workspace%20a/channels/channel%2Fa/flashcalls/flash%201'); +}); + +it('serializes query parameters for GET transport requests', function (): void { + $client = new BirdHttpHarness(); + + $response = $client->sendGetRequest('/workspaces/ws/channels/calls', [ + 'limit' => 10, + 'tag' => ['vip', 'north'], + 'id' => '123', + ]); + + expect($response)->toBeArray(); + expect($client->httpCalls)->toHaveCount(1); + expect($client->httpCalls[0]['method'])->toBe('GET'); + expect($client->httpCalls[0]['url'])->toContain('/workspaces/ws/channels/calls?'); + expect($client->httpCalls[0]['url'])->toContain('limit=10'); + expect($client->httpCalls[0]['url'])->toContain('tag%5B0%5D=vip'); + expect($client->httpCalls[0]['url'])->toContain('tag%5B1%5D=north'); + expect($client->httpCalls[0]['url'])->toContain('id=123'); +}); + +it('maps 4xx and 5xx transport failures into informative exceptions', function (): void { + $client4xx = new BirdHttpHarness(422, '{"message":"invalid payload","errors":[{"message":"to is required"}]}'); + $client5xx = new BirdHttpHarness(503, '{"error":"upstream unavailable"}'); + + expect(fn() => $client4xx->sendPostRequest('/workspaces/ws/channels/ch/calls', ['from' => '+4511122233'])) + ->toThrow(Exception::class, 'Bird API request failed with status 422'); + expect(fn() => $client5xx->sendGetRequest('/workspaces/ws/channels/ch/calls')) + ->toThrow(Exception::class, 'Bird API request failed with status 503'); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Bird/BirdOpenApiSpecTest.php new file mode 100644 index 00000000..451767e4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdOpenApiSpecTest.php @@ -0,0 +1,96 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +function bird_openapi_path_block_or_fail(string $content, string $path): string +{ + $pathMarker = ' ' . $path . ':'; + $start = strpos($content, $pathMarker); + if ($start === false) { + throw new RuntimeException('OpenAPI path block not found: ' . $path); + } + + $rest = substr($content, $start + strlen($pathMarker)); + $nextPath = strpos($rest, "\n /"); + if ($nextPath === false) { + return substr($content, $start); + } + + return substr($content, $start, strlen($pathMarker) + $nextPath); +} + +it('documents all Bird voice call parity endpoints including gather recordings insights and log', function (): void { + $content = bird_openapi_content_or_skip(); + + expect($content)->toContain('/bird/voice/calls:'); + expect($content)->toContain('/bird/voice/calls/log:'); + expect($content)->toContain('/bird/voice/calls/{id}:'); + expect($content)->toContain('/bird/voice/calls/{id}/answer:'); + expect($content)->toContain('/bird/voice/calls/{id}/ringing:'); + expect($content)->toContain('/bird/voice/calls/{id}/hangup:'); + expect($content)->toContain('/bird/voice/calls/{id}/playback:'); + expect($content)->toContain('/bird/voice/calls/{id}/say:'); + expect($content)->toContain('/bird/voice/calls/{id}/gather:'); + expect($content)->toContain('/bird/voice/calls/{id}/bridge:'); + expect($content)->toContain('/bird/voice/calls/{id}/record:'); + expect($content)->toContain('/bird/voice/calls/{id}/recordings:'); + expect($content)->toContain('/bird/voice/calls/{id}/recordings/{recordingId}:'); + expect($content)->toContain('/bird/voice/calls/{id}/insights:'); + expect($content)->toContain('/bird/voice/calls/test-outbound:'); +}); + +it('documents flash hangup endpoint and marks end alias as deprecated', function (): void { + $content = bird_openapi_content_or_skip(); + $aliasPath = bird_openapi_path_block_or_fail($content, '/bird/voice/flash-calls/end'); + + expect($content)->toContain('/bird/voice/flash-calls:'); + expect($content)->toContain('/bird/voice/flash-calls/{id}:'); + expect($content)->toContain('/bird/voice/flash-calls/hangup:'); + expect($content)->toContain('/bird/voice/flash-calls/end:'); + expect($aliasPath)->toContain('deprecated: true'); + expect($aliasPath)->toContain('birdEndFlashCallByNumbers'); +}); + +it('defines request and response schemas for Bird call command recording insight log and flash payloads', function (): void { + $content = bird_openapi_content_or_skip(); + + expect($content)->toContain('BirdVoiceCallCommandResponse:'); + expect($content)->toContain('BirdVoiceCallBridgeResponse:'); + expect($content)->toContain('BirdVoiceCallRecording:'); + expect($content)->toContain('BirdVoiceCallRecordingListResponse:'); + expect($content)->toContain('BirdVoiceCallRecordingSingleResponse:'); + expect($content)->toContain('BirdVoiceCallInsightsResponse:'); + expect($content)->toContain('BirdVoiceCallsLogResponse:'); + expect($content)->toContain('BirdFlashCallHangupRequest:'); + expect($content)->toContain('BirdFlashCallHangupResponse:'); + + expect($content)->toContain('BirdVoiceCallCreateRequest:'); + expect($content)->toContain('BirdVoiceCallUpdateRequest:'); + expect($content)->toContain('BirdVoiceCallGatherRequest:'); + expect($content)->toContain('BirdVoiceCallRecordingUpdateRequest:'); + expect($content)->toContain('BirdFlashCallCreateRequest:'); + expect($content)->toContain('BirdFlashCallEndRequest:'); + expect($content)->toContain('BirdTestOutboundCallRequest:'); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdRequestValidationTest.php b/services/nginx/app/tests/Unit/Bird/BirdRequestValidationTest.php new file mode 100644 index 00000000..8dd15621 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdRequestValidationTest.php @@ -0,0 +1,136 @@ +validateSchema($payload, $schema); + $this->forwardCalls++; + } + } +} + +beforeEach(function (): void { + global $response; + + $response = new class { + public function error(mixed $data, int $status = null): void + { + $encoded = is_string($data) ? $data : json_encode($data); + throw new RuntimeException('HTTP_' . (string)$status . ':' . $encoded); + } + }; +}); + +it('accepts valid payloads for create and nested call flow commands', function (): void { + $payload = [ + 'from' => '+4532330288', + 'to' => '+4542331128', + 'ringTimeout' => 20, + 'notification' => ['url' => 'https://example.com/webhook'], + 'callFlow' => [ + [ + 'command' => 'say', + 'conditions' => [ + ['variable' => 'keys', 'operator' => 'eq', 'value' => '1'], + ], + 'options' => ['text' => 'hello'], + ], + ], + ]; + + $errors = bird_request_validator::validate($payload, bird_request_schemas::voiceCreateCallBody()); + + expect($errors)->toBe([]); +}); + +it('rejects unknown fields and invalid enum values in strict schemas', function (): void { + $unknownErrors = bird_request_validator::validate( + ['to' => '+4542331128', 'unknownField' => 'x'], + bird_request_schemas::voiceCreateCallBody() + ); + $enumErrors = bird_request_validator::validate( + ['cause' => 'completed'], + bird_request_schemas::voiceHangupBody() + ); + + expect($unknownErrors)->not->toBe([]); + expect(implode(' | ', $unknownErrors))->toContain('$.unknownField is not allowed'); + + expect($enumErrors)->not->toBe([]); + expect(implode(' | ', $enumErrors))->toContain('$.cause must be one of: rejected, busy'); +}); + +it('supports CSV normalization in schema validation for log filters', function (): void { + $validQuery = [ + 'channelId' => '2cbde4fd-8899-4f2d-95ea-2ab7cc6b8c97,5a6bd8c1-32b7-4a03-8b5a-b6a4a9d7a8b2', + 'tag' => 'north,vip', + ]; + $invalidQuery = [ + 'channelId' => 'not-a-uuid', + ]; + + $validErrors = bird_request_validator::validate($validQuery, bird_request_schemas::voiceCallsLogQuery()); + $invalidErrors = bird_request_validator::validate($invalidQuery, bird_request_schemas::voiceCallsLogQuery()); + + expect($validErrors)->toBe([]); + expect($invalidErrors)->not->toBe([]); + expect(implode(' | ', $invalidErrors))->toContain('$.channelId[0] must be a UUID'); +}); + +it('validates flash hangup payloads for both supported request shapes', function (): void { + $resultMode = [ + 'result' => 'verified', + 'receivedCli' => '+4542331128', + ]; + $numbersMode = [ + 'from' => '+4532330288', + 'to' => '+4542331128', + 'result' => 'verified', + ]; + $invalid = [ + 'result' => 'ok', + ]; + + $resultErrors = bird_request_validator::validate($resultMode, bird_request_schemas::flashHangupBody()); + $numbersErrors = bird_request_validator::validate($numbersMode, bird_request_schemas::flashHangupBody()); + $invalidErrors = bird_request_validator::validate($invalid, bird_request_schemas::flashHangupBody()); + + expect($resultErrors)->toBe([]); + expect($numbersErrors)->toBe([]); + expect($invalidErrors)->not->toBe([]); + expect(implode(' | ', $invalidErrors))->toContain('does not match any of the allowed schemas'); +}); + +it('fails before forward step when strict validation fails and forwards when valid', function (): void { + $harness = new BirdRouteValidationHarness(); + + try { + $harness->validateThenForward(['unexpected' => true], bird_request_schemas::noBody()); + $caught = null; + } catch (RuntimeException $e) { + $caught = $e; + } + + expect($caught)->toBeInstanceOf(RuntimeException::class); + expect($caught?->getMessage())->toContain('HTTP_400'); + expect($harness->forwardCalls)->toBe(0); + + $harness->validateThenForward([], bird_request_schemas::noBody()); + expect($harness->forwardCalls)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdRouteWiringTest.php b/services/nginx/app/tests/Unit/Bird/BirdRouteWiringTest.php new file mode 100644 index 00000000..9e585236 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdRouteWiringTest.php @@ -0,0 +1,79 @@ +not->toBeFalse(); + expect($content)->toContain('/bird/voice/calls'); + expect($content)->toContain('/bird/voice/calls/log'); + expect($content)->toContain('/bird/voice/calls/{id}'); + expect($content)->toContain('/bird/voice/calls/{id}/answer'); + expect($content)->toContain('/bird/voice/calls/{id}/ringing'); + expect($content)->toContain('/bird/voice/calls/{id}/hangup'); + expect($content)->toContain('/bird/voice/calls/{id}/playback'); + expect($content)->toContain('/bird/voice/calls/{id}/say'); + expect($content)->toContain('/bird/voice/calls/{id}/gather'); + expect($content)->toContain('/bird/voice/calls/{id}/bridge'); + expect($content)->toContain('/bird/voice/calls/{id}/record'); + expect($content)->toContain('/bird/voice/calls/{id}/recordings'); + expect($content)->toContain('/bird/voice/calls/{id}/recordings/{recordingId}'); + expect($content)->toContain('/bird/voice/calls/{id}/insights'); + expect($content)->toContain('/bird/voice/calls/test-outbound'); + + expect($content)->toContain('modules_bird_voice_calls_create'); + expect($content)->toContain('modules_bird_voice_calls_list'); + expect($content)->toContain('modules_bird_voice_calls_get'); + expect($content)->toContain('modules_bird_voice_calls_update'); + expect($content)->toContain('modules_bird_voice_calls_answer'); + expect($content)->toContain('modules_bird_voice_calls_ringing'); + expect($content)->toContain('modules_bird_voice_calls_hangup'); + expect($content)->toContain('modules_bird_voice_calls_playback'); + expect($content)->toContain('modules_bird_voice_calls_say'); + expect($content)->toContain('modules_bird_voice_calls_gather'); + expect($content)->toContain('modules_bird_voice_calls_bridge'); + expect($content)->toContain('modules_bird_voice_calls_record'); + expect($content)->toContain('modules_bird_voice_calls_recordings_create'); + expect($content)->toContain('modules_bird_voice_calls_recordings_list'); + expect($content)->toContain('modules_bird_voice_calls_recordings_get'); + expect($content)->toContain('modules_bird_voice_calls_recordings_update'); + expect($content)->toContain('modules_bird_voice_calls_insights_get'); + expect($content)->toContain('modules_bird_voice_calls_log_list'); + expect($content)->toContain('modules_bird_voice_calls_test_outbound'); + + expect($content)->toContain('birdValidateSchema'); + expect($content)->toContain('birdResolveWorkspaceAndChannelIds'); + expect($content)->toContain('birdResolveRouteCallId'); + expect($content)->toContain('birdResolveRouteRecordingId'); +}); + +it('registers flash hangup endpoint and keeps end alias wired as compatibility path', function (): void { + $content = file_get_contents(app_path('routes/birdVoiceFlashCallsRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/bird/voice/flash-calls'); + expect($content)->toContain('/bird/voice/flash-calls/{id}'); + expect($content)->toContain('/bird/voice/flash-calls/hangup'); + expect($content)->toContain('/bird/voice/flash-calls/end'); + + expect($content)->toContain('modules_bird_voice_flash_calls_create'); + expect($content)->toContain('modules_bird_voice_flash_calls_list'); + expect($content)->toContain('modules_bird_voice_flash_calls_get'); + expect($content)->toContain('modules_bird_voice_flash_calls_end'); + expect($content)->toContain('modules_bird_voice_flash_calls_hangup'); + expect($content)->toContain('modules_bird_voice_flash_calls_end_by_numbers'); + + expect($content)->toContain('flashHangupBody'); + expect($content)->toContain('hangupFlashCall($workspaceId, $channelId, $payload)'); +}); + +it('keeps Bird number and webhook routes intact', function (): void { + $numbers = file_get_contents(app_path('routes/birdNumbersRoute.php')); + $webhooks = file_get_contents(app_path('routes/birdVoiceWebhooksRoute.php')); + + expect($numbers)->not->toBeFalse(); + expect($numbers)->toContain('/bird/numbers'); + expect($numbers)->toContain('/bird/numbers/{id}'); + + expect($webhooks)->not->toBeFalse(); + expect($webhooks)->toContain('/bird/voice/calls/webhook/inbound'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php index 7cf8c692..1af658a0 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php @@ -2,11 +2,18 @@ function economic_v2_openapi_content_or_skip(): string { - $candidates = [ - dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'openapi.yaml', // monorepo root in local workspace - dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'openapi.yaml', - dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'openapi.yaml', - ]; + $candidates = []; + for ($depth = 1; $depth <= 8; $depth++) { + $candidates[] = dirname(__DIR__, $depth) . DIRECTORY_SEPARATOR . 'openapi.yaml'; + } + + $cwd = getcwd(); + if (is_string($cwd) && $cwd !== '') { + $candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml'; + $candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml'; + } + + $candidates = array_values(array_unique($candidates)); foreach ($candidates as $candidate) { if (is_file($candidate)) { diff --git a/services/nginx/app/tests/Unit/Selfserve/NormalizationTest.php b/services/nginx/app/tests/Unit/Selfserve/NormalizationTest.php index 0b19aee5..f5521332 100644 --- a/services/nginx/app/tests/Unit/Selfserve/NormalizationTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/NormalizationTest.php @@ -1,18 +1,24 @@ toContain('MACHINE'); }); } - diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index 3974a967..9732af21 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -58,10 +58,10 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set:'); - expect($allowedPathBlock)->toContain('vehicle_type_id:'); - expect($allowedPathBlock)->toContain('vehicle_type:'); - expect($summaryPathBlock)->toContain('vehicle_type_id:'); - expect($summaryPathBlock)->toContain('vehicle_type:'); + expect($allowedPathBlock)->toContain('name: vehicle_type_id'); + expect($allowedPathBlock)->toContain('name: vehicle_type'); + expect($summaryPathBlock)->toContain('name: vehicle_type_id'); + expect($summaryPathBlock)->toContain('name: vehicle_type'); }); it('defines reusable self-serve wash and machine type schemas', function (): void { @@ -74,6 +74,7 @@ it('defines reusable self-serve wash and machine type schemas', function (): voi expect($content)->toContain('DepartmentSelfserveVehicleConditionMutationResponse:'); expect($content)->toContain('machine_type_id:'); expect($content)->toContain('SelfServeLaneMachineRelayStatus:'); + expect($content)->toContain(' wash_started_at:'); }); it('documents in-progress self-serve wash start and machine relay fields', function (): void { diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php index 31e0b45b..012894a8 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php @@ -8,3 +8,12 @@ it('adds dynamic_images_vehicle_type column for legacy selfserve wash session ta expect($bootstrapContent)->toContain("'dynamic_images_vehicle_type'"); expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'); }); + +it('adds wash_started_at column for legacy selfserve wash session schemas', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain("'selfserve_wash_sessions'"); + expect($bootstrapContent)->toContain("'wash_started_at'"); + expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashStartedAtWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashStartedAtWiringTest.php new file mode 100644 index 00000000..3803c3fe --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashStartedAtWiringTest.php @@ -0,0 +1,30 @@ +not->toBeFalse(); + expect($sessionsObject)->toContain('public object_property $wash_started_at;'); + expect($sessionsObject)->toContain("\$this->wash_started_at = new object_property("); + expect($sessionsObject)->toContain("\$this->wash_started_at->set(\$resolvedWashStartedAt);"); + expect($sessionsObject)->toContain("'wash_started_at' =>"); +}); + +it('passes lane wash start time into the session machine-start marker', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($washFlow)->not->toBeFalse(); + expect($washFlow)->toContain('$washStartedAt = (int)$lane->getWashStartTime();'); + expect($washFlow)->toContain('$session->markMachineStartTriggered('); + expect($washFlow)->toContain("date('Y-m-d H:i:s', \$washStartedAt)"); +}); + +it('resolves in-progress wash_started_at from session with compatibility fallbacks', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('$wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value();'); + expect($moduleSelfServeRoute)->toContain('$wash_started_at = $machine_start_triggered_at;'); + expect($moduleSelfServeRoute)->toContain('$wash_started_at = $format_wash_started_at($selfserve->lane($lane_id)->getWashStartTime());'); + expect($moduleSelfServeRoute)->toContain('\'wash_started_at\' => $wash_started_at'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsOpenApiSpecTest.php index 3822baf2..1d3bd6a2 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsOpenApiSpecTest.php @@ -2,11 +2,16 @@ function department_weather_targets_openapi_content_or_skip(): string { - $candidates = [ - WD . '/openapi.yaml', - dirname(WD) . '/openapi.yaml', - dirname(WD, 2) . '/openapi.yaml', - ]; + $candidates = [WD . '/openapi.yaml']; + for ($depth = 1; $depth <= 8; $depth++) { + $candidates[] = dirname(WD, $depth) . '/openapi.yaml'; + } + $cwd = getcwd(); + if (is_string($cwd) && $cwd !== '') { + $candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml'; + $candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml'; + } + $candidates = array_values(array_unique($candidates)); foreach ($candidates as $candidate) { if (!is_file($candidate)) { diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php index 0d820813..deb840c7 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php @@ -9,11 +9,16 @@ it('includes a date key in department weather timeline entries', function (): vo }); it('documents the date key in the openapi department weather timeline schema', function (): void { - $candidates = [ - WD . '/openapi.yaml', - dirname(WD) . '/openapi.yaml', - dirname(WD, 2) . '/openapi.yaml', - ]; + $candidates = [WD . '/openapi.yaml']; + for ($depth = 1; $depth <= 8; $depth++) { + $candidates[] = dirname(WD, $depth) . '/openapi.yaml'; + } + $cwd = getcwd(); + if (is_string($cwd) && $cwd !== '') { + $candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml'; + $candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml'; + } + $candidates = array_values(array_unique($candidates)); $openApiFile = null; foreach ($candidates as $candidate) { diff --git a/services/nginx/app/traits/bird_route_validation_t.php b/services/nginx/app/traits/bird_route_validation_t.php new file mode 100644 index 00000000..d6926fbc --- /dev/null +++ b/services/nginx/app/traits/bird_route_validation_t.php @@ -0,0 +1,143 @@ +error([ + 'message' => 'Validation failed', + 'errors' => array_values(array_unique($normalizedErrors)), + ], 400); + } + + private function birdAssertUuid(string $value, string $field): string + { + $normalized = trim($value); + if ($normalized === '') { + $this->birdFailValidation($field . ' is required'); + } + if (!bird_request_validator::isUuid($normalized)) { + $this->birdFailValidation($field . ' must be a UUID'); + } + return $normalized; + } + + private function birdResolveWorkspaceId(bird $client): string + { + $workspaceId = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); + if ($workspaceId === '') { + $workspaceId = $this->getConfiguredWorkspaceId($client); + } + if ($workspaceId === '') { + $this->birdFailValidation('Missing required parameter: workspaceId'); + } + return $this->birdAssertUuid($workspaceId, 'workspaceId'); + } + + private function birdResolveWorkspaceAndChannelIds(bird $client): array + { + $workspaceId = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); + $channelId = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); + + if ($workspaceId === '') { + $workspaceId = $this->getConfiguredWorkspaceId($client); + } + if ($channelId === '') { + $channelId = $this->getConfiguredChannelId($client); + } + if ($workspaceId === '' || $channelId === '') { + $this->birdFailValidation('Missing required parameters: workspaceId, channelId'); + } + + return [ + $this->birdAssertUuid($workspaceId, 'workspaceId'), + $this->birdAssertUuid($channelId, 'channelId'), + ]; + } + + private function birdResolveRouteCallId(string $key = 'id'): string + { + $callId = $this->normalizeOptionalString((string)$this->fromRoute($key)); + if ($callId === '') { + $this->birdFailValidation('Missing required parameter: callId'); + } + return $this->birdAssertUuid($callId, 'callId'); + } + + private function birdResolveRouteRecordingId(string $key = 'recordingId'): string + { + $recordingId = $this->normalizeOptionalString((string)$this->fromRoute($key)); + if ($recordingId === '') { + $this->birdFailValidation('Missing required parameter: recordingId'); + } + return $this->birdAssertUuid($recordingId, 'recordingId'); + } + + private function birdPayloadWithout(array $keys): array + { + $payload = $this->getParametersAsArray(); + foreach ($keys as $key) { + unset($payload[$key]); + } + return $payload; + } + + private function birdNormalizeCsvField(array $payload, string $key): array + { + if (!array_key_exists($key, $payload)) { + return $payload; + } + $value = $payload[$key]; + if (is_string($value)) { + $parts = array_map('trim', explode(',', $value)); + $payload[$key] = array_values(array_filter($parts, static fn($part) => $part !== '')); + return $payload; + } + if (is_array($value)) { + $payload[$key] = array_values(array_filter(array_map(static fn($part) => is_scalar($part) ? trim((string)$part) : '', $value), static fn($part) => $part !== '')); + return $payload; + } + return $payload; + } + + private function birdValidateSchema(array $payload, array $schema): array + { + $errors = bird_request_validator::validate($payload, $schema); + if ($errors !== []) { + $this->birdFailValidation($errors); + } + return $payload; + } +}