From f80acce8d91ec9fa6503526218a89f23a22d5575 Mon Sep 17 00:00:00 2001 From: giresse19 Date: Mon, 30 Mar 2026 20:05:12 +0300 Subject: [PATCH 01/35] feat: Refactor send schedules based on review feedback --- api/docs/docs.go | 577 +- api/docs/swagger.json | 9115 +++++++++-------- api/docs/swagger.yaml | 2572 +++-- api/pkg/di/container.go | 48 + api/pkg/entities/phone.go | 14 +- api/pkg/entities/send_schedule.go | 26 + api/pkg/handlers/send_schedule_handler.go | 187 + .../gorm_phone_notification_repository.go | 61 +- .../gorm_send_schedule_repository.go | 83 + .../phone_notification_repository.go | 2 +- .../repositories/send_schedule_repository.go | 18 + api/pkg/requests/phone_update_request.go | 12 + .../requests/send_schedule_store_request.go | 48 + api/pkg/responses/send_schedule_responses.go | 13 + .../services/phone_notification_service.go | 17 +- api/pkg/services/phone_service.go | 6 + api/pkg/services/send_schedule_service.go | 136 + api/pkg/validators/phone_handler_validator.go | 9 + .../send_schedule_handler_validator.go | 55 + web/pages/settings/index.vue | 38 + web/pages/settings/send-schedules/index.vue | 304 + web/store/index.ts | 1 + 22 files changed, 8145 insertions(+), 5197 deletions(-) create mode 100644 api/pkg/entities/send_schedule.go create mode 100644 api/pkg/handlers/send_schedule_handler.go create mode 100644 api/pkg/repositories/gorm_send_schedule_repository.go create mode 100644 api/pkg/repositories/send_schedule_repository.go create mode 100644 api/pkg/requests/send_schedule_store_request.go create mode 100644 api/pkg/responses/send_schedule_responses.go create mode 100644 api/pkg/services/send_schedule_service.go create mode 100644 api/pkg/validators/send_schedule_handler_validator.go create mode 100644 web/pages/settings/send-schedules/index.vue diff --git a/api/docs/docs.go b/api/docs/docs.go index 26545bdb..9b3adba6 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1,5 +1,4 @@ -// Package docs GENERATED BY SWAG; DO NOT EDIT -// This file was generated by swaggo/swag +// Package docs Code generated by swaggo/swag. DO NOT EDIT package docs import "github.com/swaggo/swag" @@ -1429,6 +1428,72 @@ const docTemplate = `{ } }, "/messages/{messageID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a message from the database by the message ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Get a message from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message", + "name": "messageID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, "delete": { "security": [ { @@ -2114,6 +2179,280 @@ const docTemplate = `{ } } }, + "/send-schedules": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Lists the send schedules owned by the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "List send schedules", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.SendSchedulesResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a send schedule for the authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "Create send schedule", + "parameters": [ + { + "description": "Payload of new send schedule.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.SendScheduleStore" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.SendScheduleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/send-schedules/{scheduleID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Loads a single send schedule owned by the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "Show send schedule", + "parameters": [ + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.SendScheduleResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates a send schedule owned by the authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "Update send schedule", + "parameters": [ + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleID", + "in": "path", + "required": true + }, + { + "description": "Payload of updated send schedule.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.SendScheduleStore" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.SendScheduleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a send schedule owned by the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "Delete send schedule", + "parameters": [ + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/users/me": { "get": { "security": [ @@ -3118,7 +3457,11 @@ const docTemplate = `{ }, "sim": { "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card", - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], "example": "DEFAULT" }, "status": { @@ -3215,6 +3558,7 @@ const docTemplate = `{ "message_expiration_seconds", "messages_per_minute", "phone_number", + "schedule_id", "sim", "updated_at", "user_id" @@ -3253,9 +3597,12 @@ const docTemplate = `{ "type": "string", "example": "+18005550199" }, + "schedule_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, "sim": { - "description": "SIM card that received the message", - "type": "string" + "$ref": "#/definitions/entities.SIM" }, "updated_at": { "type": "string", @@ -3331,6 +3678,117 @@ const docTemplate = `{ } } }, + "entities.SIM": { + "type": "string", + "enum": [ + "SIM1", + "SIM2" + ], + "x-enum-varnames": [ + "SIM1", + "SIM2" + ] + }, + "entities.SendSchedule": { + "type": "object", + "required": [ + "created_at", + "id", + "is_active", + "name", + "timezone", + "updated_at", + "user_id", + "windows" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "is_active": { + "type": "boolean", + "example": true + }, + "name": { + "type": "string", + "example": "Business Hours" + }, + "timezone": { + "type": "string", + "example": "Europe/Tallinn" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + }, + "windows": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.SendScheduleWindow" + } + } + } + }, + "entities.SendScheduleWindow": { + "type": "object", + "required": [ + "day_of_week", + "end_minute", + "start_minute" + ], + "properties": { + "day_of_week": { + "type": "integer", + "example": 1 + }, + "end_minute": { + "type": "integer", + "example": 1020 + }, + "start_minute": { + "type": "integer", + "example": 540 + } + } + }, + "entities.SubscriptionName": { + "type": "string", + "enum": [ + "free", + "pro-monthly", + "pro-yearly", + "ultra-monthly", + "ultra-yearly", + "pro-lifetime", + "20k-monthly", + "100k-monthly", + "50k-monthly", + "200k-monthly", + "20k-yearly" + ], + "x-enum-varnames": [ + "SubscriptionNameFree", + "SubscriptionNameProMonthly", + "SubscriptionNameProYearly", + "SubscriptionNameUltraMonthly", + "SubscriptionNameUltraYearly", + "SubscriptionNameProLifetime", + "SubscriptionName20KMonthly", + "SubscriptionName100KMonthly", + "SubscriptionName50KMonthly", + "SubscriptionName200KMonthly", + "SubscriptionName20KYearly" + ] + }, "entities.User": { "type": "object", "required": [ @@ -3393,7 +3851,11 @@ const docTemplate = `{ "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" }, "subscription_name": { - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SubscriptionName" + } + ], "example": "free" }, "subscription_renews_at": { @@ -3644,7 +4106,11 @@ const docTemplate = `{ }, "sim": { "description": "SIM card that received the message", - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], "example": "SIM1" }, "timestamp": { @@ -3751,6 +4217,7 @@ const docTemplate = `{ "messages_per_minute", "missed_call_auto_reply", "phone_number", + "schedule_id", "sim" ], "properties": { @@ -3780,6 +4247,10 @@ const docTemplate = `{ "type": "string", "example": "+18005550199" }, + "schedule_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, "sim": { "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", "type": "string", @@ -3787,6 +4258,51 @@ const docTemplate = `{ } } }, + "requests.SendScheduleStore": { + "type": "object", + "required": [ + "is_active", + "name", + "timezone", + "windows" + ], + "properties": { + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "windows": { + "type": "array", + "items": { + "$ref": "#/definitions/requests.SendScheduleWindow" + } + } + } + }, + "requests.SendScheduleWindow": { + "type": "object", + "required": [ + "day_of_week", + "end_minute", + "start_minute" + ], + "properties": { + "day_of_week": { + "type": "integer" + }, + "end_minute": { + "type": "integer" + }, + "start_minute": { + "type": "integer" + } + } + }, "requests.UserNotificationUpdate": { "type": "object", "required": [ @@ -4327,6 +4843,51 @@ const docTemplate = `{ } } }, + "responses.SendScheduleResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.SendSchedule" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.SendSchedulesResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.SendSchedule" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.Unauthorized": { "type": "object", "required": [ @@ -4612,6 +5173,8 @@ var SwaggerInfo = &swag.Spec{ Description: "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", } func init() { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index db16ea4b..430e52b1 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1,4166 +1,5161 @@ { - "schemes": ["https"], - "swagger": "2.0", - "info": { - "description": "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.", - "title": "httpSMS API Reference", - "contact": { - "name": "support@httpsms.com", - "email": "support@httpsms.com" - }, - "license": { - "name": "AGPL-3.0", - "url": "https://raw.githubusercontent.com/NdoleStudio/http-sms-manager/main/LICENSE" - }, - "version": "1.0" - }, - "host": "api.httpsms.com", - "basePath": "/v1", - "paths": { - "/billing/usage": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get the summary of sent and received messages for a user in the current month", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Billing"], - "summary": "Get Billing Usage.", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.BillingUsageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/billing/usage-history": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get billing usage records of sent and received messages for a user in the past. It will be sorted by timestamp in descending order.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Billing"], - "summary": "Get billing usage history.", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of heartbeats to skip", - "name": "skip", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "description": "number of heartbeats to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.BillingUsagesResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/bulk-messages": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).", - "consumes": ["multipart/form-data"], - "produces": ["application/json"], - "tags": ["BulkSMS"], - "summary": "Store bulk SMS file", - "parameters": [ - { - "type": "file", - "description": "The Excel or CSV file containing the messages to be sent.", - "name": "document", - "in": "formData", - "required": true - } - ], - "responses": { - "202": { - "description": "Accepted", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/discord-integrations": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get the discord integrations of a user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["DiscordIntegration"], - "summary": "Get discord integrations of a user", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of discord integrations to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter discord integrations containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of discord integrations to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.DiscordsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Store a discord integration for the authenticated user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["DiscordIntegration"], - "summary": "Store discord integration", - "parameters": [ - { - "description": "Payload of the discord integration request", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.DiscordStore" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/responses.DiscordResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/discord-integrations/{discordID}": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Update a discord integration for the currently authenticated user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["DiscordIntegration"], - "summary": "Update a discord integration", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the discord integration", - "name": "discordID", - "in": "path", - "required": true - }, - { - "description": "Payload of discord integration to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.DiscordUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.DiscordResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a discord integration for a user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Webhooks"], - "summary": "Delete discord integration", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the discord integration", - "name": "discordID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/discord/event": { - "post": { - "description": "Publish a discord event to the registered listeners", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Discord"], - "summary": "Consume a discord event", - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/heartbeats": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get the last time a phone number requested for outstanding messages. It will be sorted by timestamp in descending order.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Heartbeats"], - "summary": "Get heartbeats of an owner phone number", - "parameters": [ - { - "type": "string", - "default": "+18005550199", - "description": "the owner's phone number", - "name": "owner", - "in": "query", - "required": true - }, - { - "minimum": 0, - "type": "integer", - "description": "number of heartbeats to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of heartbeats to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.HeartbeatsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Store the heartbeat to make notify that a phone number is still active", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Heartbeats"], - "summary": "Register heartbeat of an owner phone number", - "parameters": [ - { - "description": "Payload of the heartbeat request", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.HeartbeatStore" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.HeartbeatResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/integration/3cx/messages": { - "post": { - "description": "Sends an SMS message from the 3CX platform", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["3CXIntegration"], - "summary": "Sends a 3CX SMS message", - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/message-threads": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["MessageThreads"], - "summary": "Get message threads for a phone number", - "parameters": [ - { - "type": "string", - "default": "+18005550199", - "description": "owner phone number", - "name": "owner", - "in": "query", - "required": true - }, - { - "minimum": 0, - "type": "integer", - "description": "number of messages to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter message threads containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of messages to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageThreadsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/message-threads/{messageThreadID}": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the details of a message thread", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["MessageThreads"], - "summary": "Update a message thread", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the message thread", - "name": "messageThreadID", - "in": "path", - "required": true - }, - { - "description": "Payload of message thread details to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageThreadUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a message thread from the database and also deletes all the messages in the thread.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["MessageThreads"], - "summary": "Delete a message thread from the database.", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the message thread", - "name": "messageThreadID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/messages": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get list of messages which are sent between 2 phone numbers. It will be sorted by timestamp in descending order.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Messages"], - "summary": "Get messages which are sent between 2 phone numbers", - "parameters": [ - { - "type": "string", - "default": "+18005550199", - "description": "the owner's phone number", - "name": "owner", - "in": "query", - "required": true - }, - { - "type": "string", - "default": "+18005550100", - "description": "the contact's phone number", - "name": "contact", - "in": "query", - "required": true - }, - { - "minimum": 0, - "type": "integer", - "description": "number of messages to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter messages containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of messages to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessagesResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/messages/bulk-send": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Add bulk SMS messages to be sent by the android phone", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Messages"], - "summary": "Send bulk SMS messages", - "parameters": [ - { - "description": "Bulk send message request payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageBulkSend" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/responses.MessagesResponse" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/messages/calls/missed": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Messages"], - "summary": "Register a missed call event on the mobile phone", - "parameters": [ - { - "description": "Payload of the missed call event.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageCallMissed" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/messages/outstanding": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get an outstanding message to be sent by an android phone", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Messages"], - "summary": "Get an outstanding message", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703cb", - "description": "The ID of the message", - "name": "message_id", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/messages/receive": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Add a new message received from a mobile phone", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Messages"], - "summary": "Receive a new SMS message from a mobile phone", - "parameters": [ - { - "description": "Received message request payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageReceive" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/messages/search": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "This returns the list of all messages based on the filter criteria including missed calls", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Messages"], - "summary": "Search all messages of a user", - "parameters": [ - { - "type": "string", - "description": "Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/", - "name": "token", - "in": "header", - "required": true - }, - { - "type": "string", - "default": "+18005550199,+18005550100", - "description": "the owner's phone numbers", - "name": "owners", - "in": "query", - "required": true - }, - { - "minimum": 0, - "type": "integer", - "description": "number of messages to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter messages containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 200, - "minimum": 1, - "type": "integer", - "description": "number of messages to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessagesResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/messages/send": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Add a new SMS message to be sent by your Android phone", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Messages"], - "summary": "Send an SMS message", - "parameters": [ - { - "description": "Send message request payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageSend" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/messages/{messageID}": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a message from the database and removes the message content from the list of threads.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Messages"], - "summary": "Delete a message from the database.", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the message", - "name": "messageID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/messages/{messageID}/events": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Use this endpoint to send events for a message when it is failed, sent or delivered by the mobile phone.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Messages"], - "summary": "Upsert an event for a message on the mobile phone", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the message", - "name": "messageID", - "in": "path", - "required": true - }, - { - "description": "Payload of the event emitted.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageEvent" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/phone-api-keys": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get list phone API keys which a user has registered on the httpSMS application", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["PhoneAPIKeys"], - "summary": "Get the phone API keys of a user", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of phone api keys to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter phone api keys with name containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "description": "number of phone api keys to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneAPIKeysResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Creates a new phone API key which can be used to log in to the httpSMS app on your Android phone", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["PhoneAPIKeys"], - "summary": "Store phone API key", - "parameters": [ - { - "description": "Payload of new phone API key.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.PhoneAPIKeyStoreRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneAPIKeyResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/phone-api-keys/{phoneAPIKeyID}": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a phone API Key from the database and cannot be used for authentication anymore.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["PhoneAPIKeys"], - "summary": "Delete a phone API key from the database.", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the phone API key", - "name": "phoneAPIKeyID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/phone-api-keys/{phoneAPIKeyID}/phones/{phoneID}": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "You will need to login again to the httpSMS app on your Android phone with a new phone API key.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["PhoneAPIKeys"], - "summary": "Remove the association of a phone from the phone API key.", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the phone API key", - "name": "phoneAPIKeyID", - "in": "path", - "required": true - }, - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the phone", - "name": "phoneID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/phones": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get list of phones which a user has registered on the http sms application", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Phones"], - "summary": "Get phones of a user", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of heartbeats to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter phones containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of phones to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhonesResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Phones"], - "summary": "Upsert Phone", - "parameters": [ - { - "description": "Payload of new phone number.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.PhoneUpsert" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/phones/fcm-token": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Phones"], - "summary": "Upserts the FCM token of a phone", - "parameters": [ - { - "description": "Payload of new FCM token.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.PhoneFCMToken" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/phones/{phoneID}": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a phone that has been sored in the database", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Phones"], - "summary": "Delete Phone", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the phone", - "name": "phoneID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/users/me": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get details of the currently authenticated user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Get current user", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.UserResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the details of the currently authenticated user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Update a user", - "parameters": [ - { - "description": "Payload of user details to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.UserUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Deletes the currently authenticated user together with all their data.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Delete a user", - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/users/subscription": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Cancel the subscription of the authenticated user.", - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Cancel the user's subscription", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/users/subscription-update-url": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Fetches the subscription URL of the authenticated user.", - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Currently authenticated user subscription update URL", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.OkString" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/users/subscription/invoices/{subscriptionInvoiceID}": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.", - "consumes": ["application/json"], - "produces": ["application/pdf"], - "tags": ["Users"], - "summary": "Generate a subscription payment invoice", - "parameters": [ - { - "description": "Generate subscription payment invoice parameters", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.UserPaymentInvoice" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/users/subscription/payments": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Get the last 10 subscription payments.", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/users/{userID}/api-keys": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Rotate the user's API key in case the current API Key is compromised", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Rotate the user's API Key", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the user to update", - "name": "userID", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.UserResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/users/{userID}/notifications": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Update the email notification settings for a user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Update notification settings", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the user to update", - "name": "userID", - "in": "path", - "required": true - }, - { - "description": "User notification details to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.UserNotificationUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.UserResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/webhooks": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get the webhooks of a user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Webhooks"], - "summary": "Get webhooks of a user", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of webhooks to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter webhooks containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of webhooks to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.WebhooksResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Store a webhook for the authenticated user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Webhooks"], - "summary": "Store a webhook", - "parameters": [ - { - "description": "Payload of the webhook request", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.WebhookStore" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.WebhookResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, - "/webhooks/{webhookID}": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Update a webhook for the currently authenticated user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Webhooks"], - "summary": "Update a webhook", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the webhook", - "name": "webhookID", - "in": "path", - "required": true - }, - { - "description": "Payload of webhook details to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.WebhookUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.WebhookResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a webhook for a user", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Webhooks"], - "summary": "Delete webhook", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the webhook", - "name": "webhookID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - } - }, - "definitions": { - "entities.BillingUsage": { - "type": "object", - "required": [ - "created_at", - "end_timestamp", - "id", - "received_messages", - "sent_messages", - "start_timestamp", - "total_cost", - "updated_at", - "user_id" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "end_timestamp": { - "type": "string", - "example": "2022-01-31T23:59:59+00:00" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "received_messages": { - "type": "integer", - "example": 465 - }, - "sent_messages": { - "type": "integer", - "example": 321 - }, - "start_timestamp": { - "type": "string", - "example": "2022-01-01T00:00:00+00:00" - }, - "total_cost": { - "type": "integer", - "example": 0 - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } - }, - "entities.Discord": { - "type": "object", - "required": [ - "created_at", - "id", - "incoming_channel_id", - "name", - "server_id", - "updated_at", - "user_id" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "incoming_channel_id": { - "type": "string", - "example": "1095780203256627291" - }, - "name": { - "type": "string", - "example": "Game Server" - }, - "server_id": { - "type": "string", - "example": "1095778291488653372" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } - }, - "entities.Heartbeat": { - "type": "object", - "required": [ - "charging", - "id", - "owner", - "timestamp", - "user_id", - "version" - ], - "properties": { - "charging": { - "type": "boolean", - "example": true - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "owner": { - "type": "string", - "example": "+18005550199" - }, - "timestamp": { - "type": "string", - "example": "2022-06-05T14:26:01.520828+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - }, - "version": { - "type": "string", - "example": "344c10f" - } - } - }, - "entities.Message": { - "type": "object", - "required": [ - "contact", - "content", - "created_at", - "encrypted", - "id", - "max_send_attempts", - "order_timestamp", - "owner", - "request_received_at", - "send_attempt_count", - "sim", - "status", - "type", - "updated_at", - "user_id" - ], - "properties": { - "contact": { - "type": "string", - "example": "+18005550100" - }, - "content": { - "type": "string", - "example": "This is a sample text message" - }, - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "delivered_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "encrypted": { - "type": "boolean", - "example": false - }, - "expired_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "failed_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "failure_reason": { - "type": "string", - "example": "UNKNOWN" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "last_attempted_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "max_send_attempts": { - "type": "integer", - "example": 1 - }, - "order_timestamp": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "owner": { - "type": "string", - "example": "+18005550199" - }, - "received_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "request_id": { - "type": "string", - "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" - }, - "request_received_at": { - "type": "string", - "example": "2022-06-05T14:26:01.520828+03:00" - }, - "scheduled_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "scheduled_send_time": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "send_attempt_count": { - "type": "integer", - "example": 0 - }, - "send_time": { - "description": "SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message", - "type": "integer", - "example": 133414 - }, - "sent_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "sim": { - "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card", - "type": "string", - "example": "DEFAULT" - }, - "status": { - "type": "string", - "example": "pending" - }, - "type": { - "type": "string", - "example": "mobile-terminated" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } - }, - "entities.MessageThread": { - "type": "object", - "required": [ - "color", - "contact", - "created_at", - "id", - "is_archived", - "last_message_content", - "last_message_id", - "order_timestamp", - "owner", - "status", - "updated_at", - "user_id" - ], - "properties": { - "color": { - "type": "string", - "example": "indigo" - }, + "schemes": [ + "https" + ], + "swagger": "2.0", + "info": { + "description": "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.", + "title": "httpSMS API Reference", "contact": { - "type": "string", - "example": "+18005550100" - }, - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703ca" - }, - "is_archived": { - "type": "boolean", - "example": false - }, - "last_message_content": { - "type": "string", - "example": "This is a sample message content" - }, - "last_message_id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703ca" - }, - "order_timestamp": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "owner": { - "type": "string", - "example": "+18005550199" - }, - "status": { - "type": "string", - "example": "PENDING" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } - }, - "entities.Phone": { - "type": "object", - "required": [ - "created_at", - "id", - "max_send_attempts", - "message_expiration_seconds", - "messages_per_minute", - "phone_number", - "sim", - "updated_at", - "user_id" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "fcm_token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "max_send_attempts": { - "description": "MaxSendAttempts determines how many times to retry sending an SMS message", - "type": "integer", - "example": 2 - }, - "message_expiration_seconds": { - "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.", - "type": "integer" - }, - "messages_per_minute": { - "type": "integer", - "example": 1 - }, - "missed_call_auto_reply": { - "type": "string", - "example": "This phone cannot receive calls. Please send an SMS instead." - }, - "phone_number": { - "type": "string", - "example": "+18005550199" - }, - "sim": { - "description": "SIM card that received the message", - "type": "string" + "name": "support@httpsms.com", + "email": "support@httpsms.com" }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" + "license": { + "name": "AGPL-3.0", + "url": "https://raw.githubusercontent.com/NdoleStudio/http-sms-manager/main/LICENSE" }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } - }, - "entities.PhoneAPIKey": { - "type": "object", - "required": [ - "api_key", - "created_at", - "id", - "name", - "phone_ids", - "phone_numbers", - "updated_at", - "user_email", - "user_id" - ], - "properties": { - "api_key": { - "type": "string", - "example": "pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx" - }, - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "name": { - "type": "string", - "example": "Business Phone Key" - }, - "phone_ids": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "32343a19-da5e-4b1b-a767-3298a73703cb", - "32343a19-da5e-4b1b-a767-3298a73703cc" - ] - }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - }, - "example": ["+18005550199", "+18005550100"] - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "user_email": { - "type": "string", - "example": "user@gmail.com" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } - }, - "entities.User": { - "type": "object", - "required": [ - "api_key", - "created_at", - "email", - "id", - "notification_heartbeat_enabled", - "notification_message_status_enabled", - "notification_newsletter_enabled", - "notification_webhook_enabled", - "subscription_id", - "subscription_name", - "timezone", - "updated_at" - ], - "properties": { - "active_phone_id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "api_key": { - "type": "string", - "example": "x-api-key" - }, - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "email": { - "type": "string", - "example": "name@email.com" - }, - "id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - }, - "notification_heartbeat_enabled": { - "type": "boolean", - "example": true - }, - "notification_message_status_enabled": { - "type": "boolean", - "example": true - }, - "notification_newsletter_enabled": { - "type": "boolean", - "example": true - }, - "notification_webhook_enabled": { - "type": "boolean", - "example": true - }, - "subscription_ends_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "subscription_id": { - "type": "string", - "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" - }, - "subscription_name": { - "type": "string", - "example": "free" - }, - "subscription_renews_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "subscription_status": { - "type": "string", - "example": "on_trial" - }, - "timezone": { - "type": "string", - "example": "Europe/Helsinki" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - } - } - }, - "entities.Webhook": { - "type": "object", - "required": [ - "created_at", - "events", - "id", - "phone_numbers", - "signing_key", - "updated_at", - "url", - "user_id" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "events": { - "type": "array", - "items": { - "type": "string" - }, - "example": ["message.phone.received"] - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - }, - "example": ["+18005550199", "+18005550100"] - }, - "signing_key": { - "type": "string", - "example": "DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "url": { - "type": "string", - "example": "https://example.com" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } + "version": "1.0" }, - "requests.DiscordStore": { - "type": "object", - "required": ["incoming_channel_id", "name", "server_id"], - "properties": { - "incoming_channel_id": { - "type": "string" + "host": "api.httpsms.com", + "basePath": "/v1", + "paths": { + "/billing/usage": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the summary of sent and received messages for a user in the current month", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Billing" + ], + "summary": "Get Billing Usage.", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.BillingUsageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "name": { - "type": "string" + "/billing/usage-history": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get billing usage records of sent and received messages for a user in the past. It will be sorted by timestamp in descending order.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Billing" + ], + "summary": "Get billing usage history.", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of heartbeats to skip", + "name": "skip", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "description": "number of heartbeats to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.BillingUsagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "server_id": { - "type": "string" - } - } - }, - "requests.DiscordUpdate": { - "type": "object", - "required": ["incoming_channel_id", "name", "server_id"], - "properties": { - "incoming_channel_id": { - "type": "string" + "/bulk-messages": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "BulkSMS" + ], + "summary": "Store bulk SMS file", + "parameters": [ + { + "type": "file", + "description": "The Excel or CSV file containing the messages to be sent.", + "name": "document", + "in": "formData", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "name": { - "type": "string" + "/discord-integrations": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the discord integrations of a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "DiscordIntegration" + ], + "summary": "Get discord integrations of a user", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of discord integrations to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter discord integrations containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of discord integrations to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.DiscordsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Store a discord integration for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "DiscordIntegration" + ], + "summary": "Store discord integration", + "parameters": [ + { + "description": "Payload of the discord integration request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.DiscordStore" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.DiscordResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "server_id": { - "type": "string" - } - } - }, - "requests.HeartbeatStore": { - "type": "object", - "required": ["charging", "phone_numbers"], - "properties": { - "charging": { - "type": "boolean" + "/discord-integrations/{discordID}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a discord integration for the currently authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "DiscordIntegration" + ], + "summary": "Update a discord integration", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the discord integration", + "name": "discordID", + "in": "path", + "required": true + }, + { + "description": "Payload of discord integration to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.DiscordUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.DiscordResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a discord integration for a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Webhooks" + ], + "summary": "Delete discord integration", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the discord integration", + "name": "discordID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "requests.MessageBulkSend": { - "type": "object", - "required": ["content", "encrypted", "from", "to"], - "properties": { - "content": { - "type": "string", - "example": "This is a sample text message" + "/discord/event": { + "post": { + "description": "Publish a discord event to the registered listeners", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Discord" + ], + "summary": "Consume a discord event", + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "encrypted": { - "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", - "type": "boolean", - "example": false + "/heartbeats": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the last time a phone number requested for outstanding messages. It will be sorted by timestamp in descending order.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Heartbeats" + ], + "summary": "Get heartbeats of an owner phone number", + "parameters": [ + { + "type": "string", + "default": "+18005550199", + "description": "the owner's phone number", + "name": "owner", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of heartbeats to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of heartbeats to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.HeartbeatsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Store the heartbeat to make notify that a phone number is still active", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Heartbeats" + ], + "summary": "Register heartbeat of an owner phone number", + "parameters": [ + { + "description": "Payload of the heartbeat request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.HeartbeatStore" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.HeartbeatResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "from": { - "type": "string", - "example": "+18005550199" + "/integration/3cx/messages": { + "post": { + "description": "Sends an SMS message from the 3CX platform", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "3CXIntegration" + ], + "summary": "Sends a 3CX SMS message", + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "request_id": { - "description": "RequestID is an optional parameter used to track a request from the client's perspective", - "type": "string", - "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" + "/message-threads": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "MessageThreads" + ], + "summary": "Get message threads for a phone number", + "parameters": [ + { + "type": "string", + "default": "+18005550199", + "description": "owner phone number", + "name": "owner", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter message threads containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageThreadsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "to": { - "type": "array", - "items": { - "type": "string" - }, - "example": ["+18005550100", "+18005550100"] - } - } - }, - "requests.MessageCallMissed": { - "type": "object", - "required": ["from", "sim", "timestamp", "to"], - "properties": { - "from": { - "type": "string", - "example": "+18005550199" + "/message-threads/{messageThreadID}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the details of a message thread", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "MessageThreads" + ], + "summary": "Update a message thread", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message thread", + "name": "messageThreadID", + "in": "path", + "required": true + }, + { + "description": "Payload of message thread details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageThreadUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a message thread from the database and also deletes all the messages in the thread.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "MessageThreads" + ], + "summary": "Delete a message thread from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message thread", + "name": "messageThreadID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "sim": { - "type": "string", - "example": "SIM1" + "/messages": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get list of messages which are sent between 2 phone numbers. It will be sorted by timestamp in descending order.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Get messages which are sent between 2 phone numbers", + "parameters": [ + { + "type": "string", + "default": "+18005550199", + "description": "the owner's phone number", + "name": "owner", + "in": "query", + "required": true + }, + { + "type": "string", + "default": "+18005550100", + "description": "the contact's phone number", + "name": "contact", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter messages containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "timestamp": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" + "/messages/bulk-send": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add bulk SMS messages to be sent by the android phone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Send bulk SMS messages", + "parameters": [ + { + "description": "Bulk send message request payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageBulkSend" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/responses.MessagesResponse" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "to": { - "type": "string", - "example": "+18005550100" - } - } - }, - "requests.MessageEvent": { - "type": "object", - "required": ["event_name", "reason", "timestamp"], - "properties": { - "event_name": { - "description": "EventName is the type of event\n* SENT: is emitted when a message is sent by the mobile phone\n* FAILED: is event is emitted when the message could not be sent by the mobile phone\n* DELIVERED: is event is emitted when a delivery report has been received by the mobile phone", - "type": "string", - "example": "SENT" + "/messages/calls/missed": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Register a missed call event on the mobile phone", + "parameters": [ + { + "description": "Payload of the missed call event.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageCallMissed" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "reason": { - "description": "Reason is the exact error message in case the event is an error", - "type": "string" + "/messages/outstanding": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get an outstanding message to be sent by an android phone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Get an outstanding message", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703cb", + "description": "The ID of the message", + "name": "message_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "timestamp": { - "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible", - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - } - } - }, - "requests.MessageReceive": { - "type": "object", - "required": ["content", "encrypted", "from", "sim", "timestamp", "to"], - "properties": { - "content": { - "type": "string", - "example": "This is a sample text message received on a phone" + "/messages/receive": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a new message received from a mobile phone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Receive a new SMS message from a mobile phone", + "parameters": [ + { + "description": "Received message request payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageReceive" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "encrypted": { - "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", - "type": "boolean", - "example": false + "/messages/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This returns the list of all messages based on the filter criteria including missed calls", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Search all messages of a user", + "parameters": [ + { + "type": "string", + "description": "Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/", + "name": "token", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "+18005550199,+18005550100", + "description": "the owner's phone numbers", + "name": "owners", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter messages containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 200, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "from": { - "type": "string", - "example": "+18005550199" + "/messages/send": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a new SMS message to be sent by your Android phone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Send an SMS message", + "parameters": [ + { + "description": "Send message request payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageSend" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "sim": { - "description": "SIM card that received the message", - "type": "string", - "example": "SIM1" + "/messages/{messageID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a message from the database by the message ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Get a message from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message", + "name": "messageID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a message from the database and removes the message content from the list of threads.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Delete a message from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message", + "name": "messageID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "timestamp": { - "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible", - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" + "/messages/{messageID}/events": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Use this endpoint to send events for a message when it is failed, sent or delivered by the mobile phone.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Upsert an event for a message on the mobile phone", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message", + "name": "messageID", + "in": "path", + "required": true + }, + { + "description": "Payload of the event emitted.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageEvent" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "to": { - "type": "string", - "example": "+18005550100" - } - } - }, - "requests.MessageSend": { - "type": "object", - "required": ["content", "from", "to"], - "properties": { - "content": { - "type": "string", - "example": "This is a sample text message" + "/phone-api-keys": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get list phone API keys which a user has registered on the httpSMS application", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PhoneAPIKeys" + ], + "summary": "Get the phone API keys of a user", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of phone api keys to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter phone api keys with name containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "description": "number of phone api keys to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneAPIKeysResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new phone API key which can be used to log in to the httpSMS app on your Android phone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PhoneAPIKeys" + ], + "summary": "Store phone API key", + "parameters": [ + { + "description": "Payload of new phone API key.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.PhoneAPIKeyStoreRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneAPIKeyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "encrypted": { - "description": "Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", - "type": "boolean", - "example": false + "/phone-api-keys/{phoneAPIKeyID}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a phone API Key from the database and cannot be used for authentication anymore.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PhoneAPIKeys" + ], + "summary": "Delete a phone API key from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the phone API key", + "name": "phoneAPIKeyID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "from": { - "type": "string", - "example": "+18005550199" + "/phone-api-keys/{phoneAPIKeyID}/phones/{phoneID}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "You will need to login again to the httpSMS app on your Android phone with a new phone API key.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PhoneAPIKeys" + ], + "summary": "Remove the association of a phone from the phone API key.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the phone API key", + "name": "phoneAPIKeyID", + "in": "path", + "required": true + }, + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the phone", + "name": "phoneID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "request_id": { - "description": "RequestID is an optional parameter used to track a request from the client's perspective", - "type": "string", - "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" + "/phones": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get list of phones which a user has registered on the http sms application", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Phones" + ], + "summary": "Get phones of a user", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of heartbeats to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter phones containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of phones to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhonesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Phones" + ], + "summary": "Upsert Phone", + "parameters": [ + { + "description": "Payload of new phone number.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.PhoneUpsert" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "send_at": { - "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.", - "type": "string", - "example": "2025-12-19T16:39:57-08:00" + "/phones/fcm-token": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Phones" + ], + "summary": "Upserts the FCM token of a phone", + "parameters": [ + { + "description": "Payload of new FCM token.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.PhoneFCMToken" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "to": { - "type": "string", - "example": "+18005550100" - } - } - }, - "requests.MessageThreadUpdate": { - "type": "object", - "required": ["is_archived"], - "properties": { - "is_archived": { - "type": "boolean", - "example": true - } - } - }, - "requests.PhoneAPIKeyStoreRequest": { - "type": "object", - "required": ["name"], - "properties": { - "name": { - "type": "string", - "example": "My Phone API Key" - } - } - }, - "requests.PhoneFCMToken": { - "type": "object", - "required": ["fcm_token", "phone_number", "sim"], - "properties": { - "fcm_token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." + "/phones/{phoneID}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a phone that has been sored in the database", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Phones" + ], + "summary": "Delete Phone", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the phone", + "name": "phoneID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "phone_number": { - "type": "string", - "example": "[+18005550199]" + "/send-schedules": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Lists the send schedules owned by the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "List send schedules", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.SendSchedulesResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a send schedule for the authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "Create send schedule", + "parameters": [ + { + "description": "Payload of new send schedule.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.SendScheduleStore" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.SendScheduleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "sim": { - "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", - "type": "string", - "example": "SIM1" - } - } - }, - "requests.PhoneUpsert": { - "type": "object", - "required": [ - "fcm_token", - "max_send_attempts", - "message_expiration_seconds", - "messages_per_minute", - "missed_call_auto_reply", - "phone_number", - "sim" - ], - "properties": { - "fcm_token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." + "/send-schedules/{scheduleID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Loads a single send schedule owned by the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "Show send schedule", + "parameters": [ + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.SendScheduleResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates a send schedule owned by the authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "Update send schedule", + "parameters": [ + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleID", + "in": "path", + "required": true + }, + { + "description": "Payload of updated send schedule.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.SendScheduleStore" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.SendScheduleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a send schedule owned by the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Send Schedules" + ], + "summary": "Delete send schedule", + "parameters": [ + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "max_send_attempts": { - "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.", - "type": "integer", - "example": 2 + "/users/me": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get details of the currently authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the details of the currently authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update a user", + "parameters": [ + { + "description": "Payload of user details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UserUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes the currently authenticated user together with all their data.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Delete a user", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "message_expiration_seconds": { - "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.", - "type": "integer", - "example": 12345 + "/users/subscription": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Cancel the subscription of the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Cancel the user's subscription", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "messages_per_minute": { - "type": "integer", - "example": 1 + "/users/subscription-update-url": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Fetches the subscription URL of the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Currently authenticated user subscription update URL", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.OkString" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "missed_call_auto_reply": { - "type": "string", - "example": "e.g. This phone cannot receive calls. Please send an SMS instead." + "/users/subscription/invoices/{subscriptionInvoiceID}": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/pdf" + ], + "tags": [ + "Users" + ], + "summary": "Generate a subscription payment invoice", + "parameters": [ + { + "description": "Generate subscription payment invoice parameters", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UserPaymentInvoice" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "phone_number": { - "type": "string", - "example": "+18005550199" + "/users/subscription/payments": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get the last 10 subscription payments.", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "sim": { - "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", - "type": "string", - "example": "SIM1" - } - } - }, - "requests.UserNotificationUpdate": { - "type": "object", - "required": [ - "heartbeat_enabled", - "message_status_enabled", - "newsletter_enabled", - "webhook_enabled" - ], - "properties": { - "heartbeat_enabled": { - "type": "boolean", - "example": true + "/users/{userID}/api-keys": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Rotate the user's API key in case the current API Key is compromised", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Rotate the user's API Key", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the user to update", + "name": "userID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "message_status_enabled": { - "type": "boolean", - "example": true + "/users/{userID}/notifications": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update the email notification settings for a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update notification settings", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the user to update", + "name": "userID", + "in": "path", + "required": true + }, + { + "description": "User notification details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UserNotificationUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "newsletter_enabled": { - "type": "boolean", - "example": true + "/webhooks": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the webhooks of a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Webhooks" + ], + "summary": "Get webhooks of a user", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of webhooks to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter webhooks containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of webhooks to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.WebhooksResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Store a webhook for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Webhooks" + ], + "summary": "Store a webhook", + "parameters": [ + { + "description": "Payload of the webhook request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.WebhookStore" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.WebhookResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } }, - "webhook_enabled": { - "type": "boolean", - "example": true + "/webhooks/{webhookID}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a webhook for the currently authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Webhooks" + ], + "summary": "Update a webhook", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the webhook", + "name": "webhookID", + "in": "path", + "required": true + }, + { + "description": "Payload of webhook details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.WebhookUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.WebhookResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a webhook for a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Webhooks" + ], + "summary": "Delete webhook", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the webhook", + "name": "webhookID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } } - } }, - "requests.UserPaymentInvoice": { - "type": "object", - "required": [ - "address", - "city", - "country", - "name", - "notes", - "state", - "zip_code" - ], - "properties": { - "address": { - "type": "string", - "example": "221B Baker Street, London" - }, - "city": { - "type": "string", - "example": "Los Angeles" + "definitions": { + "entities.BillingUsage": { + "type": "object", + "required": [ + "created_at", + "end_timestamp", + "id", + "received_messages", + "sent_messages", + "start_timestamp", + "total_cost", + "updated_at", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "end_timestamp": { + "type": "string", + "example": "2022-01-31T23:59:59+00:00" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "received_messages": { + "type": "integer", + "example": 465 + }, + "sent_messages": { + "type": "integer", + "example": 321 + }, + "start_timestamp": { + "type": "string", + "example": "2022-01-01T00:00:00+00:00" + }, + "total_cost": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } }, - "country": { - "type": "string", - "example": "US" + "entities.Discord": { + "type": "object", + "required": [ + "created_at", + "id", + "incoming_channel_id", + "name", + "server_id", + "updated_at", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "incoming_channel_id": { + "type": "string", + "example": "1095780203256627291" + }, + "name": { + "type": "string", + "example": "Game Server" + }, + "server_id": { + "type": "string", + "example": "1095778291488653372" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } }, - "name": { - "type": "string", - "example": "Acme Corp" + "entities.Heartbeat": { + "type": "object", + "required": [ + "charging", + "id", + "owner", + "timestamp", + "user_id", + "version" + ], + "properties": { + "charging": { + "type": "boolean", + "example": true + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "owner": { + "type": "string", + "example": "+18005550199" + }, + "timestamp": { + "type": "string", + "example": "2022-06-05T14:26:01.520828+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + }, + "version": { + "type": "string", + "example": "344c10f" + } + } }, - "notes": { - "type": "string", - "example": "Thank you for your business!" + "entities.Message": { + "type": "object", + "required": [ + "contact", + "content", + "created_at", + "encrypted", + "id", + "max_send_attempts", + "order_timestamp", + "owner", + "request_received_at", + "send_attempt_count", + "sim", + "status", + "type", + "updated_at", + "user_id" + ], + "properties": { + "contact": { + "type": "string", + "example": "+18005550100" + }, + "content": { + "type": "string", + "example": "This is a sample text message" + }, + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "delivered_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "encrypted": { + "type": "boolean", + "example": false + }, + "expired_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "failed_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "failure_reason": { + "type": "string", + "example": "UNKNOWN" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "last_attempted_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "max_send_attempts": { + "type": "integer", + "example": 1 + }, + "order_timestamp": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "owner": { + "type": "string", + "example": "+18005550199" + }, + "received_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "request_id": { + "type": "string", + "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" + }, + "request_received_at": { + "type": "string", + "example": "2022-06-05T14:26:01.520828+03:00" + }, + "scheduled_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "scheduled_send_time": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "send_attempt_count": { + "type": "integer", + "example": 0 + }, + "send_time": { + "description": "SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message", + "type": "integer", + "example": 133414 + }, + "sent_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "sim": { + "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], + "example": "DEFAULT" + }, + "status": { + "type": "string", + "example": "pending" + }, + "type": { + "type": "string", + "example": "mobile-terminated" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } }, - "state": { - "type": "string", - "example": "CA" + "entities.MessageThread": { + "type": "object", + "required": [ + "color", + "contact", + "created_at", + "id", + "is_archived", + "last_message_content", + "last_message_id", + "order_timestamp", + "owner", + "status", + "updated_at", + "user_id" + ], + "properties": { + "color": { + "type": "string", + "example": "indigo" + }, + "contact": { + "type": "string", + "example": "+18005550100" + }, + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703ca" + }, + "is_archived": { + "type": "boolean", + "example": false + }, + "last_message_content": { + "type": "string", + "example": "This is a sample message content" + }, + "last_message_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703ca" + }, + "order_timestamp": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "owner": { + "type": "string", + "example": "+18005550199" + }, + "status": { + "type": "string", + "example": "PENDING" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } }, - "zip_code": { - "type": "string", - "example": "9800" - } - } - }, - "requests.UserUpdate": { - "type": "object", - "required": ["active_phone_id", "timezone"], - "properties": { - "active_phone_id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + "entities.Phone": { + "type": "object", + "required": [ + "created_at", + "id", + "max_send_attempts", + "message_expiration_seconds", + "messages_per_minute", + "phone_number", + "schedule_id", + "sim", + "updated_at", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "fcm_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "max_send_attempts": { + "description": "MaxSendAttempts determines how many times to retry sending an SMS message", + "type": "integer", + "example": 2 + }, + "message_expiration_seconds": { + "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.", + "type": "integer" + }, + "messages_per_minute": { + "type": "integer", + "example": 1 + }, + "missed_call_auto_reply": { + "type": "string", + "example": "This phone cannot receive calls. Please send an SMS instead." + }, + "phone_number": { + "type": "string", + "example": "+18005550199" + }, + "schedule_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "sim": { + "$ref": "#/definitions/entities.SIM" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } }, - "timezone": { - "type": "string", - "example": "Europe/Helsinki" - } - } - }, - "requests.WebhookStore": { - "type": "object", - "required": ["events", "phone_numbers", "signing_key", "url"], - "properties": { - "events": { - "type": "array", - "items": { - "type": "string" - } + "entities.PhoneAPIKey": { + "type": "object", + "required": [ + "api_key", + "created_at", + "id", + "name", + "phone_ids", + "phone_numbers", + "updated_at", + "user_email", + "user_id" + ], + "properties": { + "api_key": { + "type": "string", + "example": "pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx" + }, + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "name": { + "type": "string", + "example": "Business Phone Key" + }, + "phone_ids": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "32343a19-da5e-4b1b-a767-3298a73703cb", + "32343a19-da5e-4b1b-a767-3298a73703cc" + ] + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "+18005550199", + "+18005550100" + ] + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "user_email": { + "type": "string", + "example": "user@gmail.com" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - }, - "example": ["+18005550100", "+18005550100"] + "entities.SIM": { + "type": "string", + "enum": [ + "SIM1", + "SIM2" + ], + "x-enum-varnames": [ + "SIM1", + "SIM2" + ] + }, + "entities.SendSchedule": { + "type": "object", + "required": [ + "created_at", + "id", + "is_active", + "name", + "timezone", + "updated_at", + "user_id", + "windows" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "is_active": { + "type": "boolean", + "example": true + }, + "name": { + "type": "string", + "example": "Business Hours" + }, + "timezone": { + "type": "string", + "example": "Europe/Tallinn" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + }, + "windows": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.SendScheduleWindow" + } + } + } }, - "signing_key": { - "type": "string" + "entities.SendScheduleWindow": { + "type": "object", + "required": [ + "day_of_week", + "end_minute", + "start_minute" + ], + "properties": { + "day_of_week": { + "type": "integer", + "example": 1 + }, + "end_minute": { + "type": "integer", + "example": 1020 + }, + "start_minute": { + "type": "integer", + "example": 540 + } + } }, - "url": { - "type": "string" - } - } - }, - "requests.WebhookUpdate": { - "type": "object", - "required": ["events", "phone_numbers", "signing_key", "url"], - "properties": { - "events": { - "type": "array", - "items": { - "type": "string" - } + "entities.SubscriptionName": { + "type": "string", + "enum": [ + "free", + "pro-monthly", + "pro-yearly", + "ultra-monthly", + "ultra-yearly", + "pro-lifetime", + "20k-monthly", + "100k-monthly", + "50k-monthly", + "200k-monthly", + "20k-yearly" + ], + "x-enum-varnames": [ + "SubscriptionNameFree", + "SubscriptionNameProMonthly", + "SubscriptionNameProYearly", + "SubscriptionNameUltraMonthly", + "SubscriptionNameUltraYearly", + "SubscriptionNameProLifetime", + "SubscriptionName20KMonthly", + "SubscriptionName100KMonthly", + "SubscriptionName50KMonthly", + "SubscriptionName200KMonthly", + "SubscriptionName20KYearly" + ] + }, + "entities.User": { + "type": "object", + "required": [ + "api_key", + "created_at", + "email", + "id", + "notification_heartbeat_enabled", + "notification_message_status_enabled", + "notification_newsletter_enabled", + "notification_webhook_enabled", + "subscription_id", + "subscription_name", + "timezone", + "updated_at" + ], + "properties": { + "active_phone_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "api_key": { + "type": "string", + "example": "x-api-key" + }, + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "email": { + "type": "string", + "example": "name@email.com" + }, + "id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + }, + "notification_heartbeat_enabled": { + "type": "boolean", + "example": true + }, + "notification_message_status_enabled": { + "type": "boolean", + "example": true + }, + "notification_newsletter_enabled": { + "type": "boolean", + "example": true + }, + "notification_webhook_enabled": { + "type": "boolean", + "example": true + }, + "subscription_ends_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "subscription_id": { + "type": "string", + "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" + }, + "subscription_name": { + "allOf": [ + { + "$ref": "#/definitions/entities.SubscriptionName" + } + ], + "example": "free" + }, + "subscription_renews_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "subscription_status": { + "type": "string", + "example": "on_trial" + }, + "timezone": { + "type": "string", + "example": "Europe/Helsinki" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + } + } }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - }, - "example": ["+18005550100", "+18005550100"] + "entities.Webhook": { + "type": "object", + "required": [ + "created_at", + "events", + "id", + "phone_numbers", + "signing_key", + "updated_at", + "url", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "events": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "message.phone.received" + ] + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "+18005550199", + "+18005550100" + ] + }, + "signing_key": { + "type": "string", + "example": "DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "url": { + "type": "string", + "example": "https://example.com" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } }, - "signing_key": { - "type": "string" + "requests.DiscordStore": { + "type": "object", + "required": [ + "incoming_channel_id", + "name", + "server_id" + ], + "properties": { + "incoming_channel_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "server_id": { + "type": "string" + } + } }, - "url": { - "type": "string" - } - } - }, - "responses.BadRequest": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "string", - "example": "The request body is not a valid JSON string" + "requests.DiscordUpdate": { + "type": "object", + "required": [ + "incoming_channel_id", + "name", + "server_id" + ], + "properties": { + "incoming_channel_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "server_id": { + "type": "string" + } + } }, - "message": { - "type": "string", - "example": "The request isn't properly formed" + "requests.HeartbeatStore": { + "type": "object", + "required": [ + "charging", + "phone_numbers" + ], + "properties": { + "charging": { + "type": "boolean" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + } + } + } }, - "status": { - "type": "string", - "example": "error" - } - } - }, - "responses.BillingUsageResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/entities.BillingUsage" + "requests.MessageBulkSend": { + "type": "object", + "required": [ + "content", + "encrypted", + "from", + "to" + ], + "properties": { + "content": { + "type": "string", + "example": "This is a sample text message" + }, + "encrypted": { + "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", + "type": "boolean", + "example": false + }, + "from": { + "type": "string", + "example": "+18005550199" + }, + "request_id": { + "description": "RequestID is an optional parameter used to track a request from the client's perspective", + "type": "string", + "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" + }, + "to": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "+18005550100", + "+18005550100" + ] + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "requests.MessageCallMissed": { + "type": "object", + "required": [ + "from", + "sim", + "timestamp", + "to" + ], + "properties": { + "from": { + "type": "string", + "example": "+18005550199" + }, + "sim": { + "type": "string", + "example": "SIM1" + }, + "timestamp": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "to": { + "type": "string", + "example": "+18005550100" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.BillingUsagesResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.BillingUsage" - } + "requests.MessageEvent": { + "type": "object", + "required": [ + "event_name", + "reason", + "timestamp" + ], + "properties": { + "event_name": { + "description": "EventName is the type of event\n* SENT: is emitted when a message is sent by the mobile phone\n* FAILED: is event is emitted when the message could not be sent by the mobile phone\n* DELIVERED: is event is emitted when a delivery report has been received by the mobile phone", + "type": "string", + "example": "SENT" + }, + "reason": { + "description": "Reason is the exact error message in case the event is an error", + "type": "string" + }, + "timestamp": { + "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible", + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "requests.MessageReceive": { + "type": "object", + "required": [ + "content", + "encrypted", + "from", + "sim", + "timestamp", + "to" + ], + "properties": { + "content": { + "type": "string", + "example": "This is a sample text message received on a phone" + }, + "encrypted": { + "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", + "type": "boolean", + "example": false + }, + "from": { + "type": "string", + "example": "+18005550199" + }, + "sim": { + "description": "SIM card that received the message", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], + "example": "SIM1" + }, + "timestamp": { + "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible", + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "to": { + "type": "string", + "example": "+18005550100" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.DiscordResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/entities.Discord" + "requests.MessageSend": { + "type": "object", + "required": [ + "content", + "from", + "to" + ], + "properties": { + "content": { + "type": "string", + "example": "This is a sample text message" + }, + "encrypted": { + "description": "Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", + "type": "boolean", + "example": false + }, + "from": { + "type": "string", + "example": "+18005550199" + }, + "request_id": { + "description": "RequestID is an optional parameter used to track a request from the client's perspective", + "type": "string", + "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" + }, + "send_at": { + "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.", + "type": "string", + "example": "2025-12-19T16:39:57-08:00" + }, + "to": { + "type": "string", + "example": "+18005550100" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "requests.MessageThreadUpdate": { + "type": "object", + "required": [ + "is_archived" + ], + "properties": { + "is_archived": { + "type": "boolean", + "example": true + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.DiscordsResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Discord" - } + "requests.PhoneAPIKeyStoreRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "example": "My Phone API Key" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "requests.PhoneFCMToken": { + "type": "object", + "required": [ + "fcm_token", + "phone_number", + "sim" + ], + "properties": { + "fcm_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." + }, + "phone_number": { + "type": "string", + "example": "[+18005550199]" + }, + "sim": { + "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", + "type": "string", + "example": "SIM1" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.HeartbeatResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/entities.Heartbeat" + "requests.PhoneUpsert": { + "type": "object", + "required": [ + "fcm_token", + "max_send_attempts", + "message_expiration_seconds", + "messages_per_minute", + "missed_call_auto_reply", + "phone_number", + "schedule_id", + "sim" + ], + "properties": { + "fcm_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." + }, + "max_send_attempts": { + "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.", + "type": "integer", + "example": 2 + }, + "message_expiration_seconds": { + "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.", + "type": "integer", + "example": 12345 + }, + "messages_per_minute": { + "type": "integer", + "example": 1 + }, + "missed_call_auto_reply": { + "type": "string", + "example": "e.g. This phone cannot receive calls. Please send an SMS instead." + }, + "phone_number": { + "type": "string", + "example": "+18005550199" + }, + "schedule_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "sim": { + "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", + "type": "string", + "example": "SIM1" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "requests.SendScheduleStore": { + "type": "object", + "required": [ + "is_active", + "name", + "timezone", + "windows" + ], + "properties": { + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "windows": { + "type": "array", + "items": { + "$ref": "#/definitions/requests.SendScheduleWindow" + } + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.HeartbeatsResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Heartbeat" - } + "requests.SendScheduleWindow": { + "type": "object", + "required": [ + "day_of_week", + "end_minute", + "start_minute" + ], + "properties": { + "day_of_week": { + "type": "integer" + }, + "end_minute": { + "type": "integer" + }, + "start_minute": { + "type": "integer" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "requests.UserNotificationUpdate": { + "type": "object", + "required": [ + "heartbeat_enabled", + "message_status_enabled", + "newsletter_enabled", + "webhook_enabled" + ], + "properties": { + "heartbeat_enabled": { + "type": "boolean", + "example": true + }, + "message_status_enabled": { + "type": "boolean", + "example": true + }, + "newsletter_enabled": { + "type": "boolean", + "example": true + }, + "webhook_enabled": { + "type": "boolean", + "example": true + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.InternalServerError": { - "type": "object", - "required": ["message", "status"], - "properties": { - "message": { - "type": "string", - "example": "We ran into an internal error while handling the request." + "requests.UserPaymentInvoice": { + "type": "object", + "required": [ + "address", + "city", + "country", + "name", + "notes", + "state", + "zip_code" + ], + "properties": { + "address": { + "type": "string", + "example": "221B Baker Street, London" + }, + "city": { + "type": "string", + "example": "Los Angeles" + }, + "country": { + "type": "string", + "example": "US" + }, + "name": { + "type": "string", + "example": "Acme Corp" + }, + "notes": { + "type": "string", + "example": "Thank you for your business!" + }, + "state": { + "type": "string", + "example": "CA" + }, + "zip_code": { + "type": "string", + "example": "9800" + } + } }, - "status": { - "type": "string", - "example": "error" - } - } - }, - "responses.MessageResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/entities.Message" + "requests.UserUpdate": { + "type": "object", + "required": [ + "active_phone_id", + "timezone" + ], + "properties": { + "active_phone_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "timezone": { + "type": "string", + "example": "Europe/Helsinki" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "requests.WebhookStore": { + "type": "object", + "required": [ + "events", + "phone_numbers", + "signing_key", + "url" + ], + "properties": { + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "+18005550100", + "+18005550100" + ] + }, + "signing_key": { + "type": "string" + }, + "url": { + "type": "string" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.MessageThreadsResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.MessageThread" - } + "requests.WebhookUpdate": { + "type": "object", + "required": [ + "events", + "phone_numbers", + "signing_key", + "url" + ], + "properties": { + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "+18005550100", + "+18005550100" + ] + }, + "signing_key": { + "type": "string" + }, + "url": { + "type": "string" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.BadRequest": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "string", + "example": "The request body is not a valid JSON string" + }, + "message": { + "type": "string", + "example": "The request isn't properly formed" + }, + "status": { + "type": "string", + "example": "error" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.MessagesResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Message" - } + "responses.BillingUsageResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.BillingUsage" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.BillingUsagesResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.BillingUsage" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.NoContent": { - "type": "object", - "required": ["message", "status"], - "properties": { - "message": { - "type": "string", - "example": "action performed successfully" + "responses.DiscordResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.Discord" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.NotFound": { - "type": "object", - "required": ["message", "status"], - "properties": { - "message": { - "type": "string", - "example": "cannot find message with ID [32343a19-da5e-4b1b-a767-3298a73703ca]" + "responses.DiscordsResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Discord" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "error" - } - } - }, - "responses.OkString": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "string" + "responses.HeartbeatResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.Heartbeat" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.HeartbeatsResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Heartbeat" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.PhoneAPIKeyResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/entities.PhoneAPIKey" + "responses.InternalServerError": { + "type": "object", + "required": [ + "message", + "status" + ], + "properties": { + "message": { + "type": "string", + "example": "We ran into an internal error while handling the request." + }, + "status": { + "type": "string", + "example": "error" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.MessageResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.Message" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.PhoneAPIKeysResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.PhoneAPIKey" - } + "responses.MessageThreadsResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.MessageThread" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.MessagesResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Message" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.PhoneResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/entities.Phone" + "responses.NoContent": { + "type": "object", + "required": [ + "message", + "status" + ], + "properties": { + "message": { + "type": "string", + "example": "action performed successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.NotFound": { + "type": "object", + "required": [ + "message", + "status" + ], + "properties": { + "message": { + "type": "string", + "example": "cannot find message with ID [32343a19-da5e-4b1b-a767-3298a73703ca]" + }, + "status": { + "type": "string", + "example": "error" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.PhonesResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Phone" - } + "responses.OkString": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "string" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.PhoneAPIKeyResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.PhoneAPIKey" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.Unauthorized": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "string", - "example": "Make sure your API key is set in the [X-API-Key] header in the request" + "responses.PhoneAPIKeysResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.PhoneAPIKey" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "message": { - "type": "string", - "example": "You are not authorized to carry out this request." + "responses.PhoneResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.Phone" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "error" - } - } - }, - "responses.UnprocessableEntity": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" + "responses.PhonesResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Phone" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } } - } }, - "message": { - "type": "string", - "example": "validation errors while handling request" + "responses.SendScheduleResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.SendSchedule" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "error" - } - } - }, - "responses.UserResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/entities.User" + "responses.SendSchedulesResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.SendSchedule" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.Unauthorized": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "string", + "example": "Make sure your API key is set in the [X-API-Key] header in the request" + }, + "message": { + "type": "string", + "example": "You are not authorized to carry out this request." + }, + "status": { + "type": "string", + "example": "error" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.UserSubscriptionPaymentsResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { + "responses.UnprocessableEntity": { "type": "object", - "required": ["attributes", "id", "type"], + "required": [ + "data", + "message", + "status" + ], "properties": { - "attributes": { - "type": "object", - "required": [ - "billing_reason", - "card_brand", - "card_last_four", - "created_at", - "currency", - "currency_rate", - "discount_total", - "discount_total_formatted", - "discount_total_usd", - "refunded", - "refunded_amount", - "refunded_amount_formatted", - "refunded_amount_usd", - "refunded_at", - "status", - "status_formatted", - "subtotal", - "subtotal_formatted", - "subtotal_usd", - "tax", - "tax_formatted", - "tax_inclusive", - "tax_usd", - "total", - "total_formatted", - "total_usd", - "updated_at" - ], - "properties": { - "billing_reason": { - "type": "string" - }, - "card_brand": { - "type": "string" - }, - "card_last_four": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "currency_rate": { - "type": "string" - }, - "discount_total": { - "type": "integer" - }, - "discount_total_formatted": { - "type": "string" - }, - "discount_total_usd": { - "type": "integer" - }, - "refunded": { - "type": "boolean" - }, - "refunded_amount": { - "type": "integer" - }, - "refunded_amount_formatted": { - "type": "string" - }, - "refunded_amount_usd": { - "type": "integer" - }, - "refunded_at": {}, - "status": { - "type": "string" - }, - "status_formatted": { - "type": "string" - }, - "subtotal": { - "type": "integer" - }, - "subtotal_formatted": { - "type": "string" - }, - "subtotal_usd": { - "type": "integer" - }, - "tax": { - "type": "integer" - }, - "tax_formatted": { - "type": "string" - }, - "tax_inclusive": { - "type": "boolean" - }, - "tax_usd": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "total_formatted": { - "type": "string" - }, - "total_usd": { - "type": "integer" - }, - "updated_at": { - "type": "string" - } + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "message": { + "type": "string", + "example": "validation errors while handling request" + }, + "status": { + "type": "string", + "example": "error" } - }, - "id": { - "type": "string" - }, - "type": { - "type": "string" - } } - } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.UserResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.User" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.WebhookResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/entities.Webhook" + "responses.UserSubscriptionPaymentsResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "attributes", + "id", + "type" + ], + "properties": { + "attributes": { + "type": "object", + "required": [ + "billing_reason", + "card_brand", + "card_last_four", + "created_at", + "currency", + "currency_rate", + "discount_total", + "discount_total_formatted", + "discount_total_usd", + "refunded", + "refunded_amount", + "refunded_amount_formatted", + "refunded_amount_usd", + "refunded_at", + "status", + "status_formatted", + "subtotal", + "subtotal_formatted", + "subtotal_usd", + "tax", + "tax_formatted", + "tax_inclusive", + "tax_usd", + "total", + "total_formatted", + "total_usd", + "updated_at" + ], + "properties": { + "billing_reason": { + "type": "string" + }, + "card_brand": { + "type": "string" + }, + "card_last_four": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "currency_rate": { + "type": "string" + }, + "discount_total": { + "type": "integer" + }, + "discount_total_formatted": { + "type": "string" + }, + "discount_total_usd": { + "type": "integer" + }, + "refunded": { + "type": "boolean" + }, + "refunded_amount": { + "type": "integer" + }, + "refunded_amount_formatted": { + "type": "string" + }, + "refunded_amount_usd": { + "type": "integer" + }, + "refunded_at": {}, + "status": { + "type": "string" + }, + "status_formatted": { + "type": "string" + }, + "subtotal": { + "type": "integer" + }, + "subtotal_formatted": { + "type": "string" + }, + "subtotal_usd": { + "type": "integer" + }, + "tax": { + "type": "integer" + }, + "tax_formatted": { + "type": "string" + }, + "tax_inclusive": { + "type": "boolean" + }, + "tax_usd": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_formatted": { + "type": "string" + }, + "total_usd": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "message": { - "type": "string", - "example": "Request handled successfully" + "responses.WebhookResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.Webhook" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } }, - "status": { - "type": "string", - "example": "success" + "responses.WebhooksResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Webhook" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } } - } }, - "responses.WebhooksResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Webhook" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "x-api-Key", + "in": "header" } - } - } - }, - "securityDefinitions": { - "ApiKeyAuth": { - "type": "apiKey", - "name": "x-api-Key", - "in": "header" } - } -} +} \ No newline at end of file diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index e8b171eb..e4bc41b1 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -30,15 +30,15 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - created_at - - end_timestamp - - id - - received_messages - - sent_messages - - start_timestamp - - total_cost - - updated_at - - user_id + - created_at + - end_timestamp + - id + - received_messages + - sent_messages + - start_timestamp + - total_cost + - updated_at + - user_id type: object entities.Discord: properties: @@ -64,13 +64,13 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - created_at - - id - - incoming_channel_id - - name - - server_id - - updated_at - - user_id + - created_at + - id + - incoming_channel_id + - name + - server_id + - updated_at + - user_id type: object entities.Heartbeat: properties: @@ -93,12 +93,12 @@ definitions: example: 344c10f type: string required: - - charging - - id - - owner - - timestamp - - user_id - - version + - charging + - id + - owner + - timestamp + - user_id + - version type: object entities.Message: properties: @@ -160,8 +160,7 @@ definitions: example: 0 type: integer send_time: - description: - SendDuration is the number of nanoseconds from when the request + description: SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message example: 133414 type: integer @@ -169,13 +168,14 @@ definitions: example: "2022-06-05T14:26:09.527976+03:00" type: string sim: + allOf: + - $ref: '#/definitions/entities.SIM' description: |- SIM is the SIM card to use to send the message * SMS1: use the SIM card in slot 1 * SMS2: use the SIM card in slot 2 * DEFAULT: used the default communication SIM card example: DEFAULT - type: string status: example: pending type: string @@ -189,21 +189,21 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - contact - - content - - created_at - - encrypted - - id - - max_send_attempts - - order_timestamp - - owner - - request_received_at - - send_attempt_count - - sim - - status - - type - - updated_at - - user_id + - contact + - content + - created_at + - encrypted + - id + - max_send_attempts + - order_timestamp + - owner + - request_received_at + - send_attempt_count + - sim + - status + - type + - updated_at + - user_id type: object entities.MessageThread: properties: @@ -244,18 +244,18 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - color - - contact - - created_at - - id - - is_archived - - last_message_content - - last_message_id - - order_timestamp - - owner - - status - - updated_at - - user_id + - color + - contact + - created_at + - id + - is_archived + - last_message_content + - last_message_id + - order_timestamp + - owner + - status + - updated_at + - user_id type: object entities.Phone: properties: @@ -269,14 +269,12 @@ definitions: example: 32343a19-da5e-4b1b-a767-3298a73703cb type: string max_send_attempts: - description: - MaxSendAttempts determines how many times to retry sending an + description: MaxSendAttempts determines how many times to retry sending an SMS message example: 2 type: integer message_expiration_seconds: - description: - MessageExpirationSeconds is the duration in seconds after sending + description: MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired. type: integer messages_per_minute: @@ -288,9 +286,11 @@ definitions: phone_number: example: "+18005550199" type: string - sim: - description: SIM card that received the message + schedule_id: + example: 32343a19-da5e-4b1b-a767-3298a73703cb type: string + sim: + $ref: '#/definitions/entities.SIM' updated_at: example: "2022-06-05T14:26:10.303278+03:00" type: string @@ -298,15 +298,16 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - created_at - - id - - max_send_attempts - - message_expiration_seconds - - messages_per_minute - - phone_number - - sim - - updated_at - - user_id + - created_at + - id + - max_send_attempts + - message_expiration_seconds + - messages_per_minute + - phone_number + - schedule_id + - sim + - updated_at + - user_id type: object entities.PhoneAPIKey: properties: @@ -324,15 +325,15 @@ definitions: type: string phone_ids: example: - - 32343a19-da5e-4b1b-a767-3298a73703cb - - 32343a19-da5e-4b1b-a767-3298a73703cc + - 32343a19-da5e-4b1b-a767-3298a73703cb + - 32343a19-da5e-4b1b-a767-3298a73703cc items: type: string type: array phone_numbers: example: - - "+18005550199" - - "+18005550100" + - "+18005550199" + - "+18005550100" items: type: string type: array @@ -346,16 +347,103 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - api_key - - created_at - - id - - name - - phone_ids - - phone_numbers - - updated_at - - user_email - - user_id + - api_key + - created_at + - id + - name + - phone_ids + - phone_numbers + - updated_at + - user_email + - user_id + type: object + entities.SIM: + enum: + - SIM1 + - SIM2 + type: string + x-enum-varnames: + - SIM1 + - SIM2 + entities.SendSchedule: + properties: + created_at: + example: "2022-06-05T14:26:02.302718+03:00" + type: string + id: + example: 32343a19-da5e-4b1b-a767-3298a73703cb + type: string + is_active: + example: true + type: boolean + name: + example: Business Hours + type: string + timezone: + example: Europe/Tallinn + type: string + updated_at: + example: "2022-06-05T14:26:10.303278+03:00" + type: string + user_id: + example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC + type: string + windows: + items: + $ref: '#/definitions/entities.SendScheduleWindow' + type: array + required: + - created_at + - id + - is_active + - name + - timezone + - updated_at + - user_id + - windows + type: object + entities.SendScheduleWindow: + properties: + day_of_week: + example: 1 + type: integer + end_minute: + example: 1020 + type: integer + start_minute: + example: 540 + type: integer + required: + - day_of_week + - end_minute + - start_minute type: object + entities.SubscriptionName: + enum: + - free + - pro-monthly + - pro-yearly + - ultra-monthly + - ultra-yearly + - pro-lifetime + - 20k-monthly + - 100k-monthly + - 50k-monthly + - 200k-monthly + - 20k-yearly + type: string + x-enum-varnames: + - SubscriptionNameFree + - SubscriptionNameProMonthly + - SubscriptionNameProYearly + - SubscriptionNameUltraMonthly + - SubscriptionNameUltraYearly + - SubscriptionNameProLifetime + - SubscriptionName20KMonthly + - SubscriptionName100KMonthly + - SubscriptionName50KMonthly + - SubscriptionName200KMonthly + - SubscriptionName20KYearly entities.User: properties: active_phone_id: @@ -392,8 +480,9 @@ definitions: example: 8f9c71b8-b84e-4417-8408-a62274f65a08 type: string subscription_name: + allOf: + - $ref: '#/definitions/entities.SubscriptionName' example: free - type: string subscription_renews_at: example: "2022-06-05T14:26:02.302718+03:00" type: string @@ -407,18 +496,18 @@ definitions: example: "2022-06-05T14:26:10.303278+03:00" type: string required: - - api_key - - created_at - - email - - id - - notification_heartbeat_enabled - - notification_message_status_enabled - - notification_newsletter_enabled - - notification_webhook_enabled - - subscription_id - - subscription_name - - timezone - - updated_at + - api_key + - created_at + - email + - id + - notification_heartbeat_enabled + - notification_message_status_enabled + - notification_newsletter_enabled + - notification_webhook_enabled + - subscription_id + - subscription_name + - timezone + - updated_at type: object entities.Webhook: properties: @@ -427,7 +516,7 @@ definitions: type: string events: example: - - message.phone.received + - message.phone.received items: type: string type: array @@ -436,8 +525,8 @@ definitions: type: string phone_numbers: example: - - "+18005550199" - - "+18005550100" + - "+18005550199" + - "+18005550100" items: type: string type: array @@ -454,14 +543,14 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - created_at - - events - - id - - phone_numbers - - signing_key - - updated_at - - url - - user_id + - created_at + - events + - id + - phone_numbers + - signing_key + - updated_at + - url + - user_id type: object requests.DiscordStore: properties: @@ -472,9 +561,9 @@ definitions: server_id: type: string required: - - incoming_channel_id - - name - - server_id + - incoming_channel_id + - name + - server_id type: object requests.DiscordUpdate: properties: @@ -485,9 +574,9 @@ definitions: server_id: type: string required: - - incoming_channel_id - - name - - server_id + - incoming_channel_id + - name + - server_id type: object requests.HeartbeatStore: properties: @@ -498,8 +587,8 @@ definitions: type: string type: array required: - - charging - - phone_numbers + - charging + - phone_numbers type: object requests.MessageBulkSend: properties: @@ -507,8 +596,7 @@ definitions: example: This is a sample text message type: string encrypted: - description: - Encrypted is used to determine if the content is end-to-end encrypted. + description: Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app example: false type: boolean @@ -516,23 +604,22 @@ definitions: example: "+18005550199" type: string request_id: - description: - RequestID is an optional parameter used to track a request from + description: RequestID is an optional parameter used to track a request from the client's perspective example: 153554b5-ae44-44a0-8f4f-7bbac5657ad4 type: string to: example: - - "+18005550100" - - "+18005550100" + - "+18005550100" + - "+18005550100" items: type: string type: array required: - - content - - encrypted - - from - - to + - content + - encrypted + - from + - to type: object requests.MessageCallMissed: properties: @@ -549,10 +636,10 @@ definitions: example: "+18005550100" type: string required: - - from - - sim - - timestamp - - to + - from + - sim + - timestamp + - to type: object requests.MessageEvent: properties: @@ -568,15 +655,14 @@ definitions: description: Reason is the exact error message in case the event is an error type: string timestamp: - description: - Timestamp is the time when the event was emitted, Please send + description: Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible example: "2022-06-05T14:26:09.527976+03:00" type: string required: - - event_name - - reason - - timestamp + - event_name + - reason + - timestamp type: object requests.MessageReceive: properties: @@ -584,8 +670,7 @@ definitions: example: This is a sample text message received on a phone type: string encrypted: - description: - Encrypted is used to determine if the content is end-to-end encrypted. + description: Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app example: false type: boolean @@ -593,12 +678,12 @@ definitions: example: "+18005550199" type: string sim: + allOf: + - $ref: '#/definitions/entities.SIM' description: SIM card that received the message example: SIM1 - type: string timestamp: - description: - Timestamp is the time when the event was emitted, Please send + description: Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible example: "2022-06-05T14:26:09.527976+03:00" type: string @@ -606,12 +691,12 @@ definitions: example: "+18005550100" type: string required: - - content - - encrypted - - from - - sim - - timestamp - - to + - content + - encrypted + - from + - sim + - timestamp + - to type: object requests.MessageSend: properties: @@ -619,8 +704,7 @@ definitions: example: This is a sample text message type: string encrypted: - description: - Encrypted is an optional parameter used to determine if the content + description: Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app example: false @@ -629,14 +713,12 @@ definitions: example: "+18005550199" type: string request_id: - description: - RequestID is an optional parameter used to track a request from + description: RequestID is an optional parameter used to track a request from the client's perspective example: 153554b5-ae44-44a0-8f4f-7bbac5657ad4 type: string send_at: - description: - SendAt is an optional parameter used to schedule a message to + description: SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future. @@ -646,9 +728,9 @@ definitions: example: "+18005550100" type: string required: - - content - - from - - to + - content + - from + - to type: object requests.MessageThreadUpdate: properties: @@ -656,7 +738,7 @@ definitions: example: true type: boolean required: - - is_archived + - is_archived type: object requests.PhoneAPIKeyStoreRequest: properties: @@ -664,7 +746,7 @@ definitions: example: My Phone API Key type: string required: - - name + - name type: object requests.PhoneFCMToken: properties: @@ -672,18 +754,17 @@ definitions: example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string phone_number: - example: "[+18005550199]" + example: '[+18005550199]' type: string sim: - description: - SIM is the SIM slot of the phone in case the phone has more than + description: SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot example: SIM1 type: string required: - - fcm_token - - phone_number - - sim + - fcm_token + - phone_number + - sim type: object requests.PhoneUpsert: properties: @@ -691,14 +772,12 @@ definitions: example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string max_send_attempts: - description: - MaxSendAttempts is the number of attempts when sending an SMS + description: MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline. example: 2 type: integer message_expiration_seconds: - description: - MessageExpirationSeconds is the duration in seconds after sending + description: MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired. example: 12345 type: integer @@ -711,20 +790,54 @@ definitions: phone_number: example: "+18005550199" type: string + schedule_id: + example: 32343a19-da5e-4b1b-a767-3298a73703cb + type: string sim: - description: - SIM is the SIM slot of the phone in case the phone has more than + description: SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot example: SIM1 type: string required: - - fcm_token - - max_send_attempts - - message_expiration_seconds - - messages_per_minute - - missed_call_auto_reply - - phone_number - - sim + - fcm_token + - max_send_attempts + - message_expiration_seconds + - messages_per_minute + - missed_call_auto_reply + - phone_number + - schedule_id + - sim + type: object + requests.SendScheduleStore: + properties: + is_active: + type: boolean + name: + type: string + timezone: + type: string + windows: + items: + $ref: '#/definitions/requests.SendScheduleWindow' + type: array + required: + - is_active + - name + - timezone + - windows + type: object + requests.SendScheduleWindow: + properties: + day_of_week: + type: integer + end_minute: + type: integer + start_minute: + type: integer + required: + - day_of_week + - end_minute + - start_minute type: object requests.UserNotificationUpdate: properties: @@ -741,10 +854,10 @@ definitions: example: true type: boolean required: - - heartbeat_enabled - - message_status_enabled - - newsletter_enabled - - webhook_enabled + - heartbeat_enabled + - message_status_enabled + - newsletter_enabled + - webhook_enabled type: object requests.UserPaymentInvoice: properties: @@ -770,13 +883,13 @@ definitions: example: "9800" type: string required: - - address - - city - - country - - name - - notes - - state - - zip_code + - address + - city + - country + - name + - notes + - state + - zip_code type: object requests.UserUpdate: properties: @@ -787,8 +900,8 @@ definitions: example: Europe/Helsinki type: string required: - - active_phone_id - - timezone + - active_phone_id + - timezone type: object requests.WebhookStore: properties: @@ -798,8 +911,8 @@ definitions: type: array phone_numbers: example: - - "+18005550100" - - "+18005550100" + - "+18005550100" + - "+18005550100" items: type: string type: array @@ -808,10 +921,10 @@ definitions: url: type: string required: - - events - - phone_numbers - - signing_key - - url + - events + - phone_numbers + - signing_key + - url type: object requests.WebhookUpdate: properties: @@ -821,8 +934,8 @@ definitions: type: array phone_numbers: example: - - "+18005550100" - - "+18005550100" + - "+18005550100" + - "+18005550100" items: type: string type: array @@ -831,10 +944,10 @@ definitions: url: type: string required: - - events - - phone_numbers - - signing_key - - url + - events + - phone_numbers + - signing_key + - url type: object responses.BadRequest: properties: @@ -848,14 +961,14 @@ definitions: example: error type: string required: - - data - - message - - status + - data + - message + - status type: object responses.BillingUsageResponse: properties: data: - $ref: "#/definitions/entities.BillingUsage" + $ref: '#/definitions/entities.BillingUsage' message: example: Request handled successfully type: string @@ -863,15 +976,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.BillingUsagesResponse: properties: data: items: - $ref: "#/definitions/entities.BillingUsage" + $ref: '#/definitions/entities.BillingUsage' type: array message: example: Request handled successfully @@ -880,14 +993,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.DiscordResponse: properties: data: - $ref: "#/definitions/entities.Discord" + $ref: '#/definitions/entities.Discord' message: example: Request handled successfully type: string @@ -895,15 +1008,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.DiscordsResponse: properties: data: items: - $ref: "#/definitions/entities.Discord" + $ref: '#/definitions/entities.Discord' type: array message: example: Request handled successfully @@ -912,14 +1025,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.HeartbeatResponse: properties: data: - $ref: "#/definitions/entities.Heartbeat" + $ref: '#/definitions/entities.Heartbeat' message: example: Request handled successfully type: string @@ -927,15 +1040,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.HeartbeatsResponse: properties: data: items: - $ref: "#/definitions/entities.Heartbeat" + $ref: '#/definitions/entities.Heartbeat' type: array message: example: Request handled successfully @@ -944,9 +1057,9 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.InternalServerError: properties: @@ -957,13 +1070,13 @@ definitions: example: error type: string required: - - message - - status + - message + - status type: object responses.MessageResponse: properties: data: - $ref: "#/definitions/entities.Message" + $ref: '#/definitions/entities.Message' message: example: Request handled successfully type: string @@ -971,15 +1084,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.MessageThreadsResponse: properties: data: items: - $ref: "#/definitions/entities.MessageThread" + $ref: '#/definitions/entities.MessageThread' type: array message: example: Request handled successfully @@ -988,15 +1101,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.MessagesResponse: properties: data: items: - $ref: "#/definitions/entities.Message" + $ref: '#/definitions/entities.Message' type: array message: example: Request handled successfully @@ -1005,9 +1118,9 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.NoContent: properties: @@ -1018,8 +1131,8 @@ definitions: example: success type: string required: - - message - - status + - message + - status type: object responses.NotFound: properties: @@ -1030,8 +1143,8 @@ definitions: example: error type: string required: - - message - - status + - message + - status type: object responses.OkString: properties: @@ -1044,14 +1157,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.PhoneAPIKeyResponse: properties: data: - $ref: "#/definitions/entities.PhoneAPIKey" + $ref: '#/definitions/entities.PhoneAPIKey' message: example: Request handled successfully type: string @@ -1059,15 +1172,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.PhoneAPIKeysResponse: properties: data: items: - $ref: "#/definitions/entities.PhoneAPIKey" + $ref: '#/definitions/entities.PhoneAPIKey' type: array message: example: Request handled successfully @@ -1076,14 +1189,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.PhoneResponse: properties: data: - $ref: "#/definitions/entities.Phone" + $ref: '#/definitions/entities.Phone' message: example: Request handled successfully type: string @@ -1091,15 +1204,47 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.PhonesResponse: properties: data: items: - $ref: "#/definitions/entities.Phone" + $ref: '#/definitions/entities.Phone' + type: array + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object + responses.SendScheduleResponse: + properties: + data: + $ref: '#/definitions/entities.SendSchedule' + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object + responses.SendSchedulesResponse: + properties: + data: + items: + $ref: '#/definitions/entities.SendSchedule' type: array message: example: Request handled successfully @@ -1108,9 +1253,9 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.Unauthorized: properties: @@ -1124,9 +1269,9 @@ definitions: example: error type: string required: - - data - - message - - status + - data + - message + - status type: object responses.UnprocessableEntity: properties: @@ -1143,14 +1288,14 @@ definitions: example: error type: string required: - - data - - message - - status + - data + - message + - status type: object responses.UserResponse: properties: data: - $ref: "#/definitions/entities.User" + $ref: '#/definitions/entities.User' message: example: Request handled successfully type: string @@ -1158,9 +1303,9 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.UserSubscriptionPaymentsResponse: properties: @@ -1223,42 +1368,42 @@ definitions: updated_at: type: string required: - - billing_reason - - card_brand - - card_last_four - - created_at - - currency - - currency_rate - - discount_total - - discount_total_formatted - - discount_total_usd - - refunded - - refunded_amount - - refunded_amount_formatted - - refunded_amount_usd - - refunded_at - - status - - status_formatted - - subtotal - - subtotal_formatted - - subtotal_usd - - tax - - tax_formatted - - tax_inclusive - - tax_usd - - total - - total_formatted - - total_usd - - updated_at + - billing_reason + - card_brand + - card_last_four + - created_at + - currency + - currency_rate + - discount_total + - discount_total_formatted + - discount_total_usd + - refunded + - refunded_amount + - refunded_amount_formatted + - refunded_amount_usd + - refunded_at + - status + - status_formatted + - subtotal + - subtotal_formatted + - subtotal_usd + - tax + - tax_formatted + - tax_inclusive + - tax_usd + - total + - total_formatted + - total_usd + - updated_at type: object id: type: string type: type: string required: - - attributes - - id - - type + - attributes + - id + - type type: object type: array message: @@ -1268,14 +1413,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.WebhookResponse: properties: data: - $ref: "#/definitions/entities.Webhook" + $ref: '#/definitions/entities.Webhook' message: example: Request handled successfully type: string @@ -1283,15 +1428,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.WebhooksResponse: properties: data: items: - $ref: "#/definitions/entities.Webhook" + $ref: '#/definitions/entities.Webhook' type: array message: example: Request handled successfully @@ -1300,17 +1445,16 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object host: api.httpsms.com info: contact: email: support@httpsms.com name: support@httpsms.com - description: - Use your Android phone to send and receive SMS messages via a simple + description: Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption. license: name: AGPL-3.0 @@ -1321,1660 +1465,1857 @@ paths: /billing/usage: get: consumes: - - application/json - description: - Get the summary of sent and received messages for a user in the + - application/json + description: Get the summary of sent and received messages for a user in the current month produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.BillingUsageResponse" + $ref: '#/definitions/responses.BillingUsageResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get Billing Usage. tags: - - Billing + - Billing /billing/usage-history: get: consumes: - - application/json - description: - Get billing usage records of sent and received messages for a user + - application/json + description: Get billing usage records of sent and received messages for a user in the past. It will be sorted by timestamp in descending order. parameters: - - description: number of heartbeats to skip - in: query - minimum: 0 - name: skip - type: integer - - description: number of heartbeats to return - in: query - maximum: 100 - minimum: 1 - name: limit - type: integer + - description: number of heartbeats to skip + in: query + minimum: 0 + name: skip + type: integer + - description: number of heartbeats to return + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.BillingUsagesResponse" + $ref: '#/definitions/responses.BillingUsagesResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get billing usage history. tags: - - Billing + - Billing /bulk-messages: post: consumes: - - multipart/form-data - description: - Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) + - multipart/form-data + description: Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx). parameters: - - description: The Excel or CSV file containing the messages to be sent. - in: formData - name: document - required: true - type: file + - description: The Excel or CSV file containing the messages to be sent. + in: formData + name: document + required: true + type: file produces: - - application/json + - application/json responses: "202": description: Accepted schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Store bulk SMS file tags: - - BulkSMS + - BulkSMS /discord-integrations: get: consumes: - - application/json + - application/json description: Get the discord integrations of a user parameters: - - description: number of discord integrations to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter discord integrations containing query - in: query - name: query - type: string - - description: number of discord integrations to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - description: number of discord integrations to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter discord integrations containing query + in: query + name: query + type: string + - description: number of discord integrations to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.DiscordsResponse" + $ref: '#/definitions/responses.DiscordsResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get discord integrations of a user tags: - - DiscordIntegration + - DiscordIntegration post: consumes: - - application/json + - application/json description: Store a discord integration for the authenticated user parameters: - - description: Payload of the discord integration request - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.DiscordStore" + - description: Payload of the discord integration request + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.DiscordStore' produces: - - application/json + - application/json responses: "201": description: Created schema: - $ref: "#/definitions/responses.DiscordResponse" + $ref: '#/definitions/responses.DiscordResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Store discord integration tags: - - DiscordIntegration + - DiscordIntegration /discord-integrations/{discordID}: delete: consumes: - - application/json + - application/json description: Delete a discord integration for a user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the discord integration - in: path - name: discordID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the discord integration + in: path + name: discordID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete discord integration tags: - - Webhooks + - Webhooks put: consumes: - - application/json + - application/json description: Update a discord integration for the currently authenticated user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the discord integration - in: path - name: discordID - required: true - type: string - - description: Payload of discord integration to update - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.DiscordUpdate" + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the discord integration + in: path + name: discordID + required: true + type: string + - description: Payload of discord integration to update + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.DiscordUpdate' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.DiscordResponse" + $ref: '#/definitions/responses.DiscordResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update a discord integration tags: - - DiscordIntegration + - DiscordIntegration /discord/event: post: consumes: - - application/json + - application/json description: Publish a discord event to the registered listeners produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' summary: Consume a discord event tags: - - Discord + - Discord /heartbeats: get: consumes: - - application/json - description: - Get the last time a phone number requested for outstanding messages. + - application/json + description: Get the last time a phone number requested for outstanding messages. It will be sorted by timestamp in descending order. parameters: - - default: "+18005550199" - description: the owner's phone number - in: query - name: owner - required: true - type: string - - description: number of heartbeats to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter containing query - in: query - name: query - type: string - - description: number of heartbeats to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - default: "+18005550199" + description: the owner's phone number + in: query + name: owner + required: true + type: string + - description: number of heartbeats to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter containing query + in: query + name: query + type: string + - description: number of heartbeats to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.HeartbeatsResponse" + $ref: '#/definitions/responses.HeartbeatsResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get heartbeats of an owner phone number tags: - - Heartbeats + - Heartbeats post: consumes: - - application/json - description: - Store the heartbeat to make notify that a phone number is still + - application/json + description: Store the heartbeat to make notify that a phone number is still active parameters: - - description: Payload of the heartbeat request - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.HeartbeatStore" + - description: Payload of the heartbeat request + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.HeartbeatStore' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.HeartbeatResponse" + $ref: '#/definitions/responses.HeartbeatResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Register heartbeat of an owner phone number tags: - - Heartbeats + - Heartbeats /integration/3cx/messages: post: consumes: - - application/json + - application/json description: Sends an SMS message from the 3CX platform produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' summary: Sends a 3CX SMS message tags: - - 3CXIntegration + - 3CXIntegration /message-threads: get: consumes: - - application/json - description: - Get list of contacts which a phone number has communicated with + - application/json + description: Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. parameters: - - default: "+18005550199" - description: owner phone number - in: query - name: owner - required: true - type: string - - description: number of messages to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter message threads containing query - in: query - name: query - type: string - - description: number of messages to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - default: "+18005550199" + description: owner phone number + in: query + name: owner + required: true + type: string + - description: number of messages to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter message threads containing query + in: query + name: query + type: string + - description: number of messages to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.MessageThreadsResponse" + $ref: '#/definitions/responses.MessageThreadsResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get message threads for a phone number tags: - - MessageThreads + - MessageThreads /message-threads/{messageThreadID}: delete: consumes: - - application/json - description: - Delete a message thread from the database and also deletes all + - application/json + description: Delete a message thread from the database and also deletes all the messages in the thread. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the message thread - in: path - name: messageThreadID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message thread + in: path + name: messageThreadID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "404": description: Not Found schema: - $ref: "#/definitions/responses.NotFound" + $ref: '#/definitions/responses.NotFound' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete a message thread from the database. tags: - - MessageThreads + - MessageThreads put: consumes: - - application/json + - application/json description: Updates the details of a message thread parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the message thread - in: path - name: messageThreadID - required: true - type: string - - description: Payload of message thread details to update - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.MessageThreadUpdate" + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message thread + in: path + name: messageThreadID + required: true + type: string + - description: Payload of message thread details to update + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.MessageThreadUpdate' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.PhoneResponse" + $ref: '#/definitions/responses.PhoneResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update a message thread tags: - - MessageThreads + - MessageThreads /messages: get: consumes: - - application/json - description: - Get list of messages which are sent between 2 phone numbers. It + - application/json + description: Get list of messages which are sent between 2 phone numbers. It will be sorted by timestamp in descending order. parameters: - - default: "+18005550199" - description: the owner's phone number - in: query - name: owner - required: true - type: string - - default: "+18005550100" - description: the contact's phone number - in: query - name: contact - required: true - type: string - - description: number of messages to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter messages containing query - in: query - name: query - type: string - - description: number of messages to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - default: "+18005550199" + description: the owner's phone number + in: query + name: owner + required: true + type: string + - default: "+18005550100" + description: the contact's phone number + in: query + name: contact + required: true + type: string + - description: number of messages to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter messages containing query + in: query + name: query + type: string + - description: number of messages to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.MessagesResponse" + $ref: '#/definitions/responses.MessagesResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get messages which are sent between 2 phone numbers tags: - - Messages + - Messages /messages/{messageID}: delete: consumes: - - application/json - description: - Delete a message from the database and removes the message content + - application/json + description: Delete a message from the database and removes the message content from the list of threads. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the message - in: path - name: messageID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message + in: path + name: messageID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "404": description: Not Found schema: - $ref: "#/definitions/responses.NotFound" + $ref: '#/definitions/responses.NotFound' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete a message from the database. tags: - - Messages + - Messages + get: + consumes: + - application/json + description: Get a message from the database by the message ID. + parameters: + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message + in: path + name: messageID + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + schema: + $ref: '#/definitions/responses.MessageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "404": + description: Not Found + schema: + $ref: '#/definitions/responses.NotFound' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Get a message from the database. + tags: + - Messages /messages/{messageID}/events: post: consumes: - - application/json - description: - Use this endpoint to send events for a message when it is failed, + - application/json + description: Use this endpoint to send events for a message when it is failed, sent or delivered by the mobile phone. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the message - in: path - name: messageID - required: true - type: string - - description: Payload of the event emitted. - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.MessageEvent" + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message + in: path + name: messageID + required: true + type: string + - description: Payload of the event emitted. + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.MessageEvent' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.MessageResponse" + $ref: '#/definitions/responses.MessageResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "404": description: Not Found schema: - $ref: "#/definitions/responses.NotFound" + $ref: '#/definitions/responses.NotFound' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Upsert an event for a message on the mobile phone tags: - - Messages + - Messages /messages/bulk-send: post: consumes: - - application/json + - application/json description: Add bulk SMS messages to be sent by the android phone parameters: - - description: Bulk send message request payload - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.MessageBulkSend" + - description: Bulk send message request payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.MessageBulkSend' produces: - - application/json + - application/json responses: "200": description: OK schema: items: - $ref: "#/definitions/responses.MessagesResponse" + $ref: '#/definitions/responses.MessagesResponse' type: array "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Send bulk SMS messages tags: - - Messages + - Messages /messages/calls/missed: post: consumes: - - application/json - description: - This endpoint is called by the httpSMS android app to register + - application/json + description: This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone. parameters: - - description: Payload of the missed call event. - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.MessageCallMissed" + - description: Payload of the missed call event. + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.MessageCallMissed' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.MessageResponse" + $ref: '#/definitions/responses.MessageResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "404": description: Not Found schema: - $ref: "#/definitions/responses.NotFound" + $ref: '#/definitions/responses.NotFound' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Register a missed call event on the mobile phone tags: - - Messages + - Messages /messages/outstanding: get: consumes: - - application/json + - application/json description: Get an outstanding message to be sent by an android phone parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703cb - description: The ID of the message - in: query - name: message_id - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703cb + description: The ID of the message + in: query + name: message_id + required: true + type: string produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.MessageResponse" + $ref: '#/definitions/responses.MessageResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get an outstanding message tags: - - Messages + - Messages /messages/receive: post: consumes: - - application/json + - application/json description: Add a new message received from a mobile phone parameters: - - description: Received message request payload - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.MessageReceive" + - description: Received message request payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.MessageReceive' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.MessageResponse" + $ref: '#/definitions/responses.MessageResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Receive a new SMS message from a mobile phone tags: - - Messages + - Messages /messages/search: get: consumes: - - application/json - description: - This returns the list of all messages based on the filter criteria + - application/json + description: This returns the list of all messages based on the filter criteria including missed calls parameters: - - description: Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/ - in: header - name: token - required: true - type: string - - default: +18005550199,+18005550100 - description: the owner's phone numbers - in: query - name: owners - required: true - type: string - - description: number of messages to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter messages containing query - in: query - name: query - type: string - - description: number of messages to return - in: query - maximum: 200 - minimum: 1 - name: limit - type: integer + - description: Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/ + in: header + name: token + required: true + type: string + - default: +18005550199,+18005550100 + description: the owner's phone numbers + in: query + name: owners + required: true + type: string + - description: number of messages to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter messages containing query + in: query + name: query + type: string + - description: number of messages to return + in: query + maximum: 200 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.MessagesResponse" + $ref: '#/definitions/responses.MessagesResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Search all messages of a user tags: - - Messages + - Messages /messages/send: post: consumes: - - application/json + - application/json description: Add a new SMS message to be sent by your Android phone parameters: - - description: Send message request payload - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.MessageSend" + - description: Send message request payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.MessageSend' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.MessageResponse" + $ref: '#/definitions/responses.MessageResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Send an SMS message tags: - - Messages + - Messages /phone-api-keys: get: consumes: - - application/json - description: - Get list phone API keys which a user has registered on the httpSMS + - application/json + description: Get list phone API keys which a user has registered on the httpSMS application parameters: - - description: number of phone api keys to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter phone api keys with name containing query - in: query - name: query - type: string - - description: number of phone api keys to return - in: query - maximum: 100 - minimum: 1 - name: limit - type: integer + - description: number of phone api keys to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter phone api keys with name containing query + in: query + name: query + type: string + - description: number of phone api keys to return + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.PhoneAPIKeysResponse" + $ref: '#/definitions/responses.PhoneAPIKeysResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get the phone API keys of a user tags: - - PhoneAPIKeys + - PhoneAPIKeys post: consumes: - - application/json - description: - Creates a new phone API key which can be used to log in to the + - application/json + description: Creates a new phone API key which can be used to log in to the httpSMS app on your Android phone parameters: - - description: Payload of new phone API key. - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.PhoneAPIKeyStoreRequest" + - description: Payload of new phone API key. + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.PhoneAPIKeyStoreRequest' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.PhoneAPIKeyResponse" + $ref: '#/definitions/responses.PhoneAPIKeyResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Store phone API key tags: - - PhoneAPIKeys + - PhoneAPIKeys /phone-api-keys/{phoneAPIKeyID}: delete: consumes: - - application/json - description: - Delete a phone API Key from the database and cannot be used for + - application/json + description: Delete a phone API Key from the database and cannot be used for authentication anymore. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the phone API key - in: path - name: phoneAPIKeyID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the phone API key + in: path + name: phoneAPIKeyID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "404": description: Not Found schema: - $ref: "#/definitions/responses.NotFound" + $ref: '#/definitions/responses.NotFound' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete a phone API key from the database. tags: - - PhoneAPIKeys + - PhoneAPIKeys /phone-api-keys/{phoneAPIKeyID}/phones/{phoneID}: delete: consumes: - - application/json - description: - You will need to login again to the httpSMS app on your Android + - application/json + description: You will need to login again to the httpSMS app on your Android phone with a new phone API key. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the phone API key - in: path - name: phoneAPIKeyID - required: true - type: string - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the phone - in: path - name: phoneID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the phone API key + in: path + name: phoneAPIKeyID + required: true + type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the phone + in: path + name: phoneID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "404": description: Not Found schema: - $ref: "#/definitions/responses.NotFound" + $ref: '#/definitions/responses.NotFound' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Remove the association of a phone from the phone API key. tags: - - PhoneAPIKeys + - PhoneAPIKeys /phones: get: consumes: - - application/json - description: - Get list of phones which a user has registered on the http sms + - application/json + description: Get list of phones which a user has registered on the http sms application parameters: - - description: number of heartbeats to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter phones containing query - in: query - name: query - type: string - - description: number of phones to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - description: number of heartbeats to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter phones containing query + in: query + name: query + type: string + - description: number of phones to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.PhonesResponse" + $ref: '#/definitions/responses.PhonesResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get phones of a user tags: - - Phones + - Phones put: consumes: - - application/json - description: - Updates properties of a user's phone. If the phone with this number + - application/json + description: Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' parameters: - - description: Payload of new phone number. - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.PhoneUpsert" + - description: Payload of new phone number. + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.PhoneUpsert' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.PhoneResponse" + $ref: '#/definitions/responses.PhoneResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Upsert Phone tags: - - Phones + - Phones /phones/{phoneID}: delete: consumes: - - application/json + - application/json description: Delete a phone that has been sored in the database parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the phone - in: path - name: phoneID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the phone + in: path + name: phoneID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete Phone tags: - - Phones + - Phones /phones/fcm-token: put: consumes: - - application/json - description: - Updates the FCM token of a phone. If the phone with this number + - application/json + description: Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' parameters: - - description: Payload of new FCM token. - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.PhoneFCMToken" + - description: Payload of new FCM token. + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.PhoneFCMToken' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.PhoneResponse" + $ref: '#/definitions/responses.PhoneResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Upserts the FCM token of a phone tags: - - Phones + - Phones + /send-schedules: + get: + description: Lists the send schedules owned by the authenticated user. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/responses.SendSchedulesResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: List send schedules + tags: + - Send Schedules + post: + consumes: + - application/json + description: Creates a send schedule for the authenticated user. + parameters: + - description: Payload of new send schedule. + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.SendScheduleStore' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/responses.SendScheduleResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Create send schedule + tags: + - Send Schedules + /send-schedules/{scheduleID}: + delete: + description: Deletes a send schedule owned by the authenticated user. + parameters: + - description: Schedule ID + in: path + name: scheduleID + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + schema: + $ref: '#/definitions/responses.NoContent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Delete send schedule + tags: + - Send Schedules + get: + description: Loads a single send schedule owned by the authenticated user. + parameters: + - description: Schedule ID + in: path + name: scheduleID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/responses.SendScheduleResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "404": + description: Not Found + schema: + $ref: '#/definitions/responses.NotFound' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Show send schedule + tags: + - Send Schedules + put: + consumes: + - application/json + description: Updates a send schedule owned by the authenticated user. + parameters: + - description: Schedule ID + in: path + name: scheduleID + required: true + type: string + - description: Payload of updated send schedule. + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.SendScheduleStore' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/responses.SendScheduleResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "404": + description: Not Found + schema: + $ref: '#/definitions/responses.NotFound' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Update send schedule + tags: + - Send Schedules /users/{userID}/api-keys: delete: consumes: - - application/json + - application/json description: Rotate the user's API key in case the current API Key is compromised parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the user to update - in: path - name: userID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the user to update + in: path + name: userID + required: true + type: string produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.UserResponse" + $ref: '#/definitions/responses.UserResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Rotate the user's API Key tags: - - Users + - Users /users/{userID}/notifications: put: consumes: - - application/json + - application/json description: Update the email notification settings for a user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the user to update - in: path - name: userID - required: true - type: string - - description: User notification details to update - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.UserNotificationUpdate" + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the user to update + in: path + name: userID + required: true + type: string + - description: User notification details to update + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.UserNotificationUpdate' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.UserResponse" + $ref: '#/definitions/responses.UserResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update notification settings tags: - - Users + - Users /users/me: delete: consumes: - - application/json - description: - Deletes the currently authenticated user together with all their + - application/json + description: Deletes the currently authenticated user together with all their data. produces: - - application/json + - application/json responses: "201": description: Created schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete a user tags: - - Users + - Users get: consumes: - - application/json + - application/json description: Get details of the currently authenticated user produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.UserResponse" + $ref: '#/definitions/responses.UserResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get current user tags: - - Users + - Users put: consumes: - - application/json + - application/json description: Updates the details of the currently authenticated user parameters: - - description: Payload of user details to update - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.UserUpdate" + - description: Payload of user details to update + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.UserUpdate' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.PhoneResponse" + $ref: '#/definitions/responses.PhoneResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update a user tags: - - Users + - Users /users/subscription: delete: description: Cancel the subscription of the authenticated user. produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Cancel the user's subscription tags: - - Users + - Users /users/subscription-update-url: get: description: Fetches the subscription URL of the authenticated user. produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.OkString" + $ref: '#/definitions/responses.OkString' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Currently authenticated user subscription update URL tags: - - Users + - Users /users/subscription/invoices/{subscriptionInvoiceID}: post: consumes: - - application/json - description: - Generates a new invoice PDF file for the given subscription payment + - application/json + description: Generates a new invoice PDF file for the given subscription payment with given parameters. parameters: - - description: Generate subscription payment invoice parameters - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.UserPaymentInvoice" + - description: Generate subscription payment invoice parameters + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.UserPaymentInvoice' produces: - - application/pdf + - application/pdf responses: "200": description: OK @@ -2983,235 +3324,234 @@ paths: "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Generate a subscription payment invoice tags: - - Users + - Users /users/subscription/payments: get: consumes: - - application/json - description: - Subscription payments are generated throughout the lifecycle of + - application/json + description: Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal. produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.UserSubscriptionPaymentsResponse" + $ref: '#/definitions/responses.UserSubscriptionPaymentsResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get the last 10 subscription payments. tags: - - Users + - Users /webhooks: get: consumes: - - application/json + - application/json description: Get the webhooks of a user parameters: - - description: number of webhooks to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter webhooks containing query - in: query - name: query - type: string - - description: number of webhooks to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - description: number of webhooks to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter webhooks containing query + in: query + name: query + type: string + - description: number of webhooks to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.WebhooksResponse" + $ref: '#/definitions/responses.WebhooksResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get webhooks of a user tags: - - Webhooks + - Webhooks post: consumes: - - application/json + - application/json description: Store a webhook for the authenticated user parameters: - - description: Payload of the webhook request - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.WebhookStore" + - description: Payload of the webhook request + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.WebhookStore' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.WebhookResponse" + $ref: '#/definitions/responses.WebhookResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Store a webhook tags: - - Webhooks + - Webhooks /webhooks/{webhookID}: delete: consumes: - - application/json + - application/json description: Delete a webhook for a user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the webhook - in: path - name: webhookID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the webhook + in: path + name: webhookID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: "#/definitions/responses.NoContent" + $ref: '#/definitions/responses.NoContent' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete webhook tags: - - Webhooks + - Webhooks put: consumes: - - application/json + - application/json description: Update a webhook for the currently authenticated user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the webhook - in: path - name: webhookID - required: true - type: string - - description: Payload of webhook details to update - in: body - name: payload - required: true - schema: - $ref: "#/definitions/requests.WebhookUpdate" + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the webhook + in: path + name: webhookID + required: true + type: string + - description: Payload of webhook details to update + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.WebhookUpdate' produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: "#/definitions/responses.WebhookResponse" + $ref: '#/definitions/responses.WebhookResponse' "400": description: Bad Request schema: - $ref: "#/definitions/responses.BadRequest" + $ref: '#/definitions/responses.BadRequest' "401": description: Unauthorized schema: - $ref: "#/definitions/responses.Unauthorized" + $ref: '#/definitions/responses.Unauthorized' "422": description: Unprocessable Entity schema: - $ref: "#/definitions/responses.UnprocessableEntity" + $ref: '#/definitions/responses.UnprocessableEntity' "500": description: Internal Server Error schema: - $ref: "#/definitions/responses.InternalServerError" + $ref: '#/definitions/responses.InternalServerError' security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update a webhook tags: - - Webhooks + - Webhooks schemes: - - https +- https securityDefinitions: ApiKeyAuth: in: header diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 632e4706..0b3f6557 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -127,6 +127,7 @@ func NewContainer(projectID string, version string) (container *Container) { container.RegisterHeartbeatListeners() container.RegisterUserRoutes() + container.RegisterSendScheduleRoutes() container.RegisterUserListeners() container.RegisterPhoneRoutes() @@ -744,6 +745,46 @@ func (container *Container) PhoneRepository() (repository repositories.PhoneRepo ) } +// SendScheduleRepository creates a new instance of repositories.SendScheduleRepository +func (container *Container) SendScheduleRepository() repositories.SendScheduleRepository { + container.logger.Debug("creating GORM repositories.SendScheduleRepository") + return repositories.NewGormSendScheduleRepository( + container.Logger(), + container.Tracer(), + container.DB(), + ) +} + +// SendScheduleService creates a new instance of services.SendScheduleService +func (container *Container) SendScheduleService() *services.SendScheduleService { + container.logger.Debug("creating services.SendScheduleService") + return services.NewSendScheduleService( + container.Logger(), + container.Tracer(), + container.SendScheduleRepository(), + ) +} + +// SendScheduleHandlerValidator creates a new instance of validators.SendScheduleHandlerValidator +func (container *Container) SendScheduleHandlerValidator() *validators.SendScheduleHandlerValidator { + container.logger.Debug("creating validators.SendScheduleHandlerValidator") + return validators.NewSendScheduleHandlerValidator( + container.Logger(), + container.Tracer(), + ) +} + +// SendScheduleHandler creates a new instance of handlers.SendScheduleHandler +func (container *Container) SendScheduleHandler() *handlers.SendScheduleHandler { + container.logger.Debug("creating handlers.SendScheduleHandler") + return handlers.NewSendScheduleHandler( + container.Logger(), + container.Tracer(), + container.SendScheduleHandlerValidator(), + container.SendScheduleService(), + ) +} + // BillingUsageRepository creates a new instance of repositories.BillingUsageRepository func (container *Container) BillingUsageRepository() (repository repositories.BillingUsageRepository) { container.logger.Debug("creating GORM repositories.BillingUsageRepository") @@ -1447,6 +1488,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi container.FirebaseMessagingClient(), container.PhoneRepository(), container.PhoneNotificationRepository(), + container.SendScheduleRepository(), container.EventDispatcher(), ) } @@ -1502,6 +1544,12 @@ func (container *Container) RegisterUserRoutes() { container.UserHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } +// RegisterSendScheduleRoutes registers routes for the /send-schedules prefix +func (container *Container) RegisterSendScheduleRoutes() { + container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.SendScheduleHandler{})) + container.SendScheduleHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) +} + // RegisterEventRoutes registers routes for the /events prefix func (container *Container) RegisterEventRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.EventsHandler{})) diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 83521759..80d2c8f2 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -8,12 +8,14 @@ import ( // Phone represents an android phone which has installed the http sms app type Phone struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` - UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` - FcmToken *string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." validate:"optional"` - PhoneNumber string `json:"phone_number" example:"+18005550199"` - MessagesPerMinute uint `json:"messages_per_minute" example:"1"` - SIM SIM `json:"sim" gorm:"default:SIM1"` + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` + FcmToken *string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." validate:"optional"` + PhoneNumber string `json:"phone_number" example:"+18005550199"` + MessagesPerMinute uint `json:"messages_per_minute" example:"1"` + SIM SIM `json:"sim" gorm:"default:SIM1"` + ScheduleID *uuid.UUID `json:"schedule_id" gorm:"type:uuid" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + Schedule *SendSchedule `json:"-" gorm:"foreignKey:ScheduleID;constraint:OnDelete:SET NULL"` // MaxSendAttempts determines how many times to retry sending an SMS message MaxSendAttempts uint `json:"max_send_attempts" example:"2"` diff --git a/api/pkg/entities/send_schedule.go b/api/pkg/entities/send_schedule.go new file mode 100644 index 00000000..55a8e07c --- /dev/null +++ b/api/pkg/entities/send_schedule.go @@ -0,0 +1,26 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +// SendScheduleWindow represents a single availability window for a day of the week. +type SendScheduleWindow struct { + DayOfWeek int `json:"day_of_week" example:"1"` + StartMinute int `json:"start_minute" example:"540"` + EndMinute int `json:"end_minute" example:"1020"` +} + +// SendSchedule controls when a phone is allowed to send outgoing SMS messages. +type SendSchedule struct { + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` + Name string `json:"name" example:"Business Hours"` + Timezone string `json:"timezone" example:"Europe/Tallinn"` + IsActive bool `json:"is_active" gorm:"default:true" example:"true"` + Windows []SendScheduleWindow `json:"windows" gorm:"type:jsonb;serializer:json"` + CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` + UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` +} diff --git a/api/pkg/handlers/send_schedule_handler.go b/api/pkg/handlers/send_schedule_handler.go new file mode 100644 index 00000000..22b47deb --- /dev/null +++ b/api/pkg/handlers/send_schedule_handler.go @@ -0,0 +1,187 @@ +package handlers + +import ( + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/davecgh/go-spew/spew" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/palantir/stacktrace" +) + +type SendScheduleHandler struct { + handler + logger telemetry.Logger + tracer telemetry.Tracer + validator *validators.SendScheduleHandlerValidator + service *services.SendScheduleService +} + +func NewSendScheduleHandler(logger telemetry.Logger, tracer telemetry.Tracer, validator *validators.SendScheduleHandlerValidator, service *services.SendScheduleService) *SendScheduleHandler { + return &SendScheduleHandler{logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandler{})), tracer: tracer, validator: validator, service: service} +} + +func (h *SendScheduleHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Get("/v1/send-schedules", h.computeRoute(middlewares, h.Index)...) + router.Post("/v1/send-schedules", h.computeRoute(middlewares, h.Store)...) + router.Get("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Show)...) + router.Put("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Update)...) + router.Delete("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Delete)...) +} + +// Index godoc +// @Summary List send schedules +// @Description Lists the send schedules owned by the authenticated user. +// @Security ApiKeyAuth +// @Tags Send Schedules +// @Produce json +// @Success 200 {object} responses.SendSchedulesResponse +// @Failure 401 {object} responses.Unauthorized +// @Failure 500 {object} responses.InternalServerError +// @Router /send-schedules [get] +func (h *SendScheduleHandler) Index(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + schedules, err := h.service.Index(ctx, h.userIDFomContext(c)) + if err != nil { + ctxLogger.Error(stacktrace.Propagate(err, "cannot list send schedules")) + return h.responseInternalServerError(c) + } + return h.responseOK(c, "send schedules fetched successfully", schedules) +} + +// Show godoc +// @Summary Show send schedule +// @Description Loads a single send schedule owned by the authenticated user. +// @Security ApiKeyAuth +// @Tags Send Schedules +// @Produce json +// @Param scheduleID path string true "Schedule ID" +// @Success 200 {object} responses.SendScheduleResponse +// @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound +// @Failure 500 {object} responses.InternalServerError +// @Router /send-schedules/{scheduleID} [get] +func (h *SendScheduleHandler) Show(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + scheduleID, err := uuid.Parse(c.Params("scheduleID")) + if err != nil { + return h.responseBadRequest(c, err) + } + schedule, err := h.service.Load(ctx, h.userIDFomContext(c), scheduleID) + if err != nil { + ctxLogger.Error(stacktrace.Propagate(err, "cannot load send schedule")) + if stacktrace.GetCode(err) == 404 { + return h.responseNotFound(c, err.Error()) + } + return h.responseInternalServerError(c) + } + return h.responseOK(c, "send schedule fetched successfully", schedule) +} + +// Store godoc +// @Summary Create send schedule +// @Description Creates a send schedule for the authenticated user. +// @Security ApiKeyAuth +// @Tags Send Schedules +// @Accept json +// @Produce json +// @Param payload body requests.SendScheduleStore true "Payload of new send schedule." +// @Success 201 {object} responses.SendScheduleResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /send-schedules [post] +func (h *SendScheduleHandler) Store(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + var request requests.SendScheduleStore + if err := c.BodyParser(&request); err != nil { + return h.responseBadRequest(c, err) + } + request = request.Sanitize() + if errors := h.validator.ValidateStore(ctx, request); len(errors) != 0 { + ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("validation errors [%s], while storing send schedule [%+#v]", spew.Sdump(errors), request))) + return h.responseUnprocessableEntity(c, errors, "validation errors while saving send schedule") + } + schedule, err := h.service.Store(ctx, request.ToParams(h.userFromContext(c))) + if err != nil { + ctxLogger.Error(stacktrace.Propagate(err, "cannot create send schedule")) + return h.responseInternalServerError(c) + } + return h.responseCreated(c, "send schedule created successfully", schedule) +} + +// Update godoc +// @Summary Update send schedule +// @Description Updates a send schedule owned by the authenticated user. +// @Security ApiKeyAuth +// @Tags Send Schedules +// @Accept json +// @Produce json +// @Param scheduleID path string true "Schedule ID" +// @Param payload body requests.SendScheduleStore true "Payload of updated send schedule." +// @Success 200 {object} responses.SendScheduleResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /send-schedules/{scheduleID} [put] +func (h *SendScheduleHandler) Update(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + scheduleID, err := uuid.Parse(c.Params("scheduleID")) + if err != nil { + return h.responseBadRequest(c, err) + } + var request requests.SendScheduleStore + if err = c.BodyParser(&request); err != nil { + return h.responseBadRequest(c, err) + } + request = request.Sanitize() + if errors := h.validator.ValidateStore(ctx, request); len(errors) != 0 { + return h.responseUnprocessableEntity(c, errors, "validation errors while updating send schedule") + } + schedule, err := h.service.Update(ctx, h.userIDFomContext(c), scheduleID, request.ToParams(h.userFromContext(c))) + if err != nil { + ctxLogger.Error(stacktrace.Propagate(err, "cannot update send schedule")) + if stacktrace.GetCode(err) == 404 { + return h.responseNotFound(c, err.Error()) + } + return h.responseInternalServerError(c) + } + return h.responseOK(c, "send schedule updated successfully", schedule) +} + +// Delete godoc +// @Summary Delete send schedule +// @Description Deletes a send schedule owned by the authenticated user. +// @Security ApiKeyAuth +// @Tags Send Schedules +// @Produce json +// @Param scheduleID path string true "Schedule ID" +// @Success 204 {object} responses.NoContent +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 500 {object} responses.InternalServerError +// @Router /send-schedules/{scheduleID} [delete] +func (h *SendScheduleHandler) Delete(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + scheduleID, err := uuid.Parse(c.Params("scheduleID")) + if err != nil { + return h.responseBadRequest(c, err) + } + if err = h.service.Delete(ctx, h.userIDFomContext(c), scheduleID); err != nil { + ctxLogger.Error(stacktrace.Propagate(err, "cannot delete send schedule")) + return h.responseInternalServerError(c) + } + return h.responseNoContent(c, "send schedule deleted successfully") +} diff --git a/api/pkg/repositories/gorm_phone_notification_repository.go b/api/pkg/repositories/gorm_phone_notification_repository.go index f491e4b3..9a1d29d1 100644 --- a/api/pkg/repositories/gorm_phone_notification_repository.go +++ b/api/pkg/repositories/gorm_phone_notification_repository.go @@ -66,11 +66,12 @@ func (repository *gormPhoneNotificationRepository) UpdateStatus(ctx context.Cont } // Schedule a notification to be sent in the future -func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context, messagesPerMinute uint, notification *entities.PhoneNotification) error { +func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context, messagesPerMinute uint, schedule *entities.SendSchedule, notification *entities.PhoneNotification) error { ctx, span := repository.tracer.Start(ctx) defer span.End() if messagesPerMinute == 0 { + notification.ScheduledAt = repository.resolveScheduledAt(time.Now().UTC(), schedule) return repository.insert(ctx, notification) } @@ -86,12 +87,10 @@ func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context, return stacktrace.Propagate(err, msg) } - notification.ScheduledAt = time.Now().UTC() + notification.ScheduledAt = repository.resolveScheduledAt(time.Now().UTC(), schedule) if err == nil { - notification.ScheduledAt = repository.maxTime( - time.Now().UTC(), - lastNotification.ScheduledAt.Add(time.Duration(60/messagesPerMinute)*time.Second), - ) + rateLimitedAt := lastNotification.ScheduledAt.Add(time.Duration(60/messagesPerMinute) * time.Second) + notification.ScheduledAt = repository.resolveScheduledAt(repository.maxTime(notification.ScheduledAt, rateLimitedAt), schedule) } if err = tx.WithContext(ctx).Create(notification).Error; err != nil { @@ -108,6 +107,56 @@ func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context, return nil } +func (repository *gormPhoneNotificationRepository) resolveScheduledAt(current time.Time, schedule *entities.SendSchedule) time.Time { + if schedule == nil || !schedule.IsActive || len(schedule.Windows) == 0 { + return current.UTC() + } + + location, err := time.LoadLocation(schedule.Timezone) + if err != nil { + return current.UTC() + } + + base := current.In(location) + var best time.Time + for dayOffset := 0; dayOffset <= 7; dayOffset++ { + day := base.AddDate(0, 0, dayOffset) + weekday := int(day.Weekday()) + for _, window := range schedule.Windows { + if window.DayOfWeek != weekday { + continue + } + + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location).Add(time.Duration(window.StartMinute) * time.Minute) + end := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location).Add(time.Duration(window.EndMinute) * time.Minute) + + var candidate time.Time + switch { + case dayOffset == 0 && base.Before(start): + candidate = start + case dayOffset == 0 && (base.Equal(start) || (base.After(start) && base.Before(end))): + candidate = base + case dayOffset > 0: + candidate = start + default: + continue + } + + if best.IsZero() || candidate.Before(best) { + best = candidate + } + } + if !best.IsZero() { + break + } + } + + if best.IsZero() { + return current.UTC() + } + return best.UTC() +} + func (repository *gormPhoneNotificationRepository) maxTime(a, b time.Time) time.Time { if a.Unix() > b.Unix() { return a diff --git a/api/pkg/repositories/gorm_send_schedule_repository.go b/api/pkg/repositories/gorm_send_schedule_repository.go new file mode 100644 index 00000000..83405d40 --- /dev/null +++ b/api/pkg/repositories/gorm_send_schedule_repository.go @@ -0,0 +1,83 @@ +package repositories + +import ( + "context" + "errors" + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/palantir/stacktrace" + "gorm.io/gorm" +) + +type gormSendScheduleRepository struct { + logger telemetry.Logger + tracer telemetry.Tracer + db *gorm.DB +} + +func NewGormSendScheduleRepository(logger telemetry.Logger, tracer telemetry.Tracer, db *gorm.DB) SendScheduleRepository { + return &gormSendScheduleRepository{logger: logger.WithService(fmt.Sprintf("%T", &gormSendScheduleRepository{})), tracer: tracer, db: db} +} + +func (r *gormSendScheduleRepository) Store(ctx context.Context, schedule *entities.SendSchedule) error { + ctx, span := r.tracer.Start(ctx) + defer span.End() + if err := r.db.WithContext(ctx).Create(schedule).Error; err != nil { + return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot store send schedule [%s]", schedule.ID))) + } + return nil +} + +func (r *gormSendScheduleRepository) Update(ctx context.Context, schedule *entities.SendSchedule) error { + ctx, span := r.tracer.Start(ctx) + defer span.End() + if err := r.db.WithContext(ctx).Save(schedule).Error; err != nil { + return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot update send schedule [%s]", schedule.ID))) + } + return nil +} + +func (r *gormSendScheduleRepository) Load(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) (*entities.SendSchedule, error) { + ctx, span := r.tracer.Start(ctx) + defer span.End() + item := new(entities.SendSchedule) + err := r.db.WithContext(ctx).Where("user_id = ?", userID).Where("id = ?", scheduleID).First(item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, r.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, fmt.Sprintf("send schedule [%s] not found", scheduleID))) + } + if err != nil { + return nil, r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot load send schedule [%s]", scheduleID))) + } + return item, nil +} + +func (r *gormSendScheduleRepository) Index(ctx context.Context, userID entities.UserID) ([]entities.SendSchedule, error) { + ctx, span := r.tracer.Start(ctx) + defer span.End() + items := make([]entities.SendSchedule, 0) + if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at ASC").Find(&items).Error; err != nil { + return nil, r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot index send schedules for user [%s]", userID))) + } + return items, nil +} + +func (r *gormSendScheduleRepository) Delete(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) error { + ctx, span := r.tracer.Start(ctx) + defer span.End() + if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Where("id = ?", scheduleID).Delete(&entities.SendSchedule{}).Error; err != nil { + return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete send schedule [%s]", scheduleID))) + } + return nil +} + +func (r *gormSendScheduleRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { + ctx, span := r.tracer.Start(ctx) + defer span.End() + if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.SendSchedule{}).Error; err != nil { + return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete send schedules for user [%s]", userID))) + } + return nil +} diff --git a/api/pkg/repositories/phone_notification_repository.go b/api/pkg/repositories/phone_notification_repository.go index 87f78490..c547379e 100644 --- a/api/pkg/repositories/phone_notification_repository.go +++ b/api/pkg/repositories/phone_notification_repository.go @@ -11,7 +11,7 @@ import ( // PhoneNotificationRepository loads and persists an entities.PhoneNotification type PhoneNotificationRepository interface { // Schedule a new entities.PhoneNotification - Schedule(ctx context.Context, messagesPerMinute uint, notification *entities.PhoneNotification) error + Schedule(ctx context.Context, messagesPerMinute uint, schedule *entities.SendSchedule, notification *entities.PhoneNotification) error // UpdateStatus of a notification UpdateStatus(ctx context.Context, notificationID uuid.UUID, status entities.PhoneNotificationStatus) error diff --git a/api/pkg/repositories/send_schedule_repository.go b/api/pkg/repositories/send_schedule_repository.go new file mode 100644 index 00000000..7b232d13 --- /dev/null +++ b/api/pkg/repositories/send_schedule_repository.go @@ -0,0 +1,18 @@ +package repositories + +import ( + "context" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" +) + +// SendScheduleRepository loads and persists entities.SendSchedule. +type SendScheduleRepository interface { + Store(ctx context.Context, schedule *entities.SendSchedule) error + Update(ctx context.Context, schedule *entities.SendSchedule) error + Load(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) (*entities.SendSchedule, error) + Index(ctx context.Context, userID entities.UserID) ([]entities.SendSchedule, error) + Delete(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) error + DeleteAllForUser(ctx context.Context, userID entities.UserID) error +} diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index f920fad4..bb710024 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -4,6 +4,8 @@ import ( "strings" "time" + "github.com/google/uuid" + "github.com/nyaruka/phonenumbers" "github.com/NdoleStudio/httpsms/pkg/entities" @@ -28,6 +30,8 @@ type PhoneUpsert struct { // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot SIM string `json:"sim" example:"SIM1"` + + ScheduleID *string `json:"schedule_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` } // Sanitize sets defaults to MessageOutstanding @@ -69,6 +73,13 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin maxSendAttempts = &input.MaxSendAttempts } + var scheduleID *uuid.UUID + if input.ScheduleID != nil && strings.TrimSpace(*input.ScheduleID) != "" { + if parsed, err := uuid.Parse(strings.TrimSpace(*input.ScheduleID)); err == nil { + scheduleID = &parsed + } + } + return &services.PhoneUpsertParams{ Source: source, PhoneNumber: phone, @@ -79,5 +90,6 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin FcmToken: fcmToken, UserID: user.ID, SIM: entities.SIM(input.SIM), + ScheduleID: scheduleID, } } diff --git a/api/pkg/requests/send_schedule_store_request.go b/api/pkg/requests/send_schedule_store_request.go new file mode 100644 index 00000000..5d9f06b0 --- /dev/null +++ b/api/pkg/requests/send_schedule_store_request.go @@ -0,0 +1,48 @@ +package requests + +import ( + "sort" + "strings" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/services" +) + +type SendScheduleWindow struct { + DayOfWeek int `json:"day_of_week"` + StartMinute int `json:"start_minute"` + EndMinute int `json:"end_minute"` +} + +type SendScheduleStore struct { + request + Name string `json:"name"` + Timezone string `json:"timezone"` + IsActive bool `json:"is_active"` + Windows []SendScheduleWindow `json:"windows"` +} + +func (input *SendScheduleStore) Sanitize() SendScheduleStore { + input.Name = strings.TrimSpace(input.Name) + input.Timezone = strings.TrimSpace(input.Timezone) + windows := make([]SendScheduleWindow, 0, len(input.Windows)) + for _, item := range input.Windows { + windows = append(windows, SendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute}) + } + sort.SliceStable(windows, func(i, j int) bool { + if windows[i].DayOfWeek == windows[j].DayOfWeek { + return windows[i].StartMinute < windows[j].StartMinute + } + return windows[i].DayOfWeek < windows[j].DayOfWeek + }) + input.Windows = windows + return *input +} + +func (input *SendScheduleStore) ToParams(user entities.AuthContext) *services.SendScheduleUpsertParams { + windows := make([]entities.SendScheduleWindow, 0, len(input.Windows)) + for _, item := range input.Windows { + windows = append(windows, entities.SendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute}) + } + return &services.SendScheduleUpsertParams{UserID: user.ID, Name: input.Name, Timezone: input.Timezone, IsActive: input.IsActive, Windows: windows} +} diff --git a/api/pkg/responses/send_schedule_responses.go b/api/pkg/responses/send_schedule_responses.go new file mode 100644 index 00000000..405b4617 --- /dev/null +++ b/api/pkg/responses/send_schedule_responses.go @@ -0,0 +1,13 @@ +package responses + +import "github.com/NdoleStudio/httpsms/pkg/entities" + +type SendSchedulesResponse struct { + response + Data []entities.SendSchedule `json:"data"` +} + +type SendScheduleResponse struct { + response + Data entities.SendSchedule `json:"data"` +} diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 7907d6d6..e8b7e013 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -24,6 +24,7 @@ type PhoneNotificationService struct { tracer telemetry.Tracer phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository + sendScheduleRepository repositories.SendScheduleRepository messagingClient *messaging.Client eventDispatcher *EventDispatcher } @@ -35,6 +36,7 @@ func NewNotificationService( messagingClient *messaging.Client, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, + sendScheduleRepository repositories.SendScheduleRepository, dispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ @@ -43,6 +45,7 @@ func NewNotificationService( messagingClient: messagingClient, phoneNotificationRepository: phoneNotificationRepository, phoneRepository: phoneRepository, + sendScheduleRepository: sendScheduleRepository, eventDispatcher: dispatcher, } } @@ -178,7 +181,19 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P UpdatedAt: time.Now().UTC(), } - if err = service.phoneNotificationRepository.Schedule(ctx, phone.MessagesPerMinute, notification); err != nil { + var schedule *entities.SendSchedule + if phone.ScheduleID != nil { + schedule, err = service.sendScheduleRepository.Load(ctx, params.UserID, *phone.ScheduleID) + if err != nil && stacktrace.GetCode(err) != repositories.ErrCodeNotFound { + msg := fmt.Sprintf("cannot load send schedule [%s] for phone [%s]", *phone.ScheduleID, phone.ID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + schedule = nil + } + } + + if err = service.phoneNotificationRepository.Schedule(ctx, phone.MessagesPerMinute, schedule, notification); err != nil { msg := fmt.Sprintf("cannot schedule notification for message [%s] to phone [%s]", params.MessageID, phone.ID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go index df8e2104..9fa01ab0 100644 --- a/api/pkg/services/phone_service.go +++ b/api/pkg/services/phone_service.go @@ -91,6 +91,7 @@ type PhoneUpsertParams struct { MessageExpirationDuration *time.Duration MissedCallAutoReply *string SIM entities.SIM + ScheduleID *uuid.UUID Source string UserID entities.UserID } @@ -111,6 +112,7 @@ func (service *PhoneService) Upsert(ctx context.Context, params *PhoneUpsertPara UserID: params.UserID, FcmToken: params.FcmToken, SIM: params.SIM, + ScheduleID: params.ScheduleID, }) } @@ -132,6 +134,7 @@ func (service *PhoneService) Upsert(ctx context.Context, params *PhoneUpsertPara UserID: params.UserID, FcmToken: params.FcmToken, SIM: params.SIM, + ScheduleID: params.ScheduleID, }) } @@ -207,6 +210,7 @@ type PhoneFCMTokenParams struct { UserID entities.UserID FcmToken *string SIM entities.SIM + ScheduleID *uuid.UUID } // UpsertFCMToken the FCM token for an entities.Phone @@ -251,6 +255,7 @@ func (service *PhoneService) createPhone(ctx context.Context, params *PhoneFCMTo MaxSendAttempts: 2, SIM: params.SIM, MissedCallAutoReply: nil, + ScheduleID: params.ScheduleID, PhoneNumber: phonenumbers.Format(params.PhoneNumber, phonenumbers.E164), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), @@ -294,6 +299,7 @@ func (service *PhoneService) update(phone *entities.Phone, params *PhoneUpsertPa } phone.SIM = params.SIM + phone.ScheduleID = params.ScheduleID return phone } diff --git a/api/pkg/services/send_schedule_service.go b/api/pkg/services/send_schedule_service.go new file mode 100644 index 00000000..96a72151 --- /dev/null +++ b/api/pkg/services/send_schedule_service.go @@ -0,0 +1,136 @@ +package services + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/palantir/stacktrace" +) + +type SendScheduleService struct { + service + logger telemetry.Logger + tracer telemetry.Tracer + repository repositories.SendScheduleRepository +} + +func NewSendScheduleService(logger telemetry.Logger, tracer telemetry.Tracer, repository repositories.SendScheduleRepository) *SendScheduleService { + return &SendScheduleService{logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleService{})), tracer: tracer, repository: repository} +} + +type SendScheduleUpsertParams struct { + UserID entities.UserID + Name string + Timezone string + IsActive bool + Windows []entities.SendScheduleWindow +} + +func (service *SendScheduleService) Index(ctx context.Context, userID entities.UserID) ([]entities.SendSchedule, error) { + return service.repository.Index(ctx, userID) +} + +func (service *SendScheduleService) Load(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) (*entities.SendSchedule, error) { + return service.repository.Load(ctx, userID, scheduleID) +} + +func (service *SendScheduleService) Store(ctx context.Context, params *SendScheduleUpsertParams) (*entities.SendSchedule, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + schedule := &entities.SendSchedule{ID: uuid.New(), UserID: params.UserID, Name: params.Name, Timezone: params.Timezone, IsActive: params.IsActive, Windows: sanitizeWindows(params.Windows), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + if err := service.repository.Store(ctx, schedule); err != nil { + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot store send schedule [%s]", schedule.ID))) + } + return schedule, nil +} + +func (service *SendScheduleService) Update(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID, params *SendScheduleUpsertParams) (*entities.SendSchedule, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + schedule, err := service.repository.Load(ctx, userID, scheduleID) + if err != nil { + return nil, err + } + schedule.Name = params.Name + schedule.Timezone = params.Timezone + schedule.IsActive = params.IsActive + schedule.Windows = sanitizeWindows(params.Windows) + schedule.UpdatedAt = time.Now().UTC() + if err = service.repository.Update(ctx, schedule); err != nil { + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot update send schedule [%s]", schedule.ID))) + } + return schedule, nil +} + +func (service *SendScheduleService) Delete(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) error { + return service.repository.Delete(ctx, userID, scheduleID) +} + +func (service *SendScheduleService) ResolveScheduledSendTime(ctx context.Context, schedule *entities.SendSchedule, current time.Time) (time.Time, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + if schedule == nil || !schedule.IsActive || len(schedule.Windows) == 0 { + return current.UTC(), nil + } + location, err := time.LoadLocation(schedule.Timezone) + if err != nil { + return current.UTC(), service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot load location [%s]", schedule.Timezone))) + } + + base := current.In(location) + type candidate struct{ t time.Time } + best := time.Time{} + for dayOffset := 0; dayOffset <= 7; dayOffset++ { + day := base.AddDate(0, 0, dayOffset) + weekday := int(day.Weekday()) + for _, window := range schedule.Windows { + if window.DayOfWeek != weekday { + continue + } + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location).Add(time.Duration(window.StartMinute) * time.Minute) + end := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location).Add(time.Duration(window.EndMinute) * time.Minute) + var candidateTime time.Time + switch { + case dayOffset == 0 && base.Before(start): + candidateTime = start + case (dayOffset == 0 && (base.Equal(start) || (base.After(start) && base.Before(end)))) || (dayOffset > 0): + candidateTime = base + if dayOffset > 0 { + candidateTime = start + } + default: + continue + } + if best.IsZero() || candidateTime.Before(best) { + best = candidateTime + } + } + if !best.IsZero() { + break + } + } + if best.IsZero() { + return current.UTC(), nil + } + return best.UTC(), nil +} + +func sanitizeWindows(windows []entities.SendScheduleWindow) []entities.SendScheduleWindow { + result := make([]entities.SendScheduleWindow, 0, len(windows)) + for _, item := range windows { + result = append(result, entities.SendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute}) + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].DayOfWeek == result[j].DayOfWeek { + return result[i].StartMinute < result[j].StartMinute + } + return result[i].DayOfWeek < result[j].DayOfWeek + }) + return result +} diff --git a/api/pkg/validators/phone_handler_validator.go b/api/pkg/validators/phone_handler_validator.go index 2369214e..f9c78255 100644 --- a/api/pkg/validators/phone_handler_validator.go +++ b/api/pkg/validators/phone_handler_validator.go @@ -88,6 +88,15 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(_ context.Context, reques }) result := v.ValidateStruct() + if request.ScheduleID != nil && strings.TrimSpace(*request.ScheduleID) != "" { + if uuidErrors := validator.ValidateUUID(strings.TrimSpace(*request.ScheduleID), "schedule_id"); len(uuidErrors) > 0 { + for key, values := range uuidErrors { + for _, value := range values { + result.Add(key, value) + } + } + } + } if len(result) > 0 { return result } diff --git a/api/pkg/validators/send_schedule_handler_validator.go b/api/pkg/validators/send_schedule_handler_validator.go new file mode 100644 index 00000000..a199f398 --- /dev/null +++ b/api/pkg/validators/send_schedule_handler_validator.go @@ -0,0 +1,55 @@ +package validators + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/thedevsaddam/govalidator" +) + +type SendScheduleHandlerValidator struct { + validator + logger telemetry.Logger + tracer telemetry.Tracer +} + +func NewSendScheduleHandlerValidator(logger telemetry.Logger, tracer telemetry.Tracer) *SendScheduleHandlerValidator { + return &SendScheduleHandlerValidator{logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandlerValidator{})), tracer: tracer} +} + +func (validator *SendScheduleHandlerValidator) ValidateStore(_ context.Context, request requests.SendScheduleStore) url.Values { + v := govalidator.New(govalidator.Options{ + Data: &request, + Rules: govalidator.MapData{ + "name": []string{"required", "min:2", "max:100"}, + "timezone": []string{"required", "min:2", "max:100"}, + }, + }) + result := v.ValidateStruct() + validator.validateWindows(result, request.Windows) + if _, err := time.LoadLocation(request.Timezone); err != nil { + result.Add("timezone", "timezone must be a valid IANA timezone") + } + return result +} + +func (validator *SendScheduleHandlerValidator) validateWindows(result url.Values, windows []requests.SendScheduleWindow) { + for index, item := range windows { + if item.DayOfWeek < 0 || item.DayOfWeek > 6 { + result.Add("windows", fmt.Sprintf("windows[%d].day_of_week must be between 0 and 6", index)) + } + if item.StartMinute < 0 || item.StartMinute > 1439 { + result.Add("windows", fmt.Sprintf("windows[%d].start_minute must be between 0 and 1439", index)) + } + if item.EndMinute < 1 || item.EndMinute > 1440 { + result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be between 1 and 1440", index)) + } + if item.EndMinute <= item.StartMinute { + result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be greater than start_minute", index)) + } + } +} diff --git a/web/pages/settings/index.vue b/web/pages/settings/index.vue index 10cbf94f..62fb4bf8 100644 --- a/web/pages/settings/index.vue +++ b/web/pages/settings/index.vue @@ -171,6 +171,18 @@ + +
Send Schedules
+

+ Manage availability schedules on a dedicated page and attach one + schedule to each phone. Outgoing messages will respect both the + selected schedule and the configured send rate. +

+ + {{ mdiCalendarClock }} + Manage Send Schedules + +
Webhooks

Webhooks allow us to send events to your server for example when @@ -549,6 +561,18 @@ label="Max Send Attempts" > + + +

+ + + {{ mdiArrowLeft }} + + +
Send Schedules
+
+
+ + + +

+ Create reusable availability schedules and attach them to phones + from the Settings page. Outgoing messages respect both the phone + send rate and the selected schedule. +

+ +
+ + {{ mdiPlus }} + Add Schedule + +
+ +
+ +
+ + + + + + {{ schedule.name }} + + + {{ schedule.is_active ? 'Active' : 'Inactive' }} + + + {{ schedule.timezone }} + +
{{ line }}
+
+ + + {{ mdiSquareEditOutline }} + Edit + + + + {{ mdiDelete }} + Delete + + +
+
+
+ + + No schedules yet. Create your first availability schedule. + +
+
+
+
+ + + + {{ activeSchedule.id ? 'Edit Schedule' : 'Add Schedule' }} + + + + + + + + + + + + + +
+
+
{{ day.label }}
+ + Add window +
+
Unavailable
+ + + + + + + + + + {{ mdiDelete }} + + + +
+
+ + Save + + Close + +
+
+ + + + Delete schedule + + Are you sure you want to delete {{ activeSchedule.name }}? + Phones attached to this schedule will no longer have schedule-based + restrictions. + + + Delete + + Cancel + + + + + + + + + diff --git a/web/store/index.ts b/web/store/index.ts index afeeaebd..3ced97f0 100644 --- a/web/store/index.ts +++ b/web/store/index.ts @@ -377,6 +377,7 @@ export const actions = { missed_call_auto_reply: phone.missed_call_auto_reply, max_send_attempts: parseInt(phone.max_send_attempts.toString()), messages_per_minute: parseInt(phone.messages_per_minute.toString()), + schedule_id: (phone as any).schedule_id || null, }) .catch((error: AxiosError) => { context.dispatch('handleAxiosError', error) From a988dea84454620c7a60f619becb46b8b05a9b5a Mon Sep 17 00:00:00 2001 From: giresse19 Date: Mon, 30 Mar 2026 21:10:37 +0300 Subject: [PATCH 02/35] feat: Refactor send schedules based on review feedback --- api/pkg/di/container.go | 4 ++++ web/pages/settings/index.vue | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index c4e91bcc..e8c015d8 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -362,6 +362,10 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK ( container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.User{}))) } + if err = db.AutoMigrate(&entities.SendSchedule{}); err != nil { + container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.SendSchedule{}))) + } + if err = db.AutoMigrate(&entities.Phone{}); err != nil { container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Phone{}))) } diff --git a/web/pages/settings/index.vue b/web/pages/settings/index.vue index b873bfe2..87294e0a 100644 --- a/web/pages/settings/index.vue +++ b/web/pages/settings/index.vue @@ -949,7 +949,11 @@ export default Vue.extend({ return this.$store.getters.getUser.subscription_renews_at != null }, timezones() { - return Intl.supportedValuesOf('timeZone') + try { + return Intl.supportedValuesOf('timeZone') + } catch { + return [] + } }, phoneNumbers() { return this.$store.getters.getPhones.map((phone) => { From 6cfa208804116e219f38831426f8abde514f3175 Mon Sep 17 00:00:00 2001 From: giresse19 Date: Mon, 30 Mar 2026 21:19:01 +0300 Subject: [PATCH 03/35] feat: Refactor send schedules based on review feedback --- web/pages/settings/index.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/settings/index.vue b/web/pages/settings/index.vue index 87294e0a..c6ef9474 100644 --- a/web/pages/settings/index.vue +++ b/web/pages/settings/index.vue @@ -853,7 +853,6 @@ import { mdiSquareEditOutline, mdiQrcode, } from '@mdi/js' -import QRCode from 'qrcode' import axios from '~/plugins/axios' import { toCanvas } from 'qrcode' import { ErrorMessages } from '~/plugins/errors' From 9de415629800568c7d9336c689462f567690e670 Mon Sep 17 00:00:00 2001 From: giresse19 Date: Fri, 10 Apr 2026 17:46:11 +0300 Subject: [PATCH 04/35] fix: handle review feedback --- api/pkg/handlers/send_schedule_handler.go | 84 +-- .../services/phone_notification_service.go | 88 ++- .../send_schedule_handler_validator.go | 93 ++- web/models/api.ts | 58 ++ web/pages/settings/index.vue | 43 +- web/pages/settings/send-schedules/index.vue | 645 +++++++++++++----- web/store/index.ts | 127 +++- 7 files changed, 884 insertions(+), 254 deletions(-) diff --git a/api/pkg/handlers/send_schedule_handler.go b/api/pkg/handlers/send_schedule_handler.go index 22b47deb..70918cdd 100644 --- a/api/pkg/handlers/send_schedule_handler.go +++ b/api/pkg/handlers/send_schedule_handler.go @@ -21,21 +21,30 @@ type SendScheduleHandler struct { service *services.SendScheduleService } -func NewSendScheduleHandler(logger telemetry.Logger, tracer telemetry.Tracer, validator *validators.SendScheduleHandlerValidator, service *services.SendScheduleService) *SendScheduleHandler { - return &SendScheduleHandler{logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandler{})), tracer: tracer, validator: validator, service: service} +func NewSendScheduleHandler( + logger telemetry.Logger, + tracer telemetry.Tracer, + validator *validators.SendScheduleHandlerValidator, + service *services.SendScheduleService, +) *SendScheduleHandler { + return &SendScheduleHandler{ + logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandler{})), + tracer: tracer, + validator: validator, + service: service, + } } func (h *SendScheduleHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { router.Get("/v1/send-schedules", h.computeRoute(middlewares, h.Index)...) router.Post("/v1/send-schedules", h.computeRoute(middlewares, h.Store)...) - router.Get("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Show)...) router.Put("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Update)...) router.Delete("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Delete)...) } // Index godoc // @Summary List send schedules -// @Description Lists the send schedules owned by the authenticated user. +// @Description List all send schedules owned by the authenticated user. // @Security ApiKeyAuth // @Tags Send Schedules // @Produce json @@ -46,47 +55,19 @@ func (h *SendScheduleHandler) RegisterRoutes(router fiber.Router, middlewares .. func (h *SendScheduleHandler) Index(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() + schedules, err := h.service.Index(ctx, h.userIDFomContext(c)) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, "cannot list send schedules")) return h.responseInternalServerError(c) } - return h.responseOK(c, "send schedules fetched successfully", schedules) -} -// Show godoc -// @Summary Show send schedule -// @Description Loads a single send schedule owned by the authenticated user. -// @Security ApiKeyAuth -// @Tags Send Schedules -// @Produce json -// @Param scheduleID path string true "Schedule ID" -// @Success 200 {object} responses.SendScheduleResponse -// @Failure 401 {object} responses.Unauthorized -// @Failure 404 {object} responses.NotFound -// @Failure 500 {object} responses.InternalServerError -// @Router /send-schedules/{scheduleID} [get] -func (h *SendScheduleHandler) Show(c *fiber.Ctx) error { - ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) - defer span.End() - scheduleID, err := uuid.Parse(c.Params("scheduleID")) - if err != nil { - return h.responseBadRequest(c, err) - } - schedule, err := h.service.Load(ctx, h.userIDFomContext(c), scheduleID) - if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot load send schedule")) - if stacktrace.GetCode(err) == 404 { - return h.responseNotFound(c, err.Error()) - } - return h.responseInternalServerError(c) - } - return h.responseOK(c, "send schedule fetched successfully", schedule) + return h.responseOK(c, "send schedules fetched successfully", schedules) } // Store godoc // @Summary Create send schedule -// @Description Creates a send schedule for the authenticated user. +// @Description Create a new send schedule for the authenticated user. // @Security ApiKeyAuth // @Tags Send Schedules // @Accept json @@ -101,26 +82,34 @@ func (h *SendScheduleHandler) Show(c *fiber.Ctx) error { func (h *SendScheduleHandler) Store(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() + var request requests.SendScheduleStore if err := c.BodyParser(&request); err != nil { return h.responseBadRequest(c, err) } + request = request.Sanitize() if errors := h.validator.ValidateStore(ctx, request); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("validation errors [%s], while storing send schedule [%+#v]", spew.Sdump(errors), request))) + ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf( + "validation errors [%s], while storing send schedule [%+#v]", + spew.Sdump(errors), + request, + ))) return h.responseUnprocessableEntity(c, errors, "validation errors while saving send schedule") } + schedule, err := h.service.Store(ctx, request.ToParams(h.userFromContext(c))) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, "cannot create send schedule")) return h.responseInternalServerError(c) } + return h.responseCreated(c, "send schedule created successfully", schedule) } // Update godoc // @Summary Update send schedule -// @Description Updates a send schedule owned by the authenticated user. +// @Description Update a send schedule owned by the authenticated user. // @Security ApiKeyAuth // @Tags Send Schedules // @Accept json @@ -137,19 +126,28 @@ func (h *SendScheduleHandler) Store(c *fiber.Ctx) error { func (h *SendScheduleHandler) Update(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() + scheduleID, err := uuid.Parse(c.Params("scheduleID")) if err != nil { return h.responseBadRequest(c, err) } + var request requests.SendScheduleStore if err = c.BodyParser(&request); err != nil { return h.responseBadRequest(c, err) } + request = request.Sanitize() if errors := h.validator.ValidateStore(ctx, request); len(errors) != 0 { return h.responseUnprocessableEntity(c, errors, "validation errors while updating send schedule") } - schedule, err := h.service.Update(ctx, h.userIDFomContext(c), scheduleID, request.ToParams(h.userFromContext(c))) + + schedule, err := h.service.Update( + ctx, + h.userIDFomContext(c), + scheduleID, + request.ToParams(h.userFromContext(c)), + ) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, "cannot update send schedule")) if stacktrace.GetCode(err) == 404 { @@ -157,12 +155,13 @@ func (h *SendScheduleHandler) Update(c *fiber.Ctx) error { } return h.responseInternalServerError(c) } + return h.responseOK(c, "send schedule updated successfully", schedule) } // Delete godoc // @Summary Delete send schedule -// @Description Deletes a send schedule owned by the authenticated user. +// @Description Delete a send schedule owned by the authenticated user. // @Security ApiKeyAuth // @Tags Send Schedules // @Produce json @@ -170,18 +169,25 @@ func (h *SendScheduleHandler) Update(c *fiber.Ctx) error { // @Success 204 {object} responses.NoContent // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound // @Failure 500 {object} responses.InternalServerError // @Router /send-schedules/{scheduleID} [delete] func (h *SendScheduleHandler) Delete(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() + scheduleID, err := uuid.Parse(c.Params("scheduleID")) if err != nil { return h.responseBadRequest(c, err) } + if err = h.service.Delete(ctx, h.userIDFomContext(c), scheduleID); err != nil { ctxLogger.Error(stacktrace.Propagate(err, "cannot delete send schedule")) + if stacktrace.GetCode(err) == 404 { + return h.responseNotFound(c, err.Error()) + } return h.responseInternalServerError(c) } + return h.responseNoContent(c, "send schedule deleted successfully") } diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index e8b7e013..38c577b5 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -40,7 +40,7 @@ func NewNotificationService( dispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ - logger: logger.WithService(fmt.Sprintf("%T", s)), + logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), tracer: tracer, messagingClient: messagingClient, phoneNotificationRepository: phoneNotificationRepository, @@ -95,7 +95,13 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p return nil } - ctxLogger.Info(fmt.Sprintf("successfully sent heartbeat FCM [%s] to phone with ID [%s] for user [%s] and monitor [%s]", result, payload.PhoneID, payload.UserID, payload.MonitorID)) + ctxLogger.Info(fmt.Sprintf( + "successfully sent heartbeat FCM [%s] to phone with ID [%s] for user [%s] and monitor [%s]", + result, + payload.PhoneID, + payload.UserID, + payload.MonitorID, + )) return nil } @@ -137,7 +143,15 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone Token: *phone.FcmToken, }) if err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, fmt.Sprintf("cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]", phone.ID, phone.UserID, params.MessageID))) + ctxLogger.Warn(stacktrace.Propagate( + err, + fmt.Sprintf( + "cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]", + phone.ID, + phone.UserID, + params.MessageID, + ), + )) msg := fmt.Sprintf("cannot send notification for to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) return service.handleNotificationFailed(ctx, errors.New(msg), params) } @@ -184,12 +198,13 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P var schedule *entities.SendSchedule if phone.ScheduleID != nil { schedule, err = service.sendScheduleRepository.Load(ctx, params.UserID, *phone.ScheduleID) - if err != nil && stacktrace.GetCode(err) != repositories.ErrCodeNotFound { - msg := fmt.Sprintf("cannot load send schedule [%s] for phone [%s]", *phone.ScheduleID, phone.ID) - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { schedule = nil + err = nil + } + if err != nil { + msg := fmt.Sprintf("cannot load send schedule [%s] for phone [%s]", *phone.ScheduleID, phone.ID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } } @@ -206,11 +221,20 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P return service.tracer.WrapErrorSpan(span, err) } - ctxLogger.Info(fmt.Sprintf("message with id [%s] notification scheduled for [%s] with id [%s]", params.MessageID, notification.ScheduledAt, notification.ID)) + ctxLogger.Info(fmt.Sprintf( + "message with id [%s] notification scheduled for [%s] with id [%s]", + params.MessageID, + notification.ScheduledAt, + notification.ID, + )) return nil } -func (service *PhoneNotificationService) dispatchMessageNotificationSend(ctx context.Context, source string, notification *entities.PhoneNotification) error { +func (service *PhoneNotificationService) dispatchMessageNotificationSend( + ctx context.Context, + source string, + notification *entities.PhoneNotification, +) error { event, err := service.createMessageNotificationSendEvent(source, &events.MessageNotificationSendPayload{ MessageID: notification.MessageID, UserID: notification.UserID, @@ -228,7 +252,11 @@ func (service *PhoneNotificationService) dispatchMessageNotificationSend(ctx con return nil } -func (service *PhoneNotificationService) dispatchMessageNotificationScheduled(ctx context.Context, params *PhoneNotificationScheduleParams, notification *entities.PhoneNotification) error { +func (service *PhoneNotificationService) dispatchMessageNotificationScheduled( + ctx context.Context, + params *PhoneNotificationScheduleParams, + notification *entities.PhoneNotification, +) error { event, err := service.createMessageNotificationScheduledEvent(params.Source, &events.MessageNotificationScheduledPayload{ MessageID: notification.MessageID, Owner: params.Owner, @@ -273,7 +301,12 @@ func (service *PhoneNotificationService) handleNotificationFailed(ctx context.Co return nil } -func (service *PhoneNotificationService) handleNotificationSent(ctx context.Context, phone *entities.Phone, result string, params *PhoneNotificationSendParams) error { +func (service *PhoneNotificationService) handleNotificationSent( + ctx context.Context, + phone *entities.Phone, + result string, + params *PhoneNotificationSendParams, +) error { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -294,15 +327,26 @@ func (service *PhoneNotificationService) handleNotificationSent(ctx context.Cont return nil } -func (service *PhoneNotificationService) createMessageNotificationScheduledEvent(source string, payload *events.MessageNotificationScheduledPayload) (cloudevents.Event, error) { +func (service *PhoneNotificationService) createMessageNotificationScheduledEvent( + source string, + payload *events.MessageNotificationScheduledPayload, +) (cloudevents.Event, error) { return service.createEvent(events.EventTypeMessageNotificationScheduled, source, payload) } -func (service *PhoneNotificationService) createMessageNotificationSendEvent(source string, payload *events.MessageNotificationSendPayload) (cloudevents.Event, error) { +func (service *PhoneNotificationService) createMessageNotificationSendEvent( + source string, + payload *events.MessageNotificationSendPayload, +) (cloudevents.Event, error) { return service.createEvent(events.EventTypeMessageNotificationSend, source, payload) } -func (service *PhoneNotificationService) createMessageNotificationSentEvent(source string, phone *entities.Phone, fcmMessageID string, params *PhoneNotificationSendParams) (cloudevents.Event, error) { +func (service *PhoneNotificationService) createMessageNotificationSentEvent( + source string, + phone *entities.Phone, + fcmMessageID string, + params *PhoneNotificationSendParams, +) (cloudevents.Event, error) { event := cloudevents.NewEvent() event.SetSource(source) @@ -329,7 +373,11 @@ func (service *PhoneNotificationService) createMessageNotificationSentEvent(sour return event, nil } -func (service *PhoneNotificationService) createMessageNotificationFailedEvent(source string, errorMessage string, params *PhoneNotificationSendParams) (cloudevents.Event, error) { +func (service *PhoneNotificationService) createMessageNotificationFailedEvent( + source string, + errorMessage string, + params *PhoneNotificationSendParams, +) (cloudevents.Event, error) { event := cloudevents.NewEvent() event.SetSource(source) @@ -354,7 +402,11 @@ func (service *PhoneNotificationService) createMessageNotificationFailedEvent(so return event, nil } -func (service *PhoneNotificationService) updateStatus(ctx context.Context, notificationID uuid.UUID, status entities.PhoneNotificationStatus) { +func (service *PhoneNotificationService) updateStatus( + ctx context.Context, + notificationID uuid.UUID, + status entities.PhoneNotificationStatus, +) { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -362,9 +414,9 @@ func (service *PhoneNotificationService) updateStatus(ctx context.Context, notif err := service.phoneNotificationRepository.UpdateStatus(ctx, notificationID, status) if err != nil { - msg := fmt.Sprintf("cannot update status of notificaiton with id [%s] to [%s]", notificationID, status) + msg := fmt.Sprintf("cannot update status of notification with id [%s] to [%s]", notificationID, status) ctxLogger.Error(stacktrace.Propagate(err, msg)) } - ctxLogger.Info(fmt.Sprintf("updated status of notificaiton with id [%s] to [%s]", notificationID, status)) + ctxLogger.Info(fmt.Sprintf("updated status of notification with id [%s] to [%s]", notificationID, status)) } diff --git a/api/pkg/validators/send_schedule_handler_validator.go b/api/pkg/validators/send_schedule_handler_validator.go index a199f398..313eba9d 100644 --- a/api/pkg/validators/send_schedule_handler_validator.go +++ b/api/pkg/validators/send_schedule_handler_validator.go @@ -11,17 +11,28 @@ import ( "github.com/thedevsaddam/govalidator" ) +const maxWindowsPerDay = 6 + type SendScheduleHandlerValidator struct { validator logger telemetry.Logger tracer telemetry.Tracer } -func NewSendScheduleHandlerValidator(logger telemetry.Logger, tracer telemetry.Tracer) *SendScheduleHandlerValidator { - return &SendScheduleHandlerValidator{logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandlerValidator{})), tracer: tracer} +func NewSendScheduleHandlerValidator( + logger telemetry.Logger, + tracer telemetry.Tracer, +) *SendScheduleHandlerValidator { + return &SendScheduleHandlerValidator{ + logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandlerValidator{})), + tracer: tracer, + } } -func (validator *SendScheduleHandlerValidator) ValidateStore(_ context.Context, request requests.SendScheduleStore) url.Values { +func (validator *SendScheduleHandlerValidator) ValidateStore( + _ context.Context, + request requests.SendScheduleStore, +) url.Values { v := govalidator.New(govalidator.Options{ Data: &request, Rules: govalidator.MapData{ @@ -29,27 +40,77 @@ func (validator *SendScheduleHandlerValidator) ValidateStore(_ context.Context, "timezone": []string{"required", "min:2", "max:100"}, }, }) + result := v.ValidateStruct() validator.validateWindows(result, request.Windows) + if _, err := time.LoadLocation(request.Timezone); err != nil { result.Add("timezone", "timezone must be a valid IANA timezone") } + return result } -func (validator *SendScheduleHandlerValidator) validateWindows(result url.Values, windows []requests.SendScheduleWindow) { +func (validator *SendScheduleHandlerValidator) validateWindows( + result url.Values, + windows []requests.SendScheduleWindow, +) { + windowsPerDay := make(map[int]int) + for index, item := range windows { - if item.DayOfWeek < 0 || item.DayOfWeek > 6 { - result.Add("windows", fmt.Sprintf("windows[%d].day_of_week must be between 0 and 6", index)) - } - if item.StartMinute < 0 || item.StartMinute > 1439 { - result.Add("windows", fmt.Sprintf("windows[%d].start_minute must be between 0 and 1439", index)) - } - if item.EndMinute < 1 || item.EndMinute > 1440 { - result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be between 1 and 1440", index)) - } - if item.EndMinute <= item.StartMinute { - result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be greater than start_minute", index)) - } + validator.validateDayOfWeek(result, index, item, windowsPerDay) + validator.validateStartMinute(result, index, item) + validator.validateEndMinute(result, index, item) + validator.validateWindowRange(result, index, item) + } +} + +func (validator *SendScheduleHandlerValidator) validateDayOfWeek( + result url.Values, + index int, + item requests.SendScheduleWindow, + windowsPerDay map[int]int, +) { + if item.DayOfWeek < 0 || item.DayOfWeek > 6 { + result.Add("windows", fmt.Sprintf("windows[%d].day_of_week must be between 0 and 6", index)) + return + } + + windowsPerDay[item.DayOfWeek]++ + if windowsPerDay[item.DayOfWeek] > maxWindowsPerDay { + result.Add( + "windows", + fmt.Sprintf("day_of_week %d cannot have more than %d windows", item.DayOfWeek, maxWindowsPerDay), + ) + } +} + +func (validator *SendScheduleHandlerValidator) validateStartMinute( + result url.Values, + index int, + item requests.SendScheduleWindow, +) { + if item.StartMinute < 0 || item.StartMinute > 1439 { + result.Add("windows", fmt.Sprintf("windows[%d].start_minute must be between 0 and 1439", index)) + } +} + +func (validator *SendScheduleHandlerValidator) validateEndMinute( + result url.Values, + index int, + item requests.SendScheduleWindow, +) { + if item.EndMinute < 1 || item.EndMinute > 1440 { + result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be between 1 and 1440", index)) + } +} + +func (validator *SendScheduleHandlerValidator) validateWindowRange( + result url.Values, + index int, + item requests.SendScheduleWindow, +) { + if item.EndMinute <= item.StartMinute { + result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be greater than start_minute", index)) } } diff --git a/web/models/api.ts b/web/models/api.ts index 660e7493..c305c134 100644 --- a/web/models/api.ts +++ b/web/models/api.ts @@ -172,6 +172,8 @@ export interface EntitiesPhone { missed_call_auto_reply?: string /** @example "+18005550199" */ phone_number: string + /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ + schedule_id?: string | null /** SIM card that received the message */ sim: string /** @example "2022-06-05T14:26:10.303278+03:00" */ @@ -255,6 +257,62 @@ export interface EntitiesWebhook { user_id: string } +export interface EntitiesSendScheduleWindow { + /** @example 1 */ + day_of_week: number + /** @example 1020 */ + end_minute: number + /** @example 540 */ + start_minute: number +} + +export interface EntitiesSendSchedule { + /** @example true */ + is_active: boolean + /** @example "2022-06-05T14:26:02.302718+03:00" */ + created_at: string + /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ + id: string + /** @example "Business Hours" */ + name: string + /** @example "Africa/Accra" */ + timezone: string + /** @example "2022-06-05T14:26:10.303278+03:00" */ + updated_at: string + /** @example "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" */ + user_id: string + windows: EntitiesSendScheduleWindow[] +} + +export interface RequestsSendScheduleWindow { + day_of_week: number + end_minute: number + start_minute: number +} + +export interface RequestsSendScheduleStore { + is_active: boolean + name: string + timezone: string + windows: RequestsSendScheduleWindow[] +} + +export interface ResponsesSendScheduleResponse { + data: EntitiesSendSchedule + /** @example "Request handled successfully" */ + message: string + /** @example "success" */ + status: string +} + +export interface ResponsesSendSchedulesResponse { + data: EntitiesSendSchedule[] + /** @example "Request handled successfully" */ + message: string + /** @example "success" */ + status: string +} + export interface RequestsDiscordStore { incoming_channel_id: string name: string diff --git a/web/pages/settings/index.vue b/web/pages/settings/index.vue index c6ef9474..56178d9e 100644 --- a/web/pages/settings/index.vue +++ b/web/pages/settings/index.vue @@ -50,6 +50,7 @@ @change="updateTimezone" > +
API Key

Use your API Key in the x-api-key HTTP Header when @@ -256,6 +257,7 @@ >Documentation +

Discord Integration
@@ -322,6 +324,7 @@ > Add Discord +
Phones

List of mobile phones which are registered for sending and @@ -382,6 +385,7 @@ +

Email Notifications
@@ -427,7 +431,11 @@ {{ mdiContentSave }} Save Notification Settings -
+ +
Delete Account

@@ -497,6 +505,7 @@ + Edit Phone @@ -605,7 +614,12 @@ - + + Add a new  @@ -720,7 +734,12 @@ - + + Add a new  @@ -853,7 +872,7 @@ import { mdiSquareEditOutline, mdiQrcode, } from '@mdi/js' -import axios from '~/plugins/axios' +import { EntitiesSendSchedule } from '~/models/api' import { toCanvas } from 'qrcode' import { ErrorMessages } from '~/plugins/errors' import LoadingButton from '~/components/LoadingButton.vue' @@ -1263,13 +1282,15 @@ export default Vue.extend({ }) }, - async loadSendSchedules() { - try { - const response = await axios.get('/v1/send-schedules') - this.sendSchedules = response.data?.data || [] - } catch (error) { - this.sendSchedules = [] - } + loadSendSchedules() { + this.$store + .dispatch('getSendSchedules') + .then((sendSchedules) => { + this.sendSchedules = sendSchedules + }) + .catch(() => { + this.sendSchedules = [] + }) }, loadWebhooks() { diff --git a/web/pages/settings/send-schedules/index.vue b/web/pages/settings/send-schedules/index.vue index 05d098c1..d271f7a2 100644 --- a/web/pages/settings/send-schedules/index.vue +++ b/web/pages/settings/send-schedules/index.vue @@ -1,5 +1,9 @@ +

Send Schedules
+

+ Create availability schedules and attach them to each phone. + Outgoing messages sent outside the schedule window are queued and + delivered when the schedule opens according to your + configured send rate. +

+ + {{ mdiCalendarClock }} + Manage Send Schedules + +
Email Notifications
@@ -432,10 +436,7 @@ Save Notification Settings -
+
Delete Account

@@ -615,11 +616,7 @@ - + Add a new  @@ -735,11 +732,7 @@ - + Add a new  From acd63b046fdc59eafa0a2a2d04e0c046fc5da9e1 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 15:11:51 +0300 Subject: [PATCH 12/35] feat(web): add send schedule create/edit/delete dialog to settings page Replace the separate /settings/send-schedules page with an inline dialog on the main settings page, matching the pattern used for phones, webhooks, and Discord integrations. - Add v-dialog for creating and editing send schedules with day/time windows - Add delete confirmation dialog - Fix edit button in schedules table (was incorrectly calling showEditPhone) - Remove the now-redundant /settings/send-schedules/ page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/pages/settings/index.vue | 559 ++++++++++++++++- web/pages/settings/send-schedules/index.vue | 633 -------------------- 2 files changed, 531 insertions(+), 661 deletions(-) delete mode 100644 web/pages/settings/send-schedules/index.vue diff --git a/web/pages/settings/index.vue b/web/pages/settings/index.vue index a1250731..ed190566 100644 --- a/web/pages/settings/index.vue +++ b/web/pages/settings/index.vue @@ -211,6 +211,7 @@ {{ event }} @@ -360,6 +361,7 @@ @@ -374,7 +376,9 @@ -

Send Schedules
+
+ Send Schedules +

Create availability schedules and attach them to each phone. Outgoing messages sent outside the schedule window are queued and @@ -385,9 +389,51 @@ >configured send rate.

- + + + + {{ mdiCalendarClock }} - Manage Send Schedules + Create Send Schedule
@@ -606,7 +652,12 @@ Update - + {{ mdiDelete }} @@ -845,10 +896,205 @@ + + + + + Add Send Schedule + Edit Send Schedule + + + + + + + + + + + + + + + +
+ {{ message }} +
+
+ + + +
+
+ {{ day.label }} +
+ + + + {{ mdiPlus }} + Add window + +
+ +
+ Unavailable +
+ +
+
+ +
+
+
+ +
+
+ + {{ mdiDelete }} + +
+
+
+
+
+ + + Save Schedule + + + + {{ mdiContentSave }} + + Update Schedule + + + + + {{ mdiDelete }} + + Delete + + +
+
+ + + + Delete schedule + + Are you sure you want to delete {{ activeSchedule.name }}? Phones attached to this schedule will no longer have schedule-based + restrictions. + + + + Delete + + + Cancel + + + - diff --git a/web/pages/settings/send-schedules/index.vue b/web/pages/settings/send-schedules/index.vue deleted file mode 100644 index d271f7a2..00000000 --- a/web/pages/settings/send-schedules/index.vue +++ /dev/null @@ -1,633 +0,0 @@ - - - - - From 1e53c554958322aa7aaa765e8fcc4a214fd7f1bc Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 15:27:02 +0300 Subject: [PATCH 13/35] docs: add entitlement service design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-05-03-entitlement-service-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-entitlement-service-design.md diff --git a/docs/superpowers/specs/2026-05-03-entitlement-service-design.md b/docs/superpowers/specs/2026-05-03-entitlement-service-design.md new file mode 100644 index 00000000..c22b8a9b --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-entitlement-service-design.md @@ -0,0 +1,188 @@ +# Entitlement Service Design + +## Problem + +The send schedule feature (and future features) need usage limits based on the user's subscription plan. Free users should be limited to 1 send schedule; paid users get unlimited. The system must be: + +- **Scalable**: Easy to add new entity limits without architectural changes +- **Configurable**: Disabled by default for self-hosted deployments, enabled via env var for cloud +- **Non-invasive**: Enforced at the handler layer, before business logic executes + +## Approach + +Create a dedicated `EntitlementService` in `pkg/services/` that: + +1. Reads `ENTITLEMENT_ENABLED` from environment (defaults to `false`) +2. Defines a code-based map of entity limits per subscription plan +3. Exposes a single `Check()` method that handlers call before creating resources +4. Returns 402 Payment Required when a free user exceeds their limit + +## Configuration + +### Environment Variable + +```env +# Set to "true" on cloud deployment; self-hosted defaults to false (no limits) +ENTITLEMENT_ENABLED=false +``` + +### Entity Limits (code-based) + +```go +// entityLimits maps entity name → subscription plan → max count +// A limit of 0 means unlimited. If a plan is not listed, it defaults to unlimited. +var entityLimits = map[string]map[entities.SubscriptionName]int{ + "MessageSendSchedule": { + entities.SubscriptionNameFree: 1, + }, + // Future: add more entities here + // "Webhook": { + // entities.SubscriptionNameFree: 3, + // }, +} +``` + +## Service Interface + +```go +// EntitlementService checks whether a user can create more of a given entity. +type EntitlementService struct { + logger telemetry.Logger + tracer telemetry.Tracer + enabled bool + userRepository repositories.UserRepository +} + +// NewEntitlementService creates the service. `enabled` comes from ENTITLEMENT_ENABLED env var. +func NewEntitlementService( + logger telemetry.Logger, + tracer telemetry.Tracer, + enabled bool, + userRepository repositories.UserRepository, +) *EntitlementService + +// CheckResult holds the outcome of an entitlement check. +type CheckResult struct { + Allowed bool + Message string +} + +// Check verifies if the user can create another instance of the given entity. +// - If entitlements are disabled (self-hosted), always returns Allowed: true. +// - Loads the user's subscription plan. +// - Looks up the limit for the entity + plan combination. +// - Compares currentCount against the limit. +func (s *EntitlementService) Check( + ctx context.Context, + userID entities.UserID, + entityName string, + currentCount int, +) (*CheckResult, error) +``` + +## Handler Integration + +In `SendScheduleHandler.Store()`: + +```go +func (h *SendScheduleHandler) Store(c *fiber.Ctx) error { + // 1. Validate request (existing logic) + // 2. Get current count (efficient COUNT query) + count, err := h.service.CountByUser(ctx, userID) + if err != nil { ... } + // 3. Check entitlement + result, err := h.entitlementService.Check(ctx, userID, "MessageSendSchedule", count) + if err != nil { + return h.responseInternalServerError(c) + } + if !result.Allowed { + return h.responsePaymentRequired(c, result.Message) + } + // 4. Proceed with creating schedule (existing logic) +} +``` + +## Repository Addition + +Add to `SendScheduleRepository` interface and GORM implementation: + +```go +// CountByUser returns the number of schedules owned by a user. +CountByUser(ctx context.Context, userID entities.UserID) (int, error) +``` + +```` + +## Error Response + +HTTP 402 Payment Required: + +```json +{ + "message": "Upgrade to a paid plan to create more than 1 send schedule. Visit https://httpsms.com/pricing for details.", + "status": "payment_required" +} +```` + +## Files to Create/Modify + +| Action | File | Change | +| ------ | --------------------------------------------------- | ------------------------------------------------------------ | +| Create | `pkg/services/entitlement_service.go` | New service with limits map, `Check()`, `CheckResult` | +| Modify | `pkg/handlers/handler.go` | Add `responsePaymentRequired()` helper method | +| Modify | `pkg/handlers/send_schedule_handler.go` | Inject `EntitlementService`, add check in `Store()` | +| Modify | `pkg/di/container.go` | Wire `EntitlementService`, read env var, inject into handler | +| Modify | `pkg/repositories/send_schedule_repository.go` | Add `CountByUser()` to interface | +| Modify | `pkg/repositories/gorm_send_schedule_repository.go` | Implement `CountByUser()` with SQL COUNT | +| Modify | `pkg/services/send_schedule_service.go` | Add `CountByUser()` pass-through method | +| Modify | `.env.example` or `.env` | Add `ENTITLEMENT_ENABLED=false` | + +## Concurrency & Race Conditions + +The handler-level check (`count → check → create`) is not atomic. Two concurrent requests could both see `count=0` and both proceed. Mitigations: + +1. **Repository count method**: Use `CountByUser(ctx, userID)` instead of loading all records (efficient SQL `SELECT COUNT(*)`). +2. **Acceptable race window**: For a limit of 1, the worst case is 2 schedules created. This is acceptable because: + - The window is extremely small (single user, same millisecond) + - The consequence is minor (user has 2 schedules instead of 1) + - A DB-level unique constraint is impractical here (limit is per-user count, not per-row uniqueness) +3. **Future hardening**: If stricter enforcement is needed, add an advisory lock or transaction-based count+insert. + +## Counting Semantics + +All schedules owned by the user count toward the limit, regardless of `is_active` status. A user must delete a schedule to free up their quota. + +## Error Handling When Enabled + +- **Entitlements disabled** (`ENTITLEMENT_ENABLED=false`): Always returns `Allowed: true`, zero DB calls. +- **Entitlements enabled, DB error loading user**: Return error (surfaces as 500). Do NOT fail-open — this is a monetized feature gate. +- **Entitlements enabled, entity not in limits map**: Returns `Allowed: true` (entity has no restrictions). + +## Design Decisions + +1. **Handler-layer enforcement**: The handler gets the count and calls `Check()`. This keeps the entitlement service free of domain-specific repository dependencies. +2. **Entity name as key**: Using the entity struct name (e.g., `"MessageSendSchedule"`) makes it self-documenting and matches the user's preference for entity-based naming. +3. **Fail-open when disabled**: Self-hosted users never hit limits. The `enabled` flag short-circuits all checks. +4. **Fail-closed on error when enabled**: If the user can't be loaded and entitlements are enabled, the request fails with 500. +5. **Separate from BillingService**: BillingService handles SMS message counting/billing. EntitlementService handles feature-level access gating. Different concerns. +6. **No caching**: User plan data is already fast to load. Caching can be added later if needed. + +## Swagger & Handler Updates + +- Add `@Failure 402 {object} responses.PaymentRequired` annotation to `Store` route +- Add `responsePaymentRequired` helper to base handler struct +- Update handler constructor to accept `*services.EntitlementService` + +## Testing Strategy + +- Unit test `EntitlementService.Check()` with: + - Disabled mode → always allowed + - Free user at limit → denied + - Free user under limit → allowed + - Paid user → always allowed + - Unknown entity → allowed (no restrictions defined) + - User load error when enabled → returns error +- Handler test for `Store`: + - Free user with 0 schedules → 201 Created + - Free user with 1 schedule → 402 Payment Required + - Paid user with N schedules → 201 Created From 24159f1eeacf268be67a837c55e62d165c2f2cb2 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 16:24:31 +0300 Subject: [PATCH 14/35] feat: add entitlement service to limit send schedules for free users - Create EntitlementService with configurable entity limits per plan - Add ENTITLEMENT_ENABLED env var (defaults to false for self-hosted) - Free users limited to 1 send schedule, paid users unlimited - Add CountByUser to send schedule repository for efficient counting - Return 402 Payment Required when limit exceeded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/.env.docker | 4 + api/pkg/di/container.go | 12 +++ api/pkg/handlers/send_schedule_handler.go | 37 +++++-- .../gorm_send_schedule_repository.go | 22 +++++ .../repositories/send_schedule_repository.go | 3 + api/pkg/services/entitlement_service.go | 96 +++++++++++++++++++ api/pkg/services/send_schedule_service.go | 8 ++ 7 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 api/pkg/services/entitlement_service.go diff --git a/api/.env.docker b/api/.env.docker index 5441d7fe..cf8f8cda 100644 --- a/api/.env.docker +++ b/api/.env.docker @@ -5,6 +5,10 @@ GCP_PROJECT_ID=httpsms-docker USE_HTTP_LOGGER=true +# Set to "true" to enable feature entitlement checks (limits for free users). +# Defaults to "false" for self-hosted deployments (no limits). +ENTITLEMENT_ENABLED=false + EVENTS_QUEUE_TYPE=emulator EVENTS_QUEUE_NAME=events-local EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 676cb1dc..5727f6f7 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -788,6 +788,7 @@ func (container *Container) SendScheduleHandler() *handlers.SendScheduleHandler container.Tracer(), container.SendScheduleHandlerValidator(), container.SendScheduleService(), + container.EntitlementService(), ) } @@ -801,6 +802,17 @@ func (container *Container) BillingUsageRepository() (repository repositories.Bi ) } +// EntitlementService creates a new instance of services.EntitlementService +func (container *Container) EntitlementService() *services.EntitlementService { + container.logger.Debug("creating services.EntitlementService") + return services.NewEntitlementService( + container.Logger(), + container.Tracer(), + os.Getenv("ENTITLEMENT_ENABLED") == "true", + container.UserRepository(), + ) +} + // DiscordRepository creates a new instance of repositories.DiscordRepository func (container *Container) DiscordRepository() (repository repositories.DiscordRepository) { container.logger.Debug("creating GORM repositories.DiscordRepository") diff --git a/api/pkg/handlers/send_schedule_handler.go b/api/pkg/handlers/send_schedule_handler.go index cb636f49..ebf475a3 100644 --- a/api/pkg/handlers/send_schedule_handler.go +++ b/api/pkg/handlers/send_schedule_handler.go @@ -17,10 +17,11 @@ import ( // SendScheduleHandler handles HTTP requests for message send schedules. type SendScheduleHandler struct { handler - logger telemetry.Logger - tracer telemetry.Tracer - validator *validators.SendScheduleHandlerValidator - service *services.SendScheduleService + logger telemetry.Logger + tracer telemetry.Tracer + validator *validators.SendScheduleHandlerValidator + service *services.SendScheduleService + entitlementService *services.EntitlementService } // NewSendScheduleHandler creates a new SendScheduleHandler. @@ -29,12 +30,14 @@ func NewSendScheduleHandler( tracer telemetry.Tracer, validator *validators.SendScheduleHandlerValidator, service *services.SendScheduleService, + entitlementService *services.EntitlementService, ) *SendScheduleHandler { return &SendScheduleHandler{ - logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandler{})), - tracer: tracer, - validator: validator, - service: service, + logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandler{})), + tracer: tracer, + validator: validator, + service: service, + entitlementService: entitlementService, } } @@ -82,6 +85,7 @@ func (h *SendScheduleHandler) Index(c *fiber.Ctx) error { // @Success 201 {object} responses.SendScheduleResponse // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized +// @Failure 402 {object} responses.BadRequest // @Failure 422 {object} responses.UnprocessableEntity // @Failure 500 {object} responses.InternalServerError // @Router /send-schedules [post] @@ -89,6 +93,23 @@ func (h *SendScheduleHandler) Store(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() + userID := h.userIDFomContext(c) + + count, err := h.service.CountByUser(ctx, userID) + if err != nil { + ctxLogger.Error(stacktrace.Propagate(err, "cannot count send schedules for entitlement check")) + return h.responseInternalServerError(c) + } + + result, err := h.entitlementService.Check(ctx, userID, "MessageSendSchedule", count) + if err != nil { + ctxLogger.Error(stacktrace.Propagate(err, "cannot check entitlement for send schedules")) + return h.responseInternalServerError(c) + } + if !result.Allowed { + return h.responsePaymentRequired(c, result.Message) + } + var request requests.SendScheduleStore if err := c.BodyParser(&request); err != nil { return h.responseBadRequest(c, err) diff --git a/api/pkg/repositories/gorm_send_schedule_repository.go b/api/pkg/repositories/gorm_send_schedule_repository.go index afd09472..4ee3c408 100644 --- a/api/pkg/repositories/gorm_send_schedule_repository.go +++ b/api/pkg/repositories/gorm_send_schedule_repository.go @@ -166,3 +166,25 @@ func (r *gormSendScheduleRepository) DeleteAllForUser( return nil } + +// CountByUser returns the number of schedules owned by a user. +func (r *gormSendScheduleRepository) CountByUser( + ctx context.Context, + userID entities.UserID, +) (int, error) { + ctx, span := r.tracer.Start(ctx) + defer span.End() + + var count int64 + if err := r.db.WithContext(ctx). + Model(&entities.MessageSendSchedule{}). + Where("user_id = ?", userID). + Count(&count).Error; err != nil { + return 0, r.tracer.WrapErrorSpan( + span, + stacktrace.Propagate(err, "cannot count send schedules for user [%s]", userID), + ) + } + + return int(count), nil +} diff --git a/api/pkg/repositories/send_schedule_repository.go b/api/pkg/repositories/send_schedule_repository.go index 57e07ca6..d57b42d7 100644 --- a/api/pkg/repositories/send_schedule_repository.go +++ b/api/pkg/repositories/send_schedule_repository.go @@ -26,4 +26,7 @@ type SendScheduleRepository interface { // DeleteAllForUser removes all message send schedules owned by a user. DeleteAllForUser(ctx context.Context, userID entities.UserID) error + + // CountByUser returns the number of schedules owned by a user. + CountByUser(ctx context.Context, userID entities.UserID) (int, error) } diff --git a/api/pkg/services/entitlement_service.go b/api/pkg/services/entitlement_service.go new file mode 100644 index 00000000..62c280ab --- /dev/null +++ b/api/pkg/services/entitlement_service.go @@ -0,0 +1,96 @@ +package services + +import ( + "context" + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/palantir/stacktrace" +) + +// entityLimits maps entity name → subscription plan → max count. +// A limit of 0 means unlimited. If a plan is not listed, it defaults to unlimited (0). +var entityLimits = map[string]map[entities.SubscriptionName]int{ + "MessageSendSchedule": { + entities.SubscriptionNameFree: 1, + }, +} + +// EntitlementCheckResult holds the outcome of an entitlement check. +type EntitlementCheckResult struct { + Allowed bool + Message string +} + +// EntitlementService checks whether a user can create more of a given entity +// based on their subscription plan. +type EntitlementService struct { + service + logger telemetry.Logger + tracer telemetry.Tracer + enabled bool + userRepository repositories.UserRepository +} + +// NewEntitlementService creates a new EntitlementService. +// The enabled flag should come from the ENTITLEMENT_ENABLED environment variable. +func NewEntitlementService( + logger telemetry.Logger, + tracer telemetry.Tracer, + enabled bool, + userRepository repositories.UserRepository, +) *EntitlementService { + return &EntitlementService{ + logger: logger.WithService(fmt.Sprintf("%T", &EntitlementService{})), + tracer: tracer, + enabled: enabled, + userRepository: userRepository, + } +} + +// Check verifies if the user can create another instance of the given entity. +func (service *EntitlementService) Check( + ctx context.Context, + userID entities.UserID, + entityName string, + currentCount int, +) (*EntitlementCheckResult, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + if !service.enabled { + return &EntitlementCheckResult{Allowed: true}, nil + } + + limits, exists := entityLimits[entityName] + if !exists { + return &EntitlementCheckResult{Allowed: true}, nil + } + + user, err := service.userRepository.Load(ctx, userID) + if err != nil { + return nil, service.tracer.WrapErrorSpan( + span, + stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s] for entitlement check", userID)), + ) + } + + limit, hasLimit := limits[user.SubscriptionName] + if !hasLimit || limit == 0 { + return &EntitlementCheckResult{Allowed: true}, nil + } + + if currentCount >= limit { + return &EntitlementCheckResult{ + Allowed: false, + Message: fmt.Sprintf( + "Upgrade to a paid plan to create more than %d send schedule. Visit https://httpsms.com/pricing for details.", + limit, + ), + }, nil + } + + return &EntitlementCheckResult{Allowed: true}, nil +} diff --git a/api/pkg/services/send_schedule_service.go b/api/pkg/services/send_schedule_service.go index 5bbddb7d..b984b32d 100644 --- a/api/pkg/services/send_schedule_service.go +++ b/api/pkg/services/send_schedule_service.go @@ -51,6 +51,14 @@ func (service *SendScheduleService) Index( return service.repository.Index(ctx, userID) } +// CountByUser returns the number of schedules owned by a user. +func (service *SendScheduleService) CountByUser( + ctx context.Context, + userID entities.UserID, +) (int, error) { + return service.repository.CountByUser(ctx, userID) +} + // Load returns a single message send schedule for a user. func (service *SendScheduleService) Load( ctx context.Context, From ceb3b35f1d45b0ccb4314b09e806561708d71477 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 16:30:45 +0300 Subject: [PATCH 15/35] feat: add formatEntityName utility for human-readable entitlement messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts PascalCase entity names to lowercase words with proper pluralization (e.g. MessageSendSchedule → 'message send schedules') Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/services/entitlement_service.go | 42 ++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/api/pkg/services/entitlement_service.go b/api/pkg/services/entitlement_service.go index 62c280ab..2cdae129 100644 --- a/api/pkg/services/entitlement_service.go +++ b/api/pkg/services/entitlement_service.go @@ -3,6 +3,8 @@ package services import ( "context" "fmt" + "strings" + "unicode" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" @@ -86,11 +88,49 @@ func (service *EntitlementService) Check( return &EntitlementCheckResult{ Allowed: false, Message: fmt.Sprintf( - "Upgrade to a paid plan to create more than %d send schedule. Visit https://httpsms.com/pricing for details.", + "Upgrade to a paid plan to create more than %d %s. Visit https://httpsms.com/pricing for details.", limit, + formatEntityName(entityName, true), ), }, nil } return &EntitlementCheckResult{Allowed: true}, nil } + +// formatEntityName converts a PascalCase entity name to lowercase words and optionally pluralizes it. +// e.g. "MessageSendSchedule" → "message send schedules" (plural) or "message send schedule" (singular) +func formatEntityName(name string, plural bool) string { + var words []string + start := 0 + for i := 1; i < len(name); i++ { + if unicode.IsUpper(rune(name[i])) { + words = append(words, strings.ToLower(name[start:i])) + start = i + } + } + words = append(words, strings.ToLower(name[start:])) + + if plural && len(words) > 0 { + last := words[len(words)-1] + switch { + case strings.HasSuffix(last, "s"), strings.HasSuffix(last, "x"), strings.HasSuffix(last, "z"), + strings.HasSuffix(last, "sh"), strings.HasSuffix(last, "ch"): + words[len(words)-1] = last + "es" + case strings.HasSuffix(last, "y") && len(last) > 1 && !isVowel(last[len(last)-2]): + words[len(words)-1] = last[:len(last)-1] + "ies" + default: + words[len(words)-1] = last + "s" + } + } + + return strings.Join(words, " ") +} + +func isVowel(c byte) bool { + switch c { + case 'a', 'e', 'i', 'o', 'u': + return true + } + return false +} From 52b55dd82328f3f3732442271b6c5eb9c182ab87 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 16:34:05 +0300 Subject: [PATCH 16/35] refactor: use go-pluralize for entity name formatting Replace custom pluralization logic with github.com/gertd/go-pluralize for more accurate English pluralization rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/go.mod | 1 + api/go.sum | 2 ++ api/pkg/services/entitlement_service.go | 23 ++++------------------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/api/go.mod b/api/go.mod index 7d752977..40a311d3 100644 --- a/api/go.mod +++ b/api/go.mod @@ -18,6 +18,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/dgraph-io/ristretto/v2 v2.4.0 github.com/dustin/go-humanize v1.0.1 + github.com/gertd/go-pluralize v0.2.1 github.com/go-hermes/hermes/v2 v2.6.2 github.com/gofiber/contrib/otelfiber v1.0.10 github.com/gofiber/fiber/v2 v2.52.13 diff --git a/api/go.sum b/api/go.sum index 1e3acc56..02111796 100644 --- a/api/go.sum +++ b/api/go.sum @@ -112,6 +112,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= +github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= diff --git a/api/pkg/services/entitlement_service.go b/api/pkg/services/entitlement_service.go index 2cdae129..cd4d740f 100644 --- a/api/pkg/services/entitlement_service.go +++ b/api/pkg/services/entitlement_service.go @@ -9,6 +9,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + pluralize "github.com/gertd/go-pluralize" "github.com/palantir/stacktrace" ) @@ -88,7 +89,7 @@ func (service *EntitlementService) Check( return &EntitlementCheckResult{ Allowed: false, Message: fmt.Sprintf( - "Upgrade to a paid plan to create more than %d %s. Visit https://httpsms.com/pricing for details.", + "Upgrade to a paid plan to create more than [%d] %s. Visit https://httpsms.com/pricing for details.", limit, formatEntityName(entityName, true), ), @@ -112,25 +113,9 @@ func formatEntityName(name string, plural bool) string { words = append(words, strings.ToLower(name[start:])) if plural && len(words) > 0 { - last := words[len(words)-1] - switch { - case strings.HasSuffix(last, "s"), strings.HasSuffix(last, "x"), strings.HasSuffix(last, "z"), - strings.HasSuffix(last, "sh"), strings.HasSuffix(last, "ch"): - words[len(words)-1] = last + "es" - case strings.HasSuffix(last, "y") && len(last) > 1 && !isVowel(last[len(last)-2]): - words[len(words)-1] = last[:len(last)-1] + "ies" - default: - words[len(words)-1] = last + "s" - } + client := pluralize.NewClient() + words[len(words)-1] = client.Plural(words[len(words)-1]) } return strings.Join(words, " ") } - -func isVowel(c byte) bool { - switch c { - case 'a', 'e', 'i', 'o', 'u': - return true - } - return false -} From 7ed5909a27573e405116fed72ce59f469c47914d Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 16:44:42 +0300 Subject: [PATCH 17/35] Fix the send schedule --- web/pages/bulk-messages/index.vue | 10 +- web/pages/settings/index.vue | 243 ++++++++++++++++-------------- 2 files changed, 136 insertions(+), 117 deletions(-) diff --git a/web/pages/bulk-messages/index.vue b/web/pages/bulk-messages/index.vue index 5b8f2011..3182530a 100644 --- a/web/pages/bulk-messages/index.vue +++ b/web/pages/bulk-messages/index.vue @@ -39,7 +39,15 @@ >Excel template and upload it here to send your SMS messages to multiple - recipients at once. + recipients at once. You can also configure + send schedules + on your phone to make sure messages are sent out at specific times + of the day e.g + Mon - Fri 9am - 5pm.

{{ errorTitle }}
diff --git a/web/pages/settings/index.vue b/web/pages/settings/index.vue index ed190566..c35fc0c7 100644 --- a/web/pages/settings/index.vue +++ b/web/pages/settings/index.vue @@ -401,9 +401,13 @@ - {{ schedule.name }} - {{ schedule.timezone }} - + + {{ schedule.name }} + + + {{ schedule.timezone }} + +
{{ line[1] }}
- + - Add Send Schedule - Edit Send Schedule + Create Message Send Schedule + Edit Message Send Schedule @@ -914,8 +918,9 @@ v-model="activeSchedule.name" outlined dense - label="Schedule name" - placeholder="Business Hours" + persistent-placeholder + label="Schedule Name" + placeholder="e.g Business Hours" :error="errorMessages.has('name')" :error-messages="errorMessages.get('name')" /> @@ -931,107 +936,91 @@ :error-messages="errorMessages.get('timezone')" /> - - - - - -
- {{ message }} -
-
- - - -
-
- {{ day.label }} -
- - - - {{ mdiPlus }} - Add window - -
- -
- Unavailable -
- -
-
- -
-
-
- -
-
- - {{ mdiDelete }} - -
-
+ + + + + + + + + +
+ + +
+
+ +
+
+
+ +
+
+ + {{ mdiPlus }} + + + {{ mdiDelete }} + +
+
+
+ {{ scheduleWindowError(day.value) }} +
+
- + - + {{ mdiDelete }} Delete
+ + Close + @@ -1254,6 +1244,7 @@ export default Vue.extend({ }, timezones() { try { + // @ts-ignore return Intl.supportedValuesOf('timeZone') } catch { return [] @@ -1425,7 +1416,7 @@ export default Vue.extend({ this.showDiscordEdit = false this.loadDiscordIntegrations() }) - .catch((errors) => { + .catch((errors: ErrorMessages) => { this.errorMessages = errors }) .finally(() => { @@ -1462,7 +1453,7 @@ export default Vue.extend({ this.showDiscordEdit = false this.loadDiscordIntegrations() }) - .catch((errors) => { + .catch((errors: ErrorMessages) => { this.errorMessages = errors }) .finally(() => { @@ -1500,7 +1491,7 @@ export default Vue.extend({ this.showWebhookEdit = false this.loadWebhooks() }) - .catch((errors) => { + .catch((errors: ErrorMessages) => { this.errorMessages = errors }) .finally(() => { @@ -1539,7 +1530,7 @@ export default Vue.extend({ this.showWebhookEdit = false this.loadWebhooks() }) - .catch((errors) => { + .catch((errors: ErrorMessages) => { this.errorMessages = errors }) .finally(() => { @@ -1578,7 +1569,7 @@ export default Vue.extend({ this.loadingSendSchedules = true this.$store .dispatch('getSendSchedules') - .then((sendSchedules) => { + .then((sendSchedules: EntitiesSendSchedule[]) => { this.sendSchedules = sendSchedules }) .finally(() => { @@ -1590,7 +1581,7 @@ export default Vue.extend({ this.loadingWebhooks = true this.$store .dispatch('getWebhooks') - .then((webhooks) => { + .then((webhooks: EntitiesWebhook[]) => { this.webhooks = webhooks }) .finally(() => { @@ -1602,7 +1593,7 @@ export default Vue.extend({ this.loadingDiscordIntegrations = true this.$store .dispatch('getDiscordIntegrations') - .then((discords) => { + .then((discords: EntitiesDiscord[]) => { this.discords = discords }) .finally(() => { @@ -1614,7 +1605,7 @@ export default Vue.extend({ this.deletingAccount = true this.$store .dispatch('deleteUserAccount') - .then((message) => { + .then((message: string) => { this.$store.dispatch('addNotification', { message: message ?? 'Your account has been deleted successfully', type: 'success', @@ -1659,6 +1650,10 @@ export default Vue.extend({ return hours * 60 + minutes }, + getWeekday(index: number): string { + return this.weekDays.find((x) => x.value == index)?.label ?? '' + }, + scheduleSummary(schedule: EntitiesSendSchedule) { return this.weekDays .map((day) => { @@ -1758,6 +1753,22 @@ export default Vue.extend({ }) }, + scheduleWindowError(index: number): string | null { + const messages = this.errorMessages.has('windows') + ? this.errorMessages.get('windows') + : [] + if (messages.length == 0) { + return null + } + + const message = messages.find((x: string) => + x.includes(`Day of week ${index}`), + ) + return message + ? message.replace(`Day of week ${index}`, this.getWeekday(index)) + : null + }, + scheduleRemoveWindow(dayOfWeek: number, index: number) { const matches = this.activeSchedule.windows.filter( (x) => x.day_of_week === dayOfWeek, From 65f63651b308b8f8e0bf622a1fff864d8a0807a0 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 16:58:42 +0300 Subject: [PATCH 18/35] Finish send schedule --- web/pages/settings/index.vue | 173 +++++++++++++++++++---------------- 1 file changed, 95 insertions(+), 78 deletions(-) diff --git a/web/pages/settings/index.vue b/web/pages/settings/index.vue index c35fc0c7..de6c0b05 100644 --- a/web/pages/settings/index.vue +++ b/web/pages/settings/index.vue @@ -904,14 +904,17 @@ Create Message Send Schedule Edit Message Send Schedule - + - - - - - - - - - -
- + +
+
+ +
+
+
+
+ -
-
+
+
+ +
+
+ -
- -
-
-
- -
-
- - {{ mdiPlus }} - - - {{ mdiDelete }} - -
-
-
{{ mdiPlus }} + + - {{ scheduleWindowError(day.value) }} -
-
+ {{ mdiDelete }} +
+ + +
+ {{ scheduleWindowError(day.value) }} +
+ + From 83844a58e0ea62e8161e1961cf92d4d4180d47c8 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:17:55 +0300 Subject: [PATCH 19/35] docs: add scheduling send refactor design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...6-05-03-scheduling-send-refactor-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md diff --git a/docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md b/docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md new file mode 100644 index 00000000..34d78445 --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md @@ -0,0 +1,117 @@ +# Scheduling Send Refactor Design + +## Problem Statement + +The current SMS scheduling logic has two issues: + +1. **No way to send at an exact time without scheduling interference.** When a user specifies a `SendTime`/`SendAt`, the system still applies rate-limiting and schedule window logic, which may shift the actual send time. + +2. **Bulk message contention.** When bulk messages (API or CSV) are sent, all events arrive at the Cloud Tasks queue near-simultaneously, causing DB serialization conflicts in `PhoneNotificationRepository.Schedule()` (which uses `SELECT ... ORDER BY scheduled_at DESC` in a transaction). The current workaround is a hardcoded 1-second spacing hack. + +## Proposed Solution + +### Core Principle + +- **Explicit `SendTime`** = send at exactly that time, bypass all scheduling logic. +- **No `SendTime`** = apply full scheduling logic (rate-limit + schedule windows), with rate-based Cloud Task dispatch delay to prevent DB contention. + +### Design + +#### 1. ExactSendTime Flag (Transient — not persisted) + +A boolean `ExactSendTime` flows through the event system: + +``` +Request → MessageSendParams → MessageAPISentPayload → PhoneNotificationScheduleParams +``` + +When `true`, the notification scheduling layer sets `ScheduledAt` to the exact time and skips rate-limit + window logic. + +#### 2. Rate-Based Dispatch Delay + +For bulk messages without an explicit `SendTime`, instead of the `index * 1s` hack, the service computes: + +```go +interval := time.Minute / time.Duration(messagesPerMinute) +delay := time.Duration(index) * interval +``` + +Where `index` is **per-phone** (not global across the batch). This spreads Cloud Task deliveries at the phone's actual send rate, eliminating DB contention naturally. Duration math avoids integer truncation issues for rates > 60/min or non-divisors of 60. + +#### 3. Per-Endpoint Behavior + +| Endpoint | `SendAt` provided | `SendAt` absent | +| --------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------- | +| Single SMS API (`/v1/messages/send`) | `ExactSendTime=true`, delay = `time.Until(SendAt)` | `ExactSendTime=false`, delay = 0 | +| Bulk SMS API (`/v1/messages/bulk-send`) | N/A (no SendAt field) | `ExactSendTime=false`, delay = `perPhoneIndex * interval` | +| CSV Upload | `ExactSendTime=true`, delay = `time.Until(SendTime)` | `ExactSendTime=false`, delay = `perPhoneIndex * interval` | + +**Index is per-phone**: In a CSV with messages to multiple phones, each phone maintains its own index counter. Messages to Phone A get indices 0, 1, 2... and messages to Phone B get separate indices 0, 1, 2... This ensures correct rate-limiting per phone without over-throttling unrelated phones. + +#### 4. Notification Scheduling Bypass + +In `PhoneNotificationService.Schedule()`: + +```go +if params.ExactSendTime && params.ScheduledSendTime != nil { + notification.ScheduledAt = *params.ScheduledSendTime + // Skip rate-limit and schedule window logic + // Insert directly +} else { + // Existing logic: rate-limit + schedule window +} +``` + +### Changes by File + +| File | Change | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pkg/events/message_api_sent_event.go` | Add `ExactSendTime bool` field to `MessageAPISentPayload` | +| `pkg/services/message_service.go` | Add `Index int` to `MessageSendParams`; update `getSendDelay()` to compute rate-based delay when `Index > 0` and `SendAt == nil`; set `ExactSendTime` on event payload when `SendAt != nil` | +| `pkg/services/phone_notification_service.go` | Add `ExactSendTime bool` + `ScheduledSendTime *time.Time` to `PhoneNotificationScheduleParams`; add bypass path in `Schedule()` when `ExactSendTime && ScheduledSendTime != nil` — insert notification directly without transaction/rate logic | +| `pkg/repositories/gorm_phone_notification_repository.go` | Add `ScheduleExact(ctx, notification)` method that inserts with a fixed `ScheduledAt` (no transaction, no rate query). Add unique constraint or dedupe check on `(message_id)` for pending notifications to ensure idempotency. | +| `pkg/repositories/phone_notification_repository.go` | Add `ScheduleExact` to the repository interface | +| `pkg/listeners/phone_notification_listener.go` | Pass `ExactSendTime` + `ScheduledSendTime` from event payload to service params | +| `pkg/requests/message_bulk_send_request.go` | Remove per-index `SendAt` computation; add `Index` to each `MessageSendParams` | +| `pkg/requests/bulk_message_request.go` | Propagate `Index` into params for CSV rows | +| `pkg/handlers/message_handler.go` | Remove `index * 1s` hack in `BulkSend` handler | +| `pkg/handlers/bulk_message_handler.go` | Compute per-phone index for CSV rows; remove any concurrent scheduling; ensure `Index` is passed to `MessageSendParams` | + +### Data Flow + +``` +User sends request + → Handler creates MessageSendParams (with Index for bulk, ExactSendTime derived from SendAt presence) + → MessageService.SendMessage() + → Computes dispatch delay: + - ExactSendTime: time.Until(SendAt) + - Bulk without SendAt: Index * (60/MessagesPerMinute)s + - Single without SendAt: 0 + → Sets ExactSendTime on MessageAPISentPayload + → DispatchWithTimeout(event, delay) → Cloud Tasks + → [delay elapses] → PhoneNotificationListener.onMessageAPISent() + → PhoneNotificationService.Schedule(params with ExactSendTime) + → If ExactSendTime: insert with exact ScheduledAt + → Else: apply rate-limit + schedule window logic +``` + +### Edge Cases + +- **SendAt in the past**: Send immediately (existing behavior preserved). +- **MessagesPerMinute = 0**: No rate limiting; bulk messages dispatch immediately (existing behavior — `Schedule()` already handles this). Rate-based delay uses 0 when rate is 0. +- **No schedule attached to phone**: Window logic returns current time unchanged (existing behavior). +- **CSV with mixed rows**: Some rows have `SendTime`, others don't. Each row is processed independently — those with `SendTime` get exact dispatch, those without get rate-based delay. +- **Cloud Task duplicate delivery**: `ScheduleExact` and `Schedule` use a dedupe check (unique active notification per `message_id`) to prevent duplicate notification creation on at-least-once delivery. +- **Retries for exact-send messages**: When an exact-send message expires and triggers a retry, the retry does NOT preserve exact-send semantics — it falls through to standard scheduling. The explicit time was a one-shot intent. + +### Terminology Note + +"Send at exactly that time" means the system will not apply additional rate-limit or schedule-window adjustments. It does NOT guarantee precise handset delivery timing (which depends on Cloud Tasks delivery, FCM push, and device state). + +### What Does NOT Change + +- The `MessageSendSchedule` entity and its `ResolveScheduledAt()` logic +- The `SendScheduleService` CRUD operations +- The phone notification entity schema (no new DB columns) +- The Android app behavior +- The web frontend (models auto-generated from Swagger) From 0d9741de888d8d3c89a9b7264704bd68e69e2240 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:30:34 +0300 Subject: [PATCH 20/35] docs: add scheduling send refactor implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-05-03-scheduling-send-refactor.md | 951 ++++++++++++++++++ 1 file changed, 951 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-03-scheduling-send-refactor.md diff --git a/docs/superpowers/plans/2026-05-03-scheduling-send-refactor.md b/docs/superpowers/plans/2026-05-03-scheduling-send-refactor.md new file mode 100644 index 00000000..a85b46ed --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-scheduling-send-refactor.md @@ -0,0 +1,951 @@ +# Scheduling Send Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to send SMS at an exact time (bypassing scheduling) when `SendAt` is specified, and replace the 1-second bulk hack with rate-based dispatch delays. + +**Architecture:** Add a transient `ExactSendTime` flag flowing through the event system. When true, bypass rate-limit and schedule window logic in notification scheduling. For bulk sends without explicit time, compute dispatch delay from `MessagesPerMinute` per-phone instead of hardcoded 1s. + +**Tech Stack:** Go, Fiber, GORM, CockroachDB, Google Cloud Tasks (CloudEvents) + +**Spec:** `docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md` + +**Build/Test commands:** + +```bash +cd api && go build ./... +cd api && go test -vet=off ./... +``` + +--- + +## Task 1: Add ExactSendTime to Event Payload + +**Files:** + +- Modify: `api/pkg/events/message_api_sent_event.go` + +- [ ] **Step 1: Add `ExactSendTime` field to `MessageAPISentPayload`** + +In `api/pkg/events/message_api_sent_event.go`, add to the struct: + +```go +ExactSendTime bool `json:"exact_send_time"` +``` + +Add it after line 22 (`ScheduledSendTime *time.Time`). + +- [ ] **Step 2: Build to verify no compile errors** + +Run: `cd api && go build ./...` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +cd api && git add -A && git commit -m "feat(events): add ExactSendTime field to MessageAPISentPayload" +``` + +--- + +## Task 2: Add Index and ExactSendTime to MessageSendParams + Update getSendDelay + +**Files:** + +- Modify: `api/pkg/services/message_service.go` + +- [ ] **Step 1: Add `Index` field to `MessageSendParams`** + +In `api/pkg/services/message_service.go` at line ~453, add `Index int` to the struct: + +```go +type MessageSendParams struct { + Owner *phonenumbers.PhoneNumber + Contact string + Encrypted bool + Content string + Attachments []string + Source string + SendAt *time.Time + RequestID *string + UserID entities.UserID + RequestReceivedAt time.Time + Index int +} +``` + +- [ ] **Step 2: Update `phoneSettings` to also return `MessagesPerMinute`** + +Change the `phoneSettings` method signature and body at line ~1014: + +```go +func (service *MessageService) phoneSettings(ctx context.Context, userID entities.UserID, owner string) (uint, entities.SIM, uint) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + ctxLogger := service.tracer.CtxLogger(service.logger, span) + + phone, err := service.phoneService.Load(ctx, userID, owner) + if err != nil { + msg := fmt.Sprintf("cannot load phone for userID [%s] and owner [%s]. using default max send attempt of 2", userID, owner) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return 2, entities.SIM1, 0 + } + + return phone.MaxSendAttemptsSanitized(), phone.SIM, phone.MessagesPerMinute +} +``` + +- [ ] **Step 3: Update `SendMessage` to use new `phoneSettings` return value and set `ExactSendTime`** + +Update `SendMessage` at line ~467. Key changes: get `messagesPerMinute` from `phoneSettings`, derive `ExactSendTime` from `SendAt != nil`, pass `messagesPerMinute` to `getSendDelay`: + +```go +func (service *MessageService) SendMessage(ctx context.Context, params MessageSendParams) (*entities.Message, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + ctxLogger := service.tracer.CtxLogger(service.logger, span) + + sendAttempts, sim, messagesPerMinute := service.phoneSettings(ctx, params.UserID, phonenumbers.Format(params.Owner, phonenumbers.E164)) + + eventPayload := events.MessageAPISentPayload{ + MessageID: uuid.New(), + UserID: params.UserID, + Encrypted: params.Encrypted, + MaxSendAttempts: sendAttempts, + RequestID: params.RequestID, + Owner: phonenumbers.Format(params.Owner, phonenumbers.E164), + Contact: params.Contact, + RequestReceivedAt: params.RequestReceivedAt, + Content: params.Content, + Attachments: params.Attachments, + ScheduledSendTime: params.SendAt, + ExactSendTime: params.SendAt != nil, + SIM: sim, + } + + event, err := service.createMessageAPISentEvent(params.Source, eventPayload) + if err != nil { + msg := fmt.Sprintf("cannot create %T from payload with message id [%s]", event, eventPayload.MessageID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + ctxLogger.Info(fmt.Sprintf("created event [%s] with id [%s] and message id [%s] and user [%s]", event.Type(), event.ID(), eventPayload.MessageID, eventPayload.UserID)) + + message, err := service.storeSentMessage(ctx, eventPayload) + if err != nil { + msg := fmt.Sprintf("cannot store message with id [%s]", eventPayload.MessageID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + timeout := service.getSendDelay(ctxLogger, eventPayload, params, messagesPerMinute) + if _, err = service.eventDispatcher.DispatchWithTimeout(ctx, event, timeout); err != nil { + msg := fmt.Sprintf("cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID()) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + ctxLogger.Info(fmt.Sprintf("[%s] event with ID [%s] dispatched succesfully for message [%s] with user [%s] and delay [%s]", event.Type(), event.ID(), eventPayload.MessageID, eventPayload.UserID, timeout)) + return message, err +} +``` + +- [ ] **Step 4: Rewrite `getSendDelay` to handle rate-based delay** + +Replace the existing `getSendDelay` method. New signature takes `messagesPerMinute` as a separate arg: + +```go +func (service *MessageService) getSendDelay(ctxLogger telemetry.Logger, eventPayload events.MessageAPISentPayload, params MessageSendParams, messagesPerMinute uint) time.Duration { + // Exact send time: delay until that time (clamped to 0 if in the past) + if params.SendAt != nil { + delay := params.SendAt.Sub(time.Now().UTC()) + if delay < 0 { + ctxLogger.Info(fmt.Sprintf("message [%s] has send time [%s] in the past. sending immediately", eventPayload.MessageID, params.SendAt.String())) + return time.Duration(0) + } + return delay + } + + // Rate-based delay for bulk messages (Index > 0) + if params.Index > 0 && messagesPerMinute > 0 { + interval := time.Minute / time.Duration(messagesPerMinute) + delay := time.Duration(params.Index) * interval + ctxLogger.Info(fmt.Sprintf("message [%s] bulk index [%d] rate-based delay [%s]", eventPayload.MessageID, params.Index, delay)) + return delay + } + + return time.Duration(0) +} +``` + +- [ ] **Step 5: Build to verify no compile errors** + +Run: `cd api && go build ./...` +Expected: success + +- [ ] **Step 6: Run tests** + +Run: `cd api && go test -vet=off ./...` +Expected: all pass + +- [ ] **Step 7: Commit** + +```bash +cd api && git add -A && git commit -m "feat(services): add rate-based dispatch delay and ExactSendTime to SendMessage" +``` + +--- + +## Task 3: Add ScheduleExact to Repository Interface and Implementation + +**Files:** + +- Modify: `api/pkg/repositories/phone_notification_repository.go` +- Modify: `api/pkg/repositories/gorm_phone_notification_repository.go` + +- [ ] **Step 1: Add `ScheduleExact` to the repository interface** + +In `api/pkg/repositories/phone_notification_repository.go`: + +```go +// PhoneNotificationRepository loads and persists an entities.PhoneNotification +type PhoneNotificationRepository interface { + // Schedule a new entities.PhoneNotification + Schedule(ctx context.Context, messagesPerMinute uint, schedule *entities.MessageSendSchedule, notification *entities.PhoneNotification) error + + // ScheduleExact stores a phone notification with a fixed ScheduledAt time, + // bypassing rate-limit and schedule window logic. + ScheduleExact(ctx context.Context, notification *entities.PhoneNotification) error + + // UpdateStatus of a notification + UpdateStatus(ctx context.Context, notificationID uuid.UUID, status entities.PhoneNotificationStatus) error + + // DeleteAllForUser deletes all entities.PhoneNotification for a user + DeleteAllForUser(ctx context.Context, userID entities.UserID) error +} +``` + +- [ ] **Step 2: Implement `ScheduleExact` on `gormPhoneNotificationRepository`** + +In `api/pkg/repositories/gorm_phone_notification_repository.go`, add after the `Schedule` method: + +```go +// ScheduleExact stores a phone notification with an exact ScheduledAt time. +// It performs a dedupe check — if a pending notification for the same message already exists, it's a no-op. +func (repository *gormPhoneNotificationRepository) ScheduleExact( + ctx context.Context, + notification *entities.PhoneNotification, +) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + // Dedupe: check if a pending notification for this message already exists + var count int64 + if err := repository.db.WithContext(ctx). + Model(&entities.PhoneNotification{}). + Where("message_id = ? AND status = ?", notification.MessageID, entities.PhoneNotificationStatusPending). + Count(&count).Error; err != nil { + return repository.tracer.WrapErrorSpan( + span, + stacktrace.Propagate(err, "cannot check for existing notification for message [%s]", notification.MessageID), + ) + } + + if count > 0 { + return nil + } + + if err := repository.db.WithContext(ctx).Create(notification).Error; err != nil { + return repository.tracer.WrapErrorSpan( + span, + stacktrace.Propagate(err, "cannot create exact-time notification with id [%s]", notification.ID), + ) + } + + return nil +} +``` + +- [ ] **Step 3: Build to verify no compile errors** + +Run: `cd api && go build ./...` +Expected: success + +- [ ] **Step 4: Commit** + +```bash +cd api && git add -A && git commit -m "feat(repositories): add ScheduleExact method for exact-time notifications" +``` + +--- + +## Task 4: Update PhoneNotificationService to Support ExactSendTime + +**Files:** + +- Modify: `api/pkg/services/phone_notification_service.go` + +- [ ] **Step 1: Add fields to `PhoneNotificationScheduleParams`** + +Update the struct at line ~162: + +```go +// PhoneNotificationScheduleParams are parameters for sending a notification +type PhoneNotificationScheduleParams struct { + UserID entities.UserID + Owner string + Source string + Encrypted bool + Contact string + Content string + SIM entities.SIM + MessageID uuid.UUID + ExactSendTime bool + ScheduledSendTime *time.Time +} +``` + +- [ ] **Step 2: Add bypass logic at the start of `Schedule` method** + +Update `Schedule` method at line ~175. Add the bypass path after loading the phone: + +```go +// Schedule a notification to be sent to a phone +func (service *PhoneNotificationService) Schedule(ctx context.Context, params *PhoneNotificationScheduleParams) error { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + ctxLogger := service.tracer.CtxLogger(service.logger, span) + + phone, err := service.phoneRepository.Load(ctx, params.UserID, params.Owner) + if err != nil { + msg := fmt.Sprintf("cannot load phone with userID [%s] and phone [%s]", params.UserID, params.Owner) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + notification := &entities.PhoneNotification{ + ID: uuid.New(), + MessageID: params.MessageID, + UserID: params.UserID, + PhoneID: phone.ID, + Status: entities.PhoneNotificationStatusPending, + ScheduledAt: time.Now().UTC(), + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + // Bypass rate-limit and schedule window logic for exact send time + if params.ExactSendTime && params.ScheduledSendTime != nil { + scheduledAt := *params.ScheduledSendTime + // Clamp past times to now (send immediately) + if scheduledAt.Before(time.Now().UTC()) { + scheduledAt = time.Now().UTC() + } + notification.ScheduledAt = scheduledAt + if err = service.phoneNotificationRepository.ScheduleExact(ctx, notification); err != nil { + msg := fmt.Sprintf("cannot schedule exact notification for message [%s] to phone [%s]", params.MessageID, phone.ID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err = service.dispatchMessageNotificationScheduled(ctx, params, notification); err != nil { + ctxLogger.Error(err) + } + + if err = service.dispatchMessageNotificationSend(ctx, params.Source, notification); err != nil { + return service.tracer.WrapErrorSpan(span, err) + } + + ctxLogger.Info(fmt.Sprintf( + "message with id [%s] exact notification scheduled for [%s] with id [%s]", + params.MessageID, + notification.ScheduledAt, + notification.ID, + )) + return nil + } + + // Standard path: apply rate-limit + schedule window logic + var schedule *entities.MessageSendSchedule + if phone.ScheduleID != nil { + schedule, err = service.sendScheduleRepository.Load(ctx, params.UserID, *phone.ScheduleID) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + schedule = nil + err = nil + } + if err != nil { + msg := fmt.Sprintf("cannot load send schedule [%s] for phone [%s]", *phone.ScheduleID, phone.ID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + } + + if err = service.phoneNotificationRepository.Schedule(ctx, phone.MessagesPerMinute, schedule, notification); err != nil { + msg := fmt.Sprintf("cannot schedule notification for message [%s] to phone [%s]", params.MessageID, phone.ID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err = service.dispatchMessageNotificationScheduled(ctx, params, notification); err != nil { + ctxLogger.Error(err) + } + + if err = service.dispatchMessageNotificationSend(ctx, params.Source, notification); err != nil { + return service.tracer.WrapErrorSpan(span, err) + } + + ctxLogger.Info(fmt.Sprintf( + "message with id [%s] notification scheduled for [%s] with id [%s]", + params.MessageID, + notification.ScheduledAt, + notification.ID, + )) + return nil +} +``` + +- [ ] **Step 3: Build to verify no compile errors** + +Run: `cd api && go build ./...` +Expected: success + +- [ ] **Step 4: Commit** + +```bash +cd api && git add -A && git commit -m "feat(services): add ExactSendTime bypass in PhoneNotificationService.Schedule" +``` + +--- + +## Task 5: Update Phone Notification Listener to Pass ExactSendTime + +**Files:** + +- Modify: `api/pkg/listeners/phone_notification_listener.go` + +- [ ] **Step 1: Pass ExactSendTime and ScheduledSendTime from event payload to service params** + +Update the `onMessageAPISent` method at line ~44: + +```go +func (listener *PhoneNotificationListener) onMessageAPISent(ctx context.Context, event cloudevents.Event) error { + ctx, span := listener.tracer.Start(ctx) + defer span.End() + + var payload events.MessageAPISentPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + sendParams := &services.PhoneNotificationScheduleParams{ + UserID: payload.UserID, + Owner: payload.Owner, + Contact: payload.Contact, + Content: payload.Content, + SIM: payload.SIM, + Encrypted: payload.Encrypted, + Source: event.Source(), + MessageID: payload.MessageID, + ExactSendTime: payload.ExactSendTime, + ScheduledSendTime: payload.ScheduledSendTime, + } + + if err := listener.service.Schedule(ctx, sendParams); err != nil { + msg := fmt.Sprintf("cannot send notification with params [%s] for event with ID [%s]", spew.Sdump(sendParams), event.ID()) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} +``` + +- [ ] **Step 2: Build to verify no compile errors** + +Run: `cd api && go build ./...` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +cd api && git add -A && git commit -m "feat(listeners): pass ExactSendTime to PhoneNotificationService from event" +``` + +--- + +## Task 6: Update Bulk Send Request + Handler + +**Files:** + +- Modify: `api/pkg/requests/message_bulk_send_request.go` +- Modify: `api/pkg/handlers/message_handler.go` + +- [ ] **Step 1: Remove per-index SendAt from `MessageBulkSend.ToMessageSendParams()`** + +In `api/pkg/requests/message_bulk_send_request.go`, update `ToMessageSendParams`: + +```go +// ToMessageSendParams converts MessageSend to services.MessageSendParams +func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source string) []services.MessageSendParams { + from, _ := phonenumbers.Parse(input.From, phonenumbers.UNKNOWN_REGION) + + var result []services.MessageSendParams + for index, to := range input.To { + result = append(result, services.MessageSendParams{ + Source: source, + Owner: from, + Encrypted: input.Encrypted, + RequestID: input.sanitizeStringPointer(input.RequestID), + UserID: userID, + RequestReceivedAt: time.Now().UTC(), + Contact: to, + Content: input.Content, + Attachments: input.Attachments, + Index: index, + }) + } + + return result +} +``` + +Key changes: removed `SendAt` assignment and added `Index: index`. + +- [ ] **Step 2: Remove the `index * 1s` hack from `BulkSend` handler** + +In `api/pkg/handlers/message_handler.go`, update the `BulkSend` handler goroutine (around line 160-175). Remove the `if message.SendAt == nil` block: + +Replace: + +```go +for index, message := range params { + wg.Add(1) + go func(message services.MessageSendParams, index int) { + count.Add(1) + if message.SendAt == nil { + sentAt := time.Now().UTC().Add(time.Duration(index) * time.Second) + message.SendAt = &sentAt + } + + response, err := h.service.SendMessage(ctx, message) +``` + +With: + +```go +for index, message := range params { + wg.Add(1) + go func(message services.MessageSendParams, index int) { + count.Add(1) + response, err := h.service.SendMessage(ctx, message) +``` + +- [ ] **Step 3: Remove unused `time` import if needed** + +Check if `time` is still used in `message_handler.go`. It likely is (used elsewhere), so skip this step if so. + +- [ ] **Step 4: Build to verify no compile errors** + +Run: `cd api && go build ./...` +Expected: success + +- [ ] **Step 5: Commit** + +```bash +cd api && git add -A && git commit -m "feat(handlers): replace 1s hack with rate-based delay for bulk send" +``` + +--- + +## Task 7: Update CSV Bulk Message Request + Handler + +**Files:** + +- Modify: `api/pkg/requests/bulk_message_request.go` +- Modify: `api/pkg/handlers/bulk_message_handler.go` + +- [ ] **Step 1: Add `Index` parameter to `BulkMessage.ToMessageSendParams()`** + +In `api/pkg/requests/bulk_message_request.go`, change the method signature to accept index: + +```go +// ToMessageSendParams converts BulkMessage to services.MessageSendParams +func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID uuid.UUID, source string, index int) services.MessageSendParams { + from, _ := phonenumbers.Parse(input.FromPhoneNumber, phonenumbers.UNKNOWN_REGION) + + return services.MessageSendParams{ + Source: source, + Owner: from, + RequestID: input.sanitizeStringPointer(fmt.Sprintf("bulk-%s", requestID.String())), + UserID: userID, + SendAt: input.SendTime, + RequestReceivedAt: time.Now().UTC(), + Contact: input.sanitizeAddress(input.ToPhoneNumber), + Content: input.Content, + Attachments: input.removeEmptyStrings(strings.Split(input.AttachmentURLs, ",")), + Index: index, + } +} +``` + +- [ ] **Step 2: Update `BulkMessageHandler.Store()` to compute per-phone index** + +In `api/pkg/handlers/bulk_message_handler.go`, update the Store method to compute per-phone indices: + +```go +func (h *BulkMessageHandler) Store(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + file, err := c.FormFile("document") + if err != nil { + msg := fmt.Sprintf("cannot fetch file with name [%s] from request", "document") + ctxLogger.Warn(stacktrace.Propagate(err, msg)) + return h.responseBadRequest(c, err) + } + + messages, validationErrors := h.validator.ValidateStore(ctx, h.userIDFomContext(c), file) + if len(validationErrors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while sending bulk sms from CSV file [%s] for [%s]", spew.Sdump(validationErrors), file.Filename, h.userIDFomContext(c)) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, validationErrors, "validation errors while sending bulk SMS") + } + + if msg := h.billingService.IsEntitledWithCount(ctx, h.userIDFomContext(c), uint(len(messages))); msg != nil { + ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("user with ID [%s] is not entitled to send [%d] messages", h.userIDFomContext(c), len(messages)))) + return h.responsePaymentRequired(c, *msg) + } + + requestID := uuid.New() + wg := sync.WaitGroup{} + count := atomic.Int64{} + + // Compute per-phone index for rate-based dispatch delay + phoneIndexMap := make(map[string]int) + for _, message := range messages { + if message.SendTime != nil { + continue // Exact-time messages don't need indexing + } + phone := message.FromPhoneNumber + phoneIndexMap[phone]++ // Pre-count not needed, we'll compute inline + } + + // Reset for actual iteration + phoneIndexCounter := make(map[string]int) + + for _, message := range messages { + wg.Add(1) + var perPhoneIndex int + if message.SendTime == nil { + perPhoneIndex = phoneIndexCounter[message.FromPhoneNumber] + phoneIndexCounter[message.FromPhoneNumber]++ + } + + go func(message *requests.BulkMessage, index int) { + count.Add(1) + _, err = h.messageService.SendMessage( + ctx, + message.ToMessageSendParams(h.userIDFomContext(c), requestID, c.OriginalURL(), index), + ) + if err != nil { + count.Add(-1) + msg := fmt.Sprintf("cannot send message with paylod [%s] at index [%d]", spew.Sdump(message), index) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + } + wg.Done() + }(message, perPhoneIndex) + } + + wg.Wait() + return h.responseAccepted(c, fmt.Sprintf("Added %d out of %d messages to the queue", count.Load(), len(messages))) +} +``` + +- [ ] **Step 3: Clean up unused `phoneIndexMap` variable** + +The `phoneIndexMap` is computed but unused. Remove it — we only need `phoneIndexCounter`: + +```go +// Compute per-phone index for rate-based dispatch delay +phoneIndexCounter := make(map[string]int) + +for _, message := range messages { + wg.Add(1) + var perPhoneIndex int + if message.SendTime == nil { + perPhoneIndex = phoneIndexCounter[message.FromPhoneNumber] + phoneIndexCounter[message.FromPhoneNumber]++ + } + + go func(message *requests.BulkMessage, index int) { + // ... same as above + }(message, perPhoneIndex) +} +``` + +- [ ] **Step 4: Build to verify no compile errors** + +Run: `cd api && go build ./...` +Expected: success + +- [ ] **Step 5: Run tests** + +Run: `cd api && go test -vet=off ./...` +Expected: all pass + +- [ ] **Step 6: Commit** + +```bash +cd api && git add -A && git commit -m "feat(handlers): add per-phone index for CSV bulk messages" +``` + +--- + +## Task 8: Add Unit Tests for getSendDelay + +**Files:** + +- Create: `api/pkg/services/message_service_test.go` + +- [ ] **Step 1: Write tests for the new `getSendDelay` logic** + +Create `api/pkg/services/message_service_test.go`: + +```go +package services + +import ( + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/trace" +) + +func TestGetSendDelay_WithSendAt_ReturnsTimeUntil(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + sendAt := time.Now().UTC().Add(5 * time.Minute) + params := MessageSendParams{SendAt: &sendAt} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 10) + + // Should be approximately 5 minutes (within 2 seconds tolerance) + assert.InDelta(t, float64(5*time.Minute), float64(delay), float64(2*time.Second)) +} + +func TestGetSendDelay_WithSendAtInPast_ReturnsZero(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + sendAt := time.Now().UTC().Add(-5 * time.Minute) + params := MessageSendParams{SendAt: &sendAt} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 10) + + assert.Equal(t, time.Duration(0), delay) +} + +func TestGetSendDelay_BulkIndex_RateBasedDelay(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + params := MessageSendParams{Index: 3} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + // 10 messages per minute = 6 seconds interval + delay := service.getSendDelay(logger, payload, params, 10) + + expected := time.Duration(3) * (time.Minute / time.Duration(10)) + assert.Equal(t, expected, delay) +} + +func TestGetSendDelay_BulkIndex_ZeroRate_ReturnsZero(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + params := MessageSendParams{Index: 5} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 0) + + assert.Equal(t, time.Duration(0), delay) +} + +func TestGetSendDelay_IndexZero_ReturnsZero(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + params := MessageSendParams{Index: 0} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 10) + + assert.Equal(t, time.Duration(0), delay) +} + +func TestGetSendDelay_NoSendAtNoIndex_ReturnsZero(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + params := MessageSendParams{} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 10) + + assert.Equal(t, time.Duration(0), delay) +} + +// noopLogger implements telemetry.Logger for testing +type noopLogger struct{} + +var _ telemetry.Logger = (*noopLogger)(nil) + +func (l *noopLogger) Error(_ error) {} +func (l *noopLogger) WithService(_ string) telemetry.Logger { return l } +func (l *noopLogger) WithString(_, _ string) telemetry.Logger { return l } +func (l *noopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { return l } +func (l *noopLogger) Trace(_ string) {} +func (l *noopLogger) Info(_ string) {} +func (l *noopLogger) Warn(_ error) {} +func (l *noopLogger) Debug(_ string) {} +func (l *noopLogger) Fatal(_ error) {} +func (l *noopLogger) Printf(_ string, _ ...interface{}) {} +``` + +- [ ] **Step 2: Run the tests** + +Run: `cd api && go test -vet=off ./pkg/services/ -run TestGetSendDelay -v` +Expected: all pass + +- [ ] **Step 3: Commit** + +```bash +cd api && git add -A && git commit -m "test(services): add unit tests for getSendDelay rate-based logic" +``` + +--- + +## Task 9: Add Unit Test for ResolveScheduledAt (Existing, Verify No Regression) + +**Files:** + +- Create: `api/pkg/entities/send_schedule_test.go` + +- [ ] **Step 1: Write tests to lock existing ResolveScheduledAt behavior** + +Create `api/pkg/entities/send_schedule_test.go`: + +```go +package entities + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestResolveScheduledAt_NilSchedule_ReturnsCurrentUTC(t *testing.T) { + now := time.Now() + var schedule *MessageSendSchedule + result := schedule.ResolveScheduledAt(now) + assert.Equal(t, now.UTC(), result) +} + +func TestResolveScheduledAt_InactiveSchedule_ReturnsCurrentUTC(t *testing.T) { + now := time.Now() + schedule := &MessageSendSchedule{IsActive: false} + result := schedule.ResolveScheduledAt(now) + assert.Equal(t, now.UTC(), result) +} + +func TestResolveScheduledAt_NoWindows_ReturnsCurrentUTC(t *testing.T) { + now := time.Now() + schedule := &MessageSendSchedule{ + IsActive: true, + Timezone: "UTC", + Windows: []MessageSendScheduleWindow{}, + } + result := schedule.ResolveScheduledAt(now) + assert.Equal(t, now.UTC(), result) +} + +func TestResolveScheduledAt_WithinWindow_ReturnsCurrentUTC(t *testing.T) { + // Wednesday at 10:00 UTC, window is Wed 9:00-17:00 (540-1020 minutes) + now := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) // Wednesday + schedule := &MessageSendSchedule{ + IsActive: true, + Timezone: "UTC", + Windows: []MessageSendScheduleWindow{ + {DayOfWeek: int(now.Weekday()), StartMinute: 540, EndMinute: 1020}, + }, + } + result := schedule.ResolveScheduledAt(now) + assert.Equal(t, now.UTC(), result) +} + +func TestResolveScheduledAt_BeforeWindow_ReturnsWindowStart(t *testing.T) { + // Wednesday at 7:00 UTC, window is Wed 9:00-17:00 + now := time.Date(2025, 1, 1, 7, 0, 0, 0, time.UTC) // Wednesday + schedule := &MessageSendSchedule{ + IsActive: true, + Timezone: "UTC", + Windows: []MessageSendScheduleWindow{ + {DayOfWeek: int(now.Weekday()), StartMinute: 540, EndMinute: 1020}, + }, + } + result := schedule.ResolveScheduledAt(now) + expected := time.Date(2025, 1, 1, 9, 0, 0, 0, time.UTC) + assert.Equal(t, expected, result) +} +``` + +- [ ] **Step 2: Run the tests** + +Run: `cd api && go test -vet=off ./pkg/entities/ -run TestResolveScheduledAt -v` +Expected: all pass + +- [ ] **Step 3: Commit** + +```bash +cd api && git add -A && git commit -m "test(entities): add regression tests for ResolveScheduledAt" +``` + +--- + +## Task 10: Final Build + Integration Verification + +**Files:** None (verification only) + +- [ ] **Step 1: Full build** + +Run: `cd api && go build ./...` +Expected: success + +- [ ] **Step 2: Full test suite** + +Run: `cd api && go test -vet=off ./...` +Expected: all pass + +- [ ] **Step 3: Generate Swagger docs (if API annotations changed)** + +The API request structs' annotations haven't changed for swagger (no new endpoints, `SendAt` already documented). Skip swagger regen unless compile errors appear. + +- [ ] **Step 4: Verify git status is clean** + +Run: `cd api && git status` +Expected: clean working tree + +--- + +## Notes + +- The `noopLogger` in tests implements the full `telemetry.Logger` interface (Error, WithService, WithString, WithSpan, Trace, Info, Warn, Debug, Fatal, Printf). +- The `ExactSendTime` field is transient — no database migrations needed. +- **Dedupe strategy**: `ScheduleExact` uses a `SELECT COUNT` check before insert. This is not fully race-proof but acceptable given: (a) Cloud Tasks at-least-once duplicates are rare, and (b) the existing `Schedule` path also has this same theoretical gap. Adding a DB unique constraint on `(message_id, status='pending')` would require a partial index migration — this is deferred as a future improvement if duplicates become a problem in practice. +- The existing `Schedule` method already handles concurrency via CockroachDB's serializable transactions (`crdbgorm.ExecuteTx`), which retries automatically on conflicts. No additional dedupe is added there. +- All existing behavior for single messages without `SendAt` is preserved (delay = 0, standard scheduling path). +- Past `SendAt` times are handled at both layers: `getSendDelay` returns 0 (immediate dispatch), and `Schedule` clamps `ScheduledAt` to `now` (no past timestamps persisted). From 425098818220370dbde28e5861d6cb87f2667c0e Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:31:44 +0300 Subject: [PATCH 21/35] feat(events): add ExactSendTime field to MessageAPISentPayload Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/events/message_api_sent_event.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/pkg/events/message_api_sent_event.go b/api/pkg/events/message_api_sent_event.go index 60a418b2..e86c911e 100644 --- a/api/pkg/events/message_api_sent_event.go +++ b/api/pkg/events/message_api_sent_event.go @@ -20,6 +20,7 @@ type MessageAPISentPayload struct { MaxSendAttempts uint `json:"max_send_attempts"` Contact string `json:"contact"` ScheduledSendTime *time.Time `json:"scheduled_send_time"` + ExactSendTime bool `json:"exact_send_time"` RequestReceivedAt time.Time `json:"request_received_at"` Content string `json:"content"` Attachments []string `json:"attachments"` From 5351ef97901150e63e2ab2fcf02e9ef4b36c7c83 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:33:32 +0300 Subject: [PATCH 22/35] feat(services): add rate-based dispatch delay and ExactSendTime to SendMessage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/services/message_service.go | 34 ++++++++++++++++++----------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 131c3520..929998bd 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -461,6 +461,7 @@ type MessageSendParams struct { RequestID *string UserID entities.UserID RequestReceivedAt time.Time + Index int } // SendMessage a new message @@ -470,7 +471,7 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe ctxLogger := service.tracer.CtxLogger(service.logger, span) - sendAttempts, sim := service.phoneSettings(ctx, params.UserID, phonenumbers.Format(params.Owner, phonenumbers.E164)) + sendAttempts, sim, messagesPerMinute := service.phoneSettings(ctx, params.UserID, phonenumbers.Format(params.Owner, phonenumbers.E164)) eventPayload := events.MessageAPISentPayload{ MessageID: uuid.New(), @@ -484,6 +485,7 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe Content: params.Content, Attachments: params.Attachments, ScheduledSendTime: params.SendAt, + ExactSendTime: params.SendAt != nil, SIM: sim, } @@ -500,7 +502,7 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - timeout := service.getSendDelay(ctxLogger, eventPayload, params.SendAt) + timeout := service.getSendDelay(ctxLogger, eventPayload, params, messagesPerMinute) if _, err = service.eventDispatcher.DispatchWithTimeout(ctx, event, timeout); err != nil { msg := fmt.Sprintf("cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID()) return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) @@ -559,18 +561,24 @@ func (service *MessageService) RegisterMissedCall(ctx context.Context, params *M return message, err } -func (service *MessageService) getSendDelay(ctxLogger telemetry.Logger, eventPayload events.MessageAPISentPayload, sendAt *time.Time) time.Duration { - if sendAt == nil { - return time.Duration(0) +func (service *MessageService) getSendDelay(ctxLogger telemetry.Logger, eventPayload events.MessageAPISentPayload, params MessageSendParams, messagesPerMinute uint) time.Duration { + if params.SendAt != nil { + delay := params.SendAt.Sub(time.Now().UTC()) + if delay < 0 { + ctxLogger.Info(fmt.Sprintf("message [%s] has send time [%s] in the past. sending immediately", eventPayload.MessageID, params.SendAt.String())) + return time.Duration(0) + } + return delay } - delay := sendAt.Sub(time.Now().UTC()) - if delay < 0 { - ctxLogger.Info(fmt.Sprintf("message [%s] has send time [%s] in the past. sending immediately", eventPayload.MessageID, sendAt.String())) - return time.Duration(0) + if params.Index > 0 && messagesPerMinute > 0 { + interval := time.Minute / time.Duration(messagesPerMinute) + delay := time.Duration(params.Index) * interval + ctxLogger.Info(fmt.Sprintf("message [%s] bulk index [%d] rate-based delay [%s]", eventPayload.MessageID, params.Index, delay)) + return delay } - return delay + return time.Duration(0) } // StoreReceivedMessage a new message @@ -1011,7 +1019,7 @@ func (service *MessageService) SearchMessages(ctx context.Context, params *Messa return messages, nil } -func (service *MessageService) phoneSettings(ctx context.Context, userID entities.UserID, owner string) (uint, entities.SIM) { +func (service *MessageService) phoneSettings(ctx context.Context, userID entities.UserID, owner string) (uint, entities.SIM, uint) { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -1021,10 +1029,10 @@ func (service *MessageService) phoneSettings(ctx context.Context, userID entitie if err != nil { msg := fmt.Sprintf("cannot load phone for userID [%s] and owner [%s]. using default max send attempt of 2", userID, owner) ctxLogger.Error(stacktrace.Propagate(err, msg)) - return 2, entities.SIM1 + return 2, entities.SIM1, 0 } - return phone.MaxSendAttemptsSanitized(), phone.SIM + return phone.MaxSendAttemptsSanitized(), phone.SIM, phone.MessagesPerMinute } // storeSentMessage a new message From 42c6fc4819b519bcb56e34957e90d9f9ced35c91 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:35:26 +0300 Subject: [PATCH 23/35] test(entities): add regression tests for ResolveScheduledAt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/entities/send_schedule_test.go | 62 +++++++++++++++++++ .../gorm_phone_notification_repository.go | 35 +++++++++++ .../phone_notification_repository.go | 4 ++ 3 files changed, 101 insertions(+) create mode 100644 api/pkg/entities/send_schedule_test.go diff --git a/api/pkg/entities/send_schedule_test.go b/api/pkg/entities/send_schedule_test.go new file mode 100644 index 00000000..1480aa2f --- /dev/null +++ b/api/pkg/entities/send_schedule_test.go @@ -0,0 +1,62 @@ +package entities + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestResolveScheduledAt_NilSchedule_ReturnsCurrentUTC(t *testing.T) { + now := time.Now() + var schedule *MessageSendSchedule + result := schedule.ResolveScheduledAt(now) + assert.Equal(t, now.UTC(), result) +} + +func TestResolveScheduledAt_InactiveSchedule_ReturnsCurrentUTC(t *testing.T) { + now := time.Now() + schedule := &MessageSendSchedule{IsActive: false} + result := schedule.ResolveScheduledAt(now) + assert.Equal(t, now.UTC(), result) +} + +func TestResolveScheduledAt_NoWindows_ReturnsCurrentUTC(t *testing.T) { + now := time.Now() + schedule := &MessageSendSchedule{ + IsActive: true, + Timezone: "UTC", + Windows: []MessageSendScheduleWindow{}, + } + result := schedule.ResolveScheduledAt(now) + assert.Equal(t, now.UTC(), result) +} + +func TestResolveScheduledAt_WithinWindow_ReturnsCurrentUTC(t *testing.T) { + // Wednesday at 10:00 UTC, window is Wed 9:00-17:00 (540-1020 minutes) + now := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) // Wednesday + schedule := &MessageSendSchedule{ + IsActive: true, + Timezone: "UTC", + Windows: []MessageSendScheduleWindow{ + {DayOfWeek: int(now.Weekday()), StartMinute: 540, EndMinute: 1020}, + }, + } + result := schedule.ResolveScheduledAt(now) + assert.Equal(t, now.UTC(), result) +} + +func TestResolveScheduledAt_BeforeWindow_ReturnsWindowStart(t *testing.T) { + // Wednesday at 7:00 UTC, window is Wed 9:00-17:00 + now := time.Date(2025, 1, 1, 7, 0, 0, 0, time.UTC) // Wednesday + schedule := &MessageSendSchedule{ + IsActive: true, + Timezone: "UTC", + Windows: []MessageSendScheduleWindow{ + {DayOfWeek: int(now.Weekday()), StartMinute: 540, EndMinute: 1020}, + }, + } + result := schedule.ResolveScheduledAt(now) + expected := time.Date(2025, 1, 1, 9, 0, 0, 0, time.UTC) + assert.Equal(t, expected, result) +} diff --git a/api/pkg/repositories/gorm_phone_notification_repository.go b/api/pkg/repositories/gorm_phone_notification_repository.go index 8cc16f1e..a36bac07 100644 --- a/api/pkg/repositories/gorm_phone_notification_repository.go +++ b/api/pkg/repositories/gorm_phone_notification_repository.go @@ -203,3 +203,38 @@ func (repository *gormPhoneNotificationRepository) insert( return nil } + +// ScheduleExact stores a phone notification with an exact ScheduledAt time. +// It performs a dedupe check — if a pending notification for the same message already exists, it's a no-op. +func (repository *gormPhoneNotificationRepository) ScheduleExact( + ctx context.Context, + notification *entities.PhoneNotification, +) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + // Dedupe: check if a pending notification for this message already exists + var count int64 + if err := repository.db.WithContext(ctx). + Model(&entities.PhoneNotification{}). + Where("message_id = ? AND status = ?", notification.MessageID, entities.PhoneNotificationStatusPending). + Count(&count).Error; err != nil { + return repository.tracer.WrapErrorSpan( + span, + stacktrace.Propagate(err, "cannot check for existing notification for message [%s]", notification.MessageID), + ) + } + + if count > 0 { + return nil + } + + if err := repository.db.WithContext(ctx).Create(notification).Error; err != nil { + return repository.tracer.WrapErrorSpan( + span, + stacktrace.Propagate(err, "cannot create exact-time notification with id [%s]", notification.ID), + ) + } + + return nil +} diff --git a/api/pkg/repositories/phone_notification_repository.go b/api/pkg/repositories/phone_notification_repository.go index 9d93f0b3..e8bedfe4 100644 --- a/api/pkg/repositories/phone_notification_repository.go +++ b/api/pkg/repositories/phone_notification_repository.go @@ -13,6 +13,10 @@ type PhoneNotificationRepository interface { // Schedule a new entities.PhoneNotification Schedule(ctx context.Context, messagesPerMinute uint, schedule *entities.MessageSendSchedule, notification *entities.PhoneNotification) error + // ScheduleExact stores a phone notification with a fixed ScheduledAt time, + // bypassing rate-limit and schedule window logic. + ScheduleExact(ctx context.Context, notification *entities.PhoneNotification) error + // UpdateStatus of a notification UpdateStatus(ctx context.Context, notificationID uuid.UUID, status entities.PhoneNotificationStatus) error From 8fb277ffe37a6c2e462a39408cbde6f1429ac8a5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:40:02 +0300 Subject: [PATCH 24/35] feat(handlers): replace 1s hack with rate-based delay for bulk send Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/handlers/message_handler.go | 5 ----- api/pkg/requests/message_bulk_send_request.go | 3 +-- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index 935e9ba4..9504d518 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -161,11 +161,6 @@ func (h *MessageHandler) BulkSend(c *fiber.Ctx) error { wg.Add(1) go func(message services.MessageSendParams, index int) { count.Add(1) - if message.SendAt == nil { - sentAt := time.Now().UTC().Add(time.Duration(index) * time.Second) - message.SendAt = &sentAt - } - response, err := h.service.SendMessage(ctx, message) if err != nil { count.Add(-1) diff --git a/api/pkg/requests/message_bulk_send_request.go b/api/pkg/requests/message_bulk_send_request.go index 461ac2ed..8c7d8025 100644 --- a/api/pkg/requests/message_bulk_send_request.go +++ b/api/pkg/requests/message_bulk_send_request.go @@ -54,7 +54,6 @@ func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source var result []services.MessageSendParams for index, to := range input.To { - sendAt := time.Now().UTC().Add(time.Duration(index) * time.Second) result = append(result, services.MessageSendParams{ Source: source, Owner: from, @@ -63,9 +62,9 @@ func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source UserID: userID, RequestReceivedAt: time.Now().UTC(), Contact: to, - SendAt: &sendAt, Content: input.Content, Attachments: input.Attachments, + Index: index, }) } From 2a8b83ac29141500b7ae6c3a5119f3ffff0c22b2 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:40:36 +0300 Subject: [PATCH 25/35] test(services): add unit tests for getSendDelay rate-based logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/services/message_service_test.go | 105 +++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 api/pkg/services/message_service_test.go diff --git a/api/pkg/services/message_service_test.go b/api/pkg/services/message_service_test.go new file mode 100644 index 00000000..263816f4 --- /dev/null +++ b/api/pkg/services/message_service_test.go @@ -0,0 +1,105 @@ +package services + +import ( + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/trace" +) + +func TestGetSendDelay_WithSendAt_ReturnsTimeUntil(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + sendAt := time.Now().UTC().Add(5 * time.Minute) + params := MessageSendParams{SendAt: &sendAt} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 10) + + // Should be approximately 5 minutes (within 2 seconds tolerance) + assert.InDelta(t, float64(5*time.Minute), float64(delay), float64(2*time.Second)) +} + +func TestGetSendDelay_WithSendAtInPast_ReturnsZero(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + sendAt := time.Now().UTC().Add(-5 * time.Minute) + params := MessageSendParams{SendAt: &sendAt} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 10) + + assert.Equal(t, time.Duration(0), delay) +} + +func TestGetSendDelay_BulkIndex_RateBasedDelay(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + params := MessageSendParams{Index: 3} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + // 10 messages per minute = 6 seconds interval + delay := service.getSendDelay(logger, payload, params, 10) + + expected := time.Duration(3) * (time.Minute / time.Duration(10)) + assert.Equal(t, expected, delay) +} + +func TestGetSendDelay_BulkIndex_ZeroRate_ReturnsZero(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + params := MessageSendParams{Index: 5} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 0) + + assert.Equal(t, time.Duration(0), delay) +} + +func TestGetSendDelay_IndexZero_ReturnsZero(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + params := MessageSendParams{Index: 0} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 10) + + assert.Equal(t, time.Duration(0), delay) +} + +func TestGetSendDelay_NoSendAtNoIndex_ReturnsZero(t *testing.T) { + service := &MessageService{} + logger := &noopLogger{} + + params := MessageSendParams{} + payload := events.MessageAPISentPayload{MessageID: uuid.New()} + + delay := service.getSendDelay(logger, payload, params, 10) + + assert.Equal(t, time.Duration(0), delay) +} + +// noopLogger implements telemetry.Logger for testing +type noopLogger struct{} + +var _ telemetry.Logger = (*noopLogger)(nil) + +func (l *noopLogger) Error(_ error) {} +func (l *noopLogger) WithService(_ string) telemetry.Logger { return l } +func (l *noopLogger) WithString(_, _ string) telemetry.Logger { return l } +func (l *noopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { return l } +func (l *noopLogger) Trace(_ string) {} +func (l *noopLogger) Info(_ string) {} +func (l *noopLogger) Warn(_ error) {} +func (l *noopLogger) Debug(_ string) {} +func (l *noopLogger) Fatal(_ error) {} +func (l *noopLogger) Printf(_ string, _ ...interface{}) {} From 9af04e339f98e026f6ec53525a6092ddfb481d0c Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:40:54 +0300 Subject: [PATCH 26/35] feat(services): add ExactSendTime bypass in PhoneNotificationService.Schedule Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../services/phone_notification_service.go | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index ffafa555..24db0de5 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -161,14 +161,16 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone // PhoneNotificationScheduleParams are parameters for sending a notification type PhoneNotificationScheduleParams struct { - UserID entities.UserID - Owner string - Source string - Encrypted bool - Contact string - Content string - SIM entities.SIM - MessageID uuid.UUID + UserID entities.UserID + Owner string + Source string + Encrypted bool + Contact string + Content string + SIM entities.SIM + MessageID uuid.UUID + ExactSendTime bool + ScheduledSendTime *time.Time } // Schedule a notification to be sent to a phone @@ -195,6 +197,35 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P UpdatedAt: time.Now().UTC(), } + // Bypass rate-limit and schedule window logic for exact send time + if params.ExactSendTime && params.ScheduledSendTime != nil { + scheduledAt := *params.ScheduledSendTime + if scheduledAt.Before(time.Now().UTC()) { + scheduledAt = time.Now().UTC() + } + notification.ScheduledAt = scheduledAt + if err = service.phoneNotificationRepository.ScheduleExact(ctx, notification); err != nil { + msg := fmt.Sprintf("cannot schedule exact notification for message [%s] to phone [%s]", params.MessageID, phone.ID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err = service.dispatchMessageNotificationScheduled(ctx, params, notification); err != nil { + ctxLogger.Error(err) + } + + if err = service.dispatchMessageNotificationSend(ctx, params.Source, notification); err != nil { + return service.tracer.WrapErrorSpan(span, err) + } + + ctxLogger.Info(fmt.Sprintf( + "message with id [%s] exact notification scheduled for [%s] with id [%s]", + params.MessageID, + notification.ScheduledAt, + notification.ID, + )) + return nil + } + var schedule *entities.MessageSendSchedule if phone.ScheduleID != nil { schedule, err = service.sendScheduleRepository.Load(ctx, params.UserID, *phone.ScheduleID) From adbcd2e36b583a597bbc2508960d0f3497b48037 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:40:54 +0300 Subject: [PATCH 27/35] feat(listeners): pass ExactSendTime to PhoneNotificationService from event Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../listeners/phone_notification_listener.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/api/pkg/listeners/phone_notification_listener.go b/api/pkg/listeners/phone_notification_listener.go index e1b3eef7..bcb15612 100644 --- a/api/pkg/listeners/phone_notification_listener.go +++ b/api/pkg/listeners/phone_notification_listener.go @@ -53,14 +53,16 @@ func (listener *PhoneNotificationListener) onMessageAPISent(ctx context.Context, } sendParams := &services.PhoneNotificationScheduleParams{ - UserID: payload.UserID, - Owner: payload.Owner, - Contact: payload.Contact, - Content: payload.Content, - SIM: payload.SIM, - Encrypted: payload.Encrypted, - Source: event.Source(), - MessageID: payload.MessageID, + UserID: payload.UserID, + Owner: payload.Owner, + Contact: payload.Contact, + Content: payload.Content, + SIM: payload.SIM, + Encrypted: payload.Encrypted, + Source: event.Source(), + MessageID: payload.MessageID, + ExactSendTime: payload.ExactSendTime, + ScheduledSendTime: payload.ScheduledSendTime, } if err := listener.service.Schedule(ctx, sendParams); err != nil { From af0e5d9a6ebd3d1a18b2353d29fb109e3cd055ab Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:41:23 +0300 Subject: [PATCH 28/35] docs: add integration test setup design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...026-05-03-integration-test-setup-design.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-integration-test-setup-design.md diff --git a/docs/superpowers/specs/2026-05-03-integration-test-setup-design.md b/docs/superpowers/specs/2026-05-03-integration-test-setup-design.md new file mode 100644 index 00000000..1b4ced8e --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-integration-test-setup-design.md @@ -0,0 +1,248 @@ +# Integration Test Setup for httpSMS API + +## Problem + +The httpSMS API has no integration tests that verify the full SMS send/receive flow end-to-end. We need a CI-gated integration test that runs the entire stack in Docker and validates the core message lifecycle before deploying the API. + +## Approach + +Run the full application stack (API + PostgreSQL + Redis) in Docker alongside an **emulator** service that acts as a fake Android phone. The emulator implements a fake FCM server endpoint so the API's Firebase messaging client sends push notifications to it (instead of Google). The emulator then responds with SENT/DELIVERED events, completing the SMS lifecycle. A Go test runner exercises the API externally and asserts on final message state. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Docker Compose (tests/docker-compose.yml) │ +│ │ +│ ┌──────────┐ ┌───────┐ ┌──────────────────────────┐ │ +│ │PostgreSQL│ │ Redis │ │ API (existing Dockerfile)│ │ +│ └──────────┘ └───────┘ └────────────┬─────────────┘ │ +│ │ FCM push │ +│ ▼ │ +│ ┌──────────────────────────┐ │ +│ │ Emulator (fake phone) │ │ +│ │ - Fake FCM server :9090 │ │ +│ │ - Fires SENT/DELIVERED │ │ +│ │ events back to API │ │ +│ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + ▲ + │ HTTP calls (send SMS, get message, etc.) + │ +┌────────┴──────────┐ +│ Test Runner (Go) │ ← runs on host / in CI +│ go test ./... │ +└───────────────────┘ +``` + +## Components + +### 1. `tests/docker-compose.yml` + +Brings up the full stack: + +- **postgres** — Same as root `docker-compose.yml`, seeded with `tests/seed.sql` +- **redis** — Standard Redis +- **api** — Built from `api/Dockerfile`, configured with `FCM_ENDPOINT=http://emulator:9090` to redirect Firebase messaging to the emulator +- **emulator** — Built from `tests/emulator/Dockerfile`, receives FCM pushes and fires events back + +### 2. `tests/emulator/` (Go project) + +A lightweight Go HTTP server that: + +- Exposes `POST /v1/projects/{project}/messages:send` — mimics the FCM v1 API. Receives push notification payloads from the API's Firebase messaging client. +- Exposes `POST /token` — returns a fake OAuth2 access token (the Firebase SDK calls this before sending FCM). Response format: `{"access_token": "fake-token", "token_type": "Bearer", "expires_in": 3600}` +- Exposes `GET /health` — health check endpoint +- On receiving a push with `KEY_MESSAGE_ID` in the data payload: + 1. Calls `GET http://api:8000/v1/messages/outstanding?message_id={messageID}` (using phone API key) to fetch the message like a real phone would + 2. Waits a brief delay (e.g., 200ms) + 3. Calls `POST http://api:8000/v1/messages/{messageID}/events` with event `SENT` (using phone API key) + 4. Waits another brief delay (e.g., 200ms) + 5. Calls `POST http://api:8000/v1/messages/{messageID}/events` with event `DELIVERED` (using phone API key) +- All API calls authenticated with the seeded phone API key (`x-api-key` header) +- Asserts it received the correct FCM payload structure (path, data.KEY_MESSAGE_ID present) + +### 3. `tests/seed.sql` + +SQL script that runs on PostgreSQL startup to create: + +- A test user: `id='test-user-id'`, `email='test@httpsms.com'`, `api_key='test-user-api-key'`, `subscription_name='pro'` +- A system user (for event queue): `id='system-user-id'`, `api_key='system-user-api-key'` +- A phone: `id=`, `user_id='test-user-id'`, `phone_number='+18005550199'`, `fcm_token='fake-fcm-token'` +- A phone API key: `id=`, `user_id='test-user-id'`, `api_key='test-phone-api-key'`, `phone_numbers=['+18005550199']` + +### 4. API Modification — FCM Transport Override + +In `api/pkg/di/container.go`, modify `FirebaseMessagingClient()`: + +- When `FCM_ENDPOINT` env var is set, create the Firebase App with a custom HTTP client whose `Transport` rewrites request URLs from `https://fcm.googleapis.com` to the value of `FCM_ENDPOINT` +- This requires no changes to business logic — the messaging client works normally but routes traffic to the emulator +- The Firebase credentials must be a syntactically valid fake service account JSON with `token_uri` pointing to `http://emulator:9090/token` + +### 4b. `tests/.env.test` — API environment for tests + +```env +ENV=production +GCP_PROJECT_ID=httpsms-test +EVENTS_QUEUE_TYPE=emulator +EVENTS_QUEUE_NAME=events-local +EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events +EVENTS_QUEUE_USER_API_KEY=system-user-api-key +EVENTS_QUEUE_USER_ID=system-user-id +FCM_ENDPOINT=http://emulator:9090 +DATABASE_URL=postgresql://dbusername:dbpassword@postgres:5432/httpsms +DATABASE_URL_DEDICATED=postgresql://dbusername:dbpassword@postgres:5432/httpsms +REDIS_URL=redis://@redis:6379 +APP_PORT=8000 +ENTITLEMENT_ENABLED=false +USE_HTTP_LOGGER=true +FIREBASE_CREDENTIALS= +``` + +### 5. `tests/integration_test.go` (Go test files) + +Go tests using the standard `testing` package + `testify` for assertions: + +**Test 1: Send SMS E2E** + +1. `POST /v1/messages/send` with `from=`, `to=+18005550100`, `content="Hello"` (using user API key `x-api-key` header) +2. Extract message ID from response +3. Poll `GET /v1/messages/{id}` every 200ms with max 15s timeout (using user API key) +4. Assert message status reaches `delivered` +5. Assert message events include both `SENT` and `DELIVERED` + +**Test 2: Receive SMS** + +1. `POST /v1/messages/receive` (using phone API key auth) with `from=+18005550100`, `to=+18005550199`, `content="Hi there"`, `sim="SIM1"`, `timestamp=` +2. Extract message ID from response +3. `GET /v1/messages/{id}` (using user API key auth) +4. Assert message exists with correct content, from, to fields +5. Assert status is `received` + +### 6. `.github/workflows/integration-test.yml` + +GitHub Actions workflow: + +```yaml +name: integration-test +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + integration-test: + runs-on: ubuntu-latest + steps: + - Checkout + - Docker Compose up (tests/docker-compose.yml) + - Wait for health checks (API + emulator) + - Run: cd tests && go test -v -timeout 120s ./... + - Docker Compose down + + deploy-api: + needs: integration-test + # existing deploy logic +``` + +The `deploy-api` job depends on `integration-test` passing. + +## FCM Redirect Implementation Detail + +The Firebase Admin Go SDK's messaging client sends HTTP POST requests to: + +``` +https://fcm.googleapis.com/v1/projects/{project_id}/messages:send +``` + +We intercept this by providing a custom `http.RoundTripper`: + +```go +type fcmRedirectTransport struct { + target string // e.g., "http://emulator:9090" + base http.RoundTripper +} + +func (t *fcmRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Rewrite: https://fcm.googleapis.com/... → http://emulator:9090/... + req.URL.Scheme = "http" + req.URL.Host = strings.TrimPrefix(t.target, "http://") + return t.base.RoundTrip(req) +} +``` + +This is injected via `option.WithHTTPClient()` when creating the Firebase App in the DI container. + +## Fake Firebase Credentials + +For the integration test environment, we provide a minimal fake service account JSON: + +```json +{ + "type": "service_account", + "project_id": "httpsms-test", + "private_key_id": "test", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\n\n-----END RSA PRIVATE KEY-----\n", + "client_email": "test@httpsms-test.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "http://emulator:9090/token", + "auth_provider_x509_cert_url": "http://emulator:9090/certs", + "client_x509_cert_url": "http://emulator:9090/certs/test" +} +``` + +The emulator implements: + +- `POST /token` — Accepts JWT assertion grant, returns `{"access_token": "fake-token", "token_type": "Bearer", "expires_in": 3600}` +- Does NOT validate the JWT signature — just returns a valid token response + +## Docker Health Checks & Orchestration + +Services start in order with health dependencies: + +1. **postgres** — healthy when `pg_isready` passes +2. **redis** — healthy when accepting connections +3. **emulator** — healthy when `GET /health` returns 200 +4. **api** — starts after postgres+redis+emulator healthy, healthy when `GET /v1/` returns (or a dedicated health endpoint) + +Test runner waits for all services healthy before executing `go test`. + +## File Structure + +``` +tests/ +├── docker-compose.yml +├── seed.sql +├── go.mod +├── go.sum +├── integration_test.go +├── helpers_test.go # shared HTTP client, polling helpers +├── .env.test # env vars for the API in test mode +└── emulator/ + ├── Dockerfile + ├── go.mod + ├── go.sum + ├── main.go # entry point, starts HTTP server + ├── fcm_handler.go # fake FCM endpoint + ├── token_handler.go # fake OAuth2 token endpoint + └── events.go # fires SENT/DELIVERED events to API +``` + +## Key Design Decisions + +1. **DB seeding over Firebase Auth emulator** — Simpler, keeps focus on SMS flow testing. Auth is not what we're validating. +2. **Real FCM code path with redirected transport** — Tests the actual Firebase SDK integration, payload construction, and error handling. More confidence than a noop mock. +3. **Emulator as separate Go project** — Clean separation, own Dockerfile, own module. Doesn't pollute the API codebase. +4. **Test runner runs on host (not in Docker)** — Simpler debugging, standard `go test` output, easier CI integration. +5. **Polling with timeout for async assertions** — The send flow is async (event-driven). Polling with backoff is the pragmatic approach. + +## Out of Scope + +- Testing the web frontend +- Testing the Android app +- Load/performance testing +- Testing auth flows (login, registration) +- Testing billing/entitlements +- MMS/attachment testing (can be added later) From 1558ce6963f271a978c7882131e4c40d149f490a Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:41:40 +0300 Subject: [PATCH 29/35] feat(handlers): add per-phone index for CSV bulk messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/handlers/bulk_message_handler.go | 15 ++++++++++++--- api/pkg/requests/bulk_message_request.go | 3 ++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/api/pkg/handlers/bulk_message_handler.go b/api/pkg/handlers/bulk_message_handler.go index c660eeaa..16c833fe 100644 --- a/api/pkg/handlers/bulk_message_handler.go +++ b/api/pkg/handlers/bulk_message_handler.go @@ -89,13 +89,22 @@ func (h *BulkMessageHandler) Store(c *fiber.Ctx) error { wg := sync.WaitGroup{} count := atomic.Int64{} - for index, message := range messages { + // Compute per-phone index for rate-based dispatch delay + phoneIndexCounter := make(map[string]int) + + for _, message := range messages { wg.Add(1) + var perPhoneIndex int + if message.SendTime == nil { + perPhoneIndex = phoneIndexCounter[message.FromPhoneNumber] + phoneIndexCounter[message.FromPhoneNumber]++ + } + go func(message *requests.BulkMessage, index int) { count.Add(1) _, err = h.messageService.SendMessage( ctx, - message.ToMessageSendParams(h.userIDFomContext(c), requestID, c.OriginalURL()), + message.ToMessageSendParams(h.userIDFomContext(c), requestID, c.OriginalURL(), index), ) if err != nil { count.Add(-1) @@ -103,7 +112,7 @@ func (h *BulkMessageHandler) Store(c *fiber.Ctx) error { ctxLogger.Error(stacktrace.Propagate(err, msg)) } wg.Done() - }(message, index) + }(message, perPhoneIndex) } wg.Wait() diff --git a/api/pkg/requests/bulk_message_request.go b/api/pkg/requests/bulk_message_request.go index 77319997..000ff016 100644 --- a/api/pkg/requests/bulk_message_request.go +++ b/api/pkg/requests/bulk_message_request.go @@ -38,7 +38,7 @@ func (input *BulkMessage) Sanitize() *BulkMessage { } // ToMessageSendParams converts BulkMessage to services.MessageSendParams -func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID uuid.UUID, source string) services.MessageSendParams { +func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID uuid.UUID, source string, index int) services.MessageSendParams { from, _ := phonenumbers.Parse(input.FromPhoneNumber, phonenumbers.UNKNOWN_REGION) return services.MessageSendParams{ @@ -51,5 +51,6 @@ func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID Contact: input.sanitizeAddress(input.ToPhoneNumber), Content: input.Content, Attachments: input.removeEmptyStrings(strings.Split(input.AttachmentURLs, ",")), + Index: index, } } From 704355d9804b4e50a39b285dedaccdb0a52cd5c5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 17:50:40 +0300 Subject: [PATCH 30/35] docs: add integration test implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-05-03-integration-test-setup.md | 1107 +++++++++++++++++ 1 file changed, 1107 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-03-integration-test-setup.md diff --git a/docs/superpowers/plans/2026-05-03-integration-test-setup.md b/docs/superpowers/plans/2026-05-03-integration-test-setup.md new file mode 100644 index 00000000..9a8f8cb6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-integration-test-setup.md @@ -0,0 +1,1107 @@ +# Integration Test Setup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a CI-gated integration test that validates the full SMS send/receive flow using Docker, a phone emulator, and real FCM code paths redirected to the emulator. + +**Architecture:** Docker Compose brings up PostgreSQL + Redis + API + Emulator. The API's Firebase SDK is configured to route FCM traffic to the emulator via a custom HTTP transport. A Go test runner on the host exercises the API and asserts on message state. + +**Tech Stack:** Go, Docker Compose, PostgreSQL, Redis, Firebase Admin Go SDK, GitHub Actions + +--- + +## File Structure + +``` +tests/ +├── docker-compose.yml # orchestrates all services +├── seed.sql # seeds test user, phone, API keys +├── .env.test # API environment config for tests +├── firebase-credentials.json # fake service account JSON +├── go.mod # test runner Go module +├── go.sum +├── integration_test.go # test cases (send SMS, receive SMS) +├── helpers_test.go # HTTP client, polling, constants +└── emulator/ + ├── Dockerfile # builds emulator binary + ├── go.mod # emulator Go module + ├── go.sum + ├── main.go # entry point, HTTP server setup + ├── fcm_handler.go # fake FCM endpoint handler + ├── token_handler.go # fake OAuth2 token endpoint + └── events.go # fires SENT/DELIVERED events to API + +api/pkg/di/container.go # modified: FCM transport redirect +.github/workflows/integration-test.yml # new CI workflow +``` + +--- + +### Task 1: Create Feature Branch + +**Files:** + +- None (git operations only) + +- [ ] **Step 1: Create and switch to feature branch from main** + +```bash +cd C:\Users\Arnold\Work\NdoleStudio\httpsms.com +git checkout main +git pull origin main +git checkout -b feature/integration-tests +``` + +- [ ] **Step 2: Verify branch** + +Run: `git branch --show-current` +Expected: `feature/integration-tests` + +--- + +### Task 2: API Modification — FCM Transport Override + +**Files:** + +- Modify: `api/pkg/di/container.go:396-405` (FirebaseApp method) + +- [ ] **Step 1: Add the FCM redirect transport and modify FirebaseApp** + +In `api/pkg/di/container.go`, modify the `FirebaseApp()` method to check for `FCM_ENDPOINT` env var. When set, use ONLY a custom HTTP client (no credentials). When not set, use credentials as before. + +**Important:** `option.WithHTTPClient()` takes precedence over all other options in the Firebase SDK. Do NOT combine it with `option.WithAuthCredentialsJSON()`. Use one or the other. + +Create a new file `api/pkg/di/fcm_transport.go`: + +```go +package di + +import ( + "net/http" + "net/url" +) + +// fcmRedirectTransport rewrites Firebase SDK HTTP requests to a custom endpoint. +// Used in integration tests to redirect FCM traffic to the emulator. +type fcmRedirectTransport struct { + target *url.URL + base http.RoundTripper +} + +func (t *fcmRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.URL.Scheme = t.target.Scheme + req.URL.Host = t.target.Host + return t.base.RoundTrip(req) +} +``` + +Then modify `FirebaseApp()` in `container.go`: + +```go +// FirebaseApp creates a new instance of firebase.App +func (container *Container) FirebaseApp() (app *firebase.App) { + container.logger.Debug(fmt.Sprintf("creating %T", app)) + + var opts []option.ClientOption + + if fcmEndpoint := os.Getenv("FCM_ENDPOINT"); fcmEndpoint != "" { + container.logger.Info(fmt.Sprintf("using FCM endpoint override: %s", fcmEndpoint)) + targetURL, err := url.Parse(fcmEndpoint) + if err != nil { + container.logger.Fatal(stacktrace.Propagate(err, "cannot parse FCM_ENDPOINT")) + } + opts = append(opts, option.WithHTTPClient(&http.Client{ + Transport: &fcmRedirectTransport{ + target: targetURL, + base: http.DefaultTransport, + }, + })) + } else { + opts = append(opts, option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials())) + } + + app, err := firebase.NewApp(context.Background(), nil, opts...) + if err != nil { + msg := "cannot initialize firebase application" + container.logger.Fatal(stacktrace.Propagate(err, msg)) + } + return app +} +``` + +- [ ] **Step 2: Add `net/url` import if not already present** + +Ensure the `net/url` package is imported in `container.go` (or the new file). + +- [ ] **Step 3: Verify API still builds** + +Run: `cd api && go build ./...` +Expected: Build succeeds with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add api/pkg/di/ +git commit -m "feat(api): add FCM_ENDPOINT transport override for integration tests" +``` + +--- + +### Task 3: Emulator — Project Scaffolding + +**Files:** + +- Create: `tests/emulator/go.mod` +- Create: `tests/emulator/emulator.go` +- Create: `tests/emulator/Dockerfile` + +Note: `main.go` references `NewEmulator()` and handlers, so we create the struct first. `main.go` is created AFTER all handlers exist (Task 6b). + +- [ ] **Step 1: Initialize emulator Go module** + +```bash +mkdir -p tests/emulator +cd tests/emulator +go mod init github.com/NdoleStudio/httpsms/tests/emulator +``` + +- [ ] **Step 2: Create `tests/emulator/emulator.go`** + +```go +package main + +import "net/http" + +// Emulator acts as a fake Android phone that receives FCM pushes +// and responds with message events. +type Emulator struct { + apiBaseURL string + phoneAPIKey string + httpClient *http.Client +} + +// NewEmulator creates a new Emulator instance. +func NewEmulator(apiBaseURL, phoneAPIKey string) *Emulator { + return &Emulator{ + apiBaseURL: apiBaseURL, + phoneAPIKey: phoneAPIKey, + httpClient: &http.Client{}, + } +} + +// HealthHandler returns 200 OK for health checks. +func (e *Emulator) HealthHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} +``` + +- [ ] **Step 3: Create `tests/emulator/Dockerfile`** + +```dockerfile +FROM golang:1.22 AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/emulator . + +FROM alpine:latest +RUN apk add --no-cache ca-certificates +COPY --from=builder /bin/emulator /bin/emulator +EXPOSE 9090 +ENTRYPOINT ["/bin/emulator"] +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/emulator/ +git commit -m "feat(tests): scaffold emulator Go project" +``` + +--- + +### Task 4: Emulator — Token Handler + +**Files:** + +- Create: `tests/emulator/token_handler.go` + +- [ ] **Step 1: Create `tests/emulator/token_handler.go`** + +```go +package main + +import ( + "encoding/json" + "net/http" +) + +// TokenHandler returns a fake OAuth2 access token. +// The Firebase Admin SDK calls this endpoint to get an access token +// before making FCM API calls. +func (e *Emulator) TokenHandler(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/emulator/token_handler.go +git commit -m "feat(tests): add fake OAuth2 token handler to emulator" +``` + +--- + +### Task 5: Emulator — FCM Handler + +**Files:** + +- Create: `tests/emulator/fcm_handler.go` + +- [ ] **Step 1: Create `tests/emulator/fcm_handler.go`** + +```go +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" +) + +// fcmRequest represents the FCM v1 API request body +type fcmRequest struct { + Message struct { + Data map[string]string `json:"data"` + Token string `json:"token"` + Android struct { + Priority string `json:"priority"` + } `json:"android"` + } `json:"message"` +} + +// fcmResponse represents the FCM v1 API response +type fcmResponse struct { + Name string `json:"name"` +} + +// FCMHandler handles fake FCM send requests from the Firebase Admin SDK. +func (e *Emulator) FCMHandler(w http.ResponseWriter, r *http.Request) { + var req fcmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + messageID := req.Message.Data["KEY_MESSAGE_ID"] + if messageID == "" { + http.Error(w, "missing KEY_MESSAGE_ID in data", http.StatusBadRequest) + return + } + + log.Printf("received FCM push for message: %s", messageID) + + // Respond with success immediately (like real FCM would) + resp := fcmResponse{ + Name: fmt.Sprintf("projects/httpsms-test/messages/fake-%s", messageID), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + + // Process the message asynchronously (like a real phone would) + go e.processMessage(messageID) +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/emulator/emulator.go tests/emulator/fcm_handler.go +git commit -m "feat(tests): add FCM handler to emulator" +``` + +--- + +### Task 6: Emulator — Event Firing + +**Files:** + +- Create: `tests/emulator/events.go` + +- [ ] **Step 1: Create `tests/emulator/events.go`** + +```go +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +// messageEvent is the payload for posting a message event to the API +type messageEvent struct { + Timestamp time.Time `json:"timestamp"` + EventName string `json:"event_name"` +} + +// processMessage simulates a phone receiving an FCM push and sending the SMS. +// It calls /messages/outstanding, then fires SENT and DELIVERED events. +func (e *Emulator) processMessage(messageID string) { + // Step 1: Fetch outstanding message (like real phone does) + e.fetchOutstanding(messageID) + + // Step 2: Wait briefly then fire SENT + time.Sleep(200 * time.Millisecond) + if err := e.fireEvent(messageID, "SENT"); err != nil { + log.Printf("error firing SENT event for message %s: %v", messageID, err) + return + } + + // Step 3: Wait briefly then fire DELIVERED + time.Sleep(200 * time.Millisecond) + if err := e.fireEvent(messageID, "DELIVERED"); err != nil { + log.Printf("error firing DELIVERED event for message %s: %v", messageID, err) + return + } + + log.Printf("completed processing message: %s", messageID) +} + +// fetchOutstanding calls GET /v1/messages/outstanding to mimic the real phone behavior +func (e *Emulator) fetchOutstanding(messageID string) { + url := fmt.Sprintf("%s/v1/messages/outstanding?message_id=%s", e.apiBaseURL, messageID) + + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("x-api-key", e.phoneAPIKey) + + resp, err := e.httpClient.Do(req) + if err != nil { + log.Printf("error fetching outstanding message %s: %v", messageID, err) + return + } + defer resp.Body.Close() + log.Printf("fetched outstanding message %s: status %d", messageID, resp.StatusCode) +} + +// fireEvent posts a message event (SENT or DELIVERED) to the API +func (e *Emulator) fireEvent(messageID, eventName string) error { + url := fmt.Sprintf("%s/v1/messages/%s/events", e.apiBaseURL, messageID) + + event := messageEvent{ + Timestamp: time.Now().UTC(), + EventName: eventName, + } + + body, _ := json.Marshal(event) + req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", e.phoneAPIKey) + + resp, err := e.httpClient.Do(req) + if err != nil { + return fmt.Errorf("HTTP error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("API returned status %d for %s event", resp.StatusCode, eventName) + } + + log.Printf("fired %s event for message %s: status %d", eventName, messageID, resp.StatusCode) + return nil +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/emulator/events.go +git commit -m "feat(tests): add event firing to emulator" +``` + +--- + +### Task 6b: Emulator — Main Entry Point + +**Files:** + +- Create: `tests/emulator/main.go` + +- [ ] **Step 1: Create `tests/emulator/main.go`** + +Now that all handlers exist (HealthHandler, TokenHandler, FCMHandler), create the entry point: + +```go +package main + +import ( + "log" + "net/http" + "os" +) + +func main() { + apiBaseURL := os.Getenv("API_BASE_URL") + if apiBaseURL == "" { + apiBaseURL = "http://api:8000" + } + + phoneAPIKey := os.Getenv("PHONE_API_KEY") + if phoneAPIKey == "" { + phoneAPIKey = "pk_test-phone-api-key" + } + + emulator := NewEmulator(apiBaseURL, phoneAPIKey) + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", emulator.HealthHandler) + mux.HandleFunc("POST /token", emulator.TokenHandler) + mux.HandleFunc("POST /v1/projects/{project}/messages:send", emulator.FCMHandler) + + port := os.Getenv("PORT") + if port == "" { + port = "9090" + } + + log.Printf("emulator listening on :%s", port) + if err := http.ListenAndServe(":"+port, mux); err != nil { + log.Fatalf("server error: %v", err) + } +} +``` + +- [ ] **Step 2: Verify emulator builds** + +```bash +cd tests/emulator +go build ./... +``` + +Expected: Build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add tests/emulator/main.go +git commit -m "feat(tests): add emulator main entry point" +``` + +--- + +### Task 7: Test Infrastructure — Seed Data & Config + +**Files:** + +- Create: `tests/seed.sql` +- Create: `tests/.env.test` +- Create: `tests/firebase-credentials.json` + +- [ ] **Step 1: Create `tests/seed.sql`** + +This script must match the exact table schema from entities. The tables are auto-migrated by GORM, so we insert after API startup. Actually — since we need the user to exist BEFORE the API processes requests, we seed via Docker's postgres init scripts. + +Note: GORM auto-migrates tables on API startup. The seed SQL runs AFTER table creation. We use a Docker healthcheck + depends_on to ensure ordering. Alternatively, we can use a startup script that waits for the API to be ready, then seeds. The simplest approach: mount `seed.sql` as a Postgres init script — but that runs before GORM migrates. + +**Better approach:** Create a `tests/seed.sh` script that waits for the API to start (which runs GORM migrations), then seeds the database via `psql`. + +```sql +-- tests/seed.sql +-- Seed test data for integration tests +-- Run AFTER GORM has migrated the schema (i.e., after API starts) + +-- Test user +INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) +VALUES ( + 'test-user-id', + 'test@httpsms.com', + 'test-user-api-key', + 'UTC', + 'pro-monthly', + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- System user (for event queue auth) +INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) +VALUES ( + 'system-user-id', + 'system@httpsms.com', + 'system-user-api-key', + 'UTC', + 'pro-monthly', + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Test phone +INSERT INTO phones (id, user_id, fcm_token, phone_number, messages_per_minute, sim, max_send_attempts, message_expiration_seconds, created_at, updated_at) +VALUES ( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + 'test-user-id', + 'fake-fcm-token', + '+18005550199', + 60, + 'SIM1', + 2, + 600, + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Phone API key (for emulator to authenticate as phone) +INSERT INTO phone_api_keys (id, name, user_id, user_email, phone_numbers, phone_ids, api_key, created_at, updated_at) +VALUES ( + 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + 'Integration Test Phone Key', + 'test-user-id', + 'test@httpsms.com', + '{"+18005550199"}', + '{"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}', + 'pk_test-phone-api-key', + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; +``` + +- [ ] **Step 2: Create `tests/.env.test`** + +```env +ENV=production +GCP_PROJECT_ID=httpsms-test +USE_HTTP_LOGGER=true +ENTITLEMENT_ENABLED=false +EVENTS_QUEUE_TYPE=emulator +EVENTS_QUEUE_NAME=events-local +EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events +EVENTS_QUEUE_USER_API_KEY=system-user-api-key +EVENTS_QUEUE_USER_ID=system-user-id +FCM_ENDPOINT=http://emulator:9090 +DATABASE_URL=postgresql://dbusername:dbpassword@postgres:5432/httpsms +DATABASE_URL_DEDICATED=postgresql://dbusername:dbpassword@postgres:5432/httpsms +REDIS_URL=redis://@redis:6379 +APP_PORT=8000 +APP_NAME=httpSMS +APP_URL=http://localhost:8000 +SWAGGER_HOST=localhost:8000 +SMTP_FROM_NAME=httpSMS +SMTP_FROM_EMAIL=test@httpsms.com +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_HOST=localhost +SMTP_PORT=2525 +PUSHER_APP_ID= +PUSHER_KEY= +PUSHER_SECRET= +PUSHER_CLUSTER= +GCS_BUCKET_NAME= +UPTRACE_DSN= +CLOUDFLARE_TURNSTILE_SECRET_KEY= +``` + +- [ ] **Step 3: Create `tests/firebase-credentials.json`** + +Generate an RSA private key for the fake service account. This must be a valid RSA key so the Firebase SDK can sign JWT tokens (even though the emulator won't validate them). + +```bash +cd tests +openssl genrsa -out /tmp/test-key.pem 2048 +``` + +Then create the JSON file with the key embedded: + +```json +{ + "type": "service_account", + "project_id": "httpsms-test", + "private_key_id": "test-key-id", + "private_key": "", + "client_email": "test@httpsms-test.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "http://emulator:9090/auth", + "token_uri": "http://emulator:9090/token", + "auth_provider_x509_cert_url": "http://emulator:9090/certs", + "client_x509_cert_url": "http://emulator:9090/certs/test" +} +``` + +Note: The `FIREBASE_CREDENTIALS` env var in `.env.test` should be set to the full contents of this JSON file (single-line). The docker-compose will handle this. + +- [ ] **Step 4: Commit** + +```bash +git add tests/seed.sql tests/.env.test tests/firebase-credentials.json +git commit -m "feat(tests): add seed data and test environment config" +``` + +--- + +### Task 8: Docker Compose for Tests + +**Files:** + +- Create: `tests/docker-compose.yml` + +- [ ] **Step 1: Create `tests/docker-compose.yml`** + +```yaml +services: + postgres: + image: postgres:alpine + environment: + POSTGRES_DB: httpsms + POSTGRES_PASSWORD: dbpassword + POSTGRES_USER: dbusername + ports: + - "5435:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dbusername -d httpsms"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + + redis: + image: redis:latest + command: redis-server + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + emulator: + build: + context: ./emulator + ports: + - "9090:9090" + environment: + API_BASE_URL: http://api:8000 + PHONE_API_KEY: pk_test-phone-api-key + PORT: "9090" + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/health"] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: + context: ../api + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + emulator: + condition: service_healthy + env_file: + - .env.test + environment: + FIREBASE_CREDENTIALS: "${FIREBASE_CREDENTIALS}" + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8000/"] + interval: 5s + timeout: 10s + retries: 20 + start_period: 30s + + seed: + image: postgres:alpine + depends_on: + api: + condition: service_healthy + environment: + PGPASSWORD: dbpassword + volumes: + - ./seed.sql:/seed.sql:ro + entrypoint: + [ + "psql", + "-h", + "postgres", + "-U", + "dbusername", + "-d", + "httpsms", + "-f", + "/seed.sql", + ] + restart: "no" +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/docker-compose.yml +git commit -m "feat(tests): add docker-compose for integration test stack" +``` + +--- + +### Task 9: Test Runner — Go Module & Helpers + +**Files:** + +- Create: `tests/go.mod` +- Create: `tests/helpers_test.go` + +- [ ] **Step 1: Initialize test runner Go module** + +```bash +cd tests +go mod init github.com/NdoleStudio/httpsms/tests +go get github.com/stretchr/testify +``` + +- [ ] **Step 2: Create `tests/helpers_test.go`** + +```go +package tests + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + apiBaseURL = "http://localhost:8000" + userAPIKey = "test-user-api-key" + phoneAPIKey = "pk_test-phone-api-key" + testPhone = "+18005550199" + testContact = "+18005550100" +) + +// apiClient returns an HTTP client configured for API calls +func apiClient() *http.Client { + return &http.Client{Timeout: 10 * time.Second} +} + +// doRequest performs an HTTP request with the given API key +func doRequest(t *testing.T, method, url string, body io.Reader, apiKey string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, url, body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", apiKey) + + resp, err := apiClient().Do(req) + require.NoError(t, err) + return resp +} + +// pollMessageStatus polls GET /v1/messages/{id} until the message reaches the target status or times out +func pollMessageStatus(t *testing.T, messageID, targetStatus string, timeout time.Duration) map[string]interface{} { + t.Helper() + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + url := fmt.Sprintf("%s/v1/messages/%s", apiBaseURL, messageID) + resp := doRequest(t, "GET", url, nil, userAPIKey) + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + + if resp.StatusCode == http.StatusOK { + var result map[string]interface{} + require.NoError(t, json.Unmarshal(body, &result)) + + data, ok := result["data"].(map[string]interface{}) + if ok && data["status"] == targetStatus { + return data + } + } + + time.Sleep(200 * time.Millisecond) + } + + t.Fatalf("message %s did not reach status %q within %v", messageID, targetStatus, timeout) + return nil +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/go.mod tests/go.sum tests/helpers_test.go +git commit -m "feat(tests): add test runner module and helpers" +``` + +--- + +### Task 10: Test Runner — Integration Tests + +**Files:** + +- Create: `tests/integration_test.go` + +- [ ] **Step 1: Create `tests/integration_test.go`** + +```go +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSendSMS_E2E(t *testing.T) { + // Step 1: Send an SMS via the API + sendPayload := map[string]interface{}{ + "from": testPhone, + "to": testContact, + "content": "Hello from integration test", + } + body, _ := json.Marshal(sendPayload) + + url := fmt.Sprintf("%s/v1/messages/send", apiBaseURL) + resp := doRequest(t, "POST", url, bytes.NewReader(body), userAPIKey) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "send response: %s", string(respBody)) + + // Step 2: Extract message ID + var sendResult map[string]interface{} + require.NoError(t, json.Unmarshal(respBody, &sendResult)) + data := sendResult["data"].(map[string]interface{}) + messageID := data["id"].(string) + require.NotEmpty(t, messageID) + + t.Logf("sent message with ID: %s", messageID) + + // Step 3: Poll until message is delivered + message := pollMessageStatus(t, messageID, "delivered", 15*time.Second) + + // Step 4: Assert final state + assert.Equal(t, "delivered", message["status"]) + assert.Equal(t, testPhone, message["owner"]) + assert.Equal(t, testContact, message["contact"]) + assert.Equal(t, "Hello from integration test", message["content"]) +} + +func TestReceiveSMS_E2E(t *testing.T) { + // Step 1: Simulate receiving an SMS (phone -> API) + receivePayload := map[string]interface{}{ + "from": testContact, + "to": testPhone, + "content": "Hi there from integration test", + "encrypted": false, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + } + body, _ := json.Marshal(receivePayload) + + url := fmt.Sprintf("%s/v1/messages/receive", apiBaseURL) + resp := doRequest(t, "POST", url, bytes.NewReader(body), phoneAPIKey) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "receive response: %s", string(respBody)) + + // Step 2: Extract message ID + var receiveResult map[string]interface{} + require.NoError(t, json.Unmarshal(respBody, &receiveResult)) + data := receiveResult["data"].(map[string]interface{}) + messageID := data["id"].(string) + require.NotEmpty(t, messageID) + + t.Logf("received message with ID: %s", messageID) + + // Step 3: Verify message exists via GET + getURL := fmt.Sprintf("%s/v1/messages/%s", apiBaseURL, messageID) + getResp := doRequest(t, "GET", getURL, nil, userAPIKey) + defer getResp.Body.Close() + + getBody, err := io.ReadAll(getResp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, getResp.StatusCode) + + var getMessage map[string]interface{} + require.NoError(t, json.Unmarshal(getBody, &getMessage)) + messageData := getMessage["data"].(map[string]interface{}) + + // Step 4: Assert message fields + assert.Equal(t, "received", messageData["status"]) + assert.Equal(t, testPhone, messageData["owner"]) + assert.Equal(t, testContact, messageData["contact"]) + assert.Equal(t, "Hi there from integration test", messageData["content"]) +} +``` + +- [ ] **Step 2: Verify test file compiles** + +```bash +cd tests +go vet ./... +``` + +Expected: No errors (tests won't pass yet without the stack running). + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration_test.go +git commit -m "feat(tests): add send and receive SMS integration tests" +``` + +--- + +### Task 11: GitHub Actions Workflow + +**Files:** + +- Create: `.github/workflows/integration-test.yml` + +- [ ] **Step 1: Create `.github/workflows/integration-test.yml`** + +```yaml +name: integration-test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + integration-test: + runs-on: ubuntu-latest + steps: + - name: Checkout 🛎 + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Load Firebase credentials + run: | + echo "FIREBASE_CREDENTIALS=$(jq -c . tests/firebase-credentials.json)" >> $GITHUB_ENV + + - name: Start services 🐳 + working-directory: ./tests + run: docker compose up -d --build --wait + + - name: Wait for seed to complete + working-directory: ./tests + run: | + echo "Waiting for seed container to finish..." + docker compose wait seed || true + sleep 2 + + - name: Run integration tests 🧪 + working-directory: ./tests + run: go test -v -timeout 120s ./... + + - name: Collect logs on failure 📋 + if: failure() + working-directory: ./tests + run: | + docker compose logs api + docker compose logs emulator + + - name: Stop services 🛑 + if: always() + working-directory: ./tests + run: docker compose down -v +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/integration-test.yml +git commit -m "ci: add integration test workflow" +``` + +--- + +### Task 12: Local End-to-End Verification + +**Files:** + +- None (verification only) + +- [ ] **Step 1: Generate the fake Firebase credentials file** + +```bash +cd tests +openssl genrsa 2048 > /tmp/test-key.pem +# Create firebase-credentials.json with the key (use a script or manually format) +``` + +- [ ] **Step 2: Build and start the stack** + +```bash +cd tests +export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) +docker compose up -d --build +``` + +- [ ] **Step 3: Wait for all services to be healthy** + +```bash +docker compose ps +# All services should show "healthy" or "exited (0)" for seed +``` + +- [ ] **Step 4: Run the tests** + +```bash +cd tests +go test -v -timeout 120s ./... +``` + +Expected: Both tests pass. + +- [ ] **Step 5: Tear down** + +```bash +docker compose down -v +``` + +- [ ] **Step 6: Push branch and create PR** + +```bash +git push -u origin feature/integration-tests +gh pr create --title "feat: add integration test setup for API" --body "Adds E2E integration tests that validate the full SMS send/receive flow using Docker and a phone emulator." +``` From fe05df41d0d108c29ebd0934a18213f0b7bd12fa Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 23:04:20 +0300 Subject: [PATCH 31/35] docs: add outgoing message queue technical documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- outgoing-message-queue.md | 173 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 outgoing-message-queue.md diff --git a/outgoing-message-queue.md b/outgoing-message-queue.md new file mode 100644 index 00000000..0dfed24f --- /dev/null +++ b/outgoing-message-queue.md @@ -0,0 +1,173 @@ +# Outgoing Message Queue + +Complete guide on how httpSMS queues outgoing SMS messages for reliable delivery, including rate-based dispatch, scheduled sending, and send schedule windows. + +## How the Message Queue Works + +When you send an SMS through httpSMS (via the API, bulk send, or Excel upload), messages don't go directly to your Android phone. Instead, they enter an **outgoing message queue** that intelligently schedules delivery to ensure reliability and prevent carrier throttling. + +The queue determines **when** each message is dispatched to your phone based on three factors: + +1. **Explicit send time** — If you specify a `send_at` time, the message is sent at exactly that time +2. **Rate-based dispatch delay** — Messages without a send time are spaced out based on your configured send rate +3. **Send schedule window** — Messages can be held until your configured active hours (if enabled) + +## 1. Explicit Send Time (Bypass Queue Logic) + +When you specify a `send_at` time in your API request or a `SendTime` column in your Excel upload, the message **bypasses** both rate-limiting and schedule window logic entirely. The message will be dispatched to your phone at exactly the time you specified. + +This is ideal for: + +- Time-sensitive alerts that must go out at a precise moment +- Promotional messages timed for a specific campaign window +- Appointment reminders scheduled for a specific time before the appointment + +### Sending a single message at a specific time + +```bash +curl -L \ + --request POST \ + --url 'https://api.httpsms.com/v1/messages/send' \ + --header 'Content-Type: application/json' \ + --header 'x-api-Key: YOUR_API_KEY' \ + --data '{ + "from": "+18005550199", + "to": "+18005550100", + "content": "Your appointment is in 1 hour", + "send_at": "2025-12-19T16:39:57-08:00" + }' +``` + +The `send_at` field accepts time in [RFC 3339 format](https://datatracker.ietf.org/doc/html/rfc3339) which includes the time zone (e.g., `1996-12-19T16:39:57-08:00`). You can schedule messages up to 20 days (480 hours) in the future. + +> **Note:** If you specify a `send_at` time that is in the past, the message will be sent immediately. + +### Setting send time in bulk Excel uploads + +When using the [bulk messages Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx), you can set the optional `SendTime(optional)` column to specify when each message should be sent. Use the format `YYYY-MM-DDTHH:MM:SS` in your local time zone (e.g., `2023-11-13T02:10:01`). + +Each row with a `SendTime` value will be dispatched at exactly that time, independent of other messages in the batch. + +## 2. Rate-Based Dispatch Delay + +When you send messages **without** a `send_at` time (especially in bulk), httpSMS automatically spaces out delivery based on your phone's configured **Messages Per Minute** rate. This prevents carrier throttling and ensures reliable delivery. + +### How rate-based dispatch works + +The system calculates a dispatch delay for each message based on its position in the batch: + +``` +interval = 60 seconds ÷ messages_per_minute +delay = message_index × interval +``` + +**Example:** If your phone is configured for 10 messages per minute: + +| Message | Index | Delay | Dispatched At | +| ------- | ----- | ----- | ------------- | +| 1st | 0 | 0s | Immediately | +| 2nd | 1 | 6s | +6 seconds | +| 3rd | 2 | 12s | +12 seconds | +| 4th | 3 | 18s | +18 seconds | +| 10th | 9 | 54s | +54 seconds | + +This ensures your phone sends at most 10 SMS per minute, matching the configured rate. + +### Per-phone indexing for bulk sends + +When sending bulk messages to multiple recipients from the same phone number, the index is calculated per phone. This means messages to different recipient numbers are all spaced according to the sending phone's rate, ensuring the sending phone isn't overwhelmed. + +When using Excel/CSV uploads with multiple sender phones (different `From` numbers), each phone gets its own independent index counter. Messages from Phone A don't affect the timing of messages from Phone B. + +### Configuring Messages Per Minute + +To modify the send rate for your phone number: + +1. Go to [https://httpsms.com/settings](https://httpsms.com/settings#phones) +2. Tap the **"EDIT"** button on the phone number +3. Update the **"Messages Per Minute"** value + +**Default:** 10 messages per minute for newly registered phones. + +**Maximum:** 29 messages per minute (the [maximum permitted by an unrooted Android phone](https://android.googlesource.com/platform/frameworks/opt/telephony/+/master/src/java/com/android/internal/telephony/SmsUsageMonitor.java#84)). + +> **Tip:** If you're sending large batches, a lower rate (5-10/min) is more reliable. Higher rates (20+/min) may trigger carrier spam filters depending on your region. + +## 3. Send Schedule Window + +The send schedule window allows you to restrict message delivery to specific hours of the day. When enabled, messages sent outside the configured window are held in the queue and dispatched when the next window opens. + +This is useful for: + +- Respecting recipient quiet hours (no messages at 3 AM) +- Complying with regional messaging regulations +- Concentrating delivery during business hours + +> **Important:** Messages with an explicit `send_at` time bypass the send schedule window entirely. Only messages without a specified send time are subject to window restrictions. + +### Configuring the Send Schedule + +You can configure the send schedule window for each phone number in your account settings at [https://httpsms.com/settings](https://httpsms.com/settings#phones). Click **"EDIT"** on the phone number and set: + +- **Schedule Active** — Enable or disable the schedule window +- **Start Time** — The time of day when sending begins (e.g., `08:00`) +- **End Time** — The time of day when sending stops (e.g., `21:00`) +- **Timezone** — The timezone for the schedule (e.g., `America/New_York`) + +### How the schedule window works + +| Current Time vs Window | Behavior | +| ---------------------- | ------------------------------------------------------ | +| Within window | Message dispatched immediately (subject to rate delay) | +| Before window opens | Message held until window start time | +| After window closes | Message held until next day's window start time | + +## Bulk Send via API + +When sending to multiple recipients using the bulk API endpoint, all messages are automatically queued with rate-based dispatch delays: + +```bash +curl -L \ + --request POST \ + --url 'https://api.httpsms.com/v1/messages/bulk-send' \ + --header 'Content-Type: application/json' \ + --header 'x-api-Key: YOUR_API_KEY' \ + --data '{ + "from": "+18005550199", + "to": ["+18005550100", "+18005550101", "+18005550102"], + "content": "Hello from httpSMS!" + }' +``` + +In this example, with a default rate of 10 messages/minute: + +- Message to `+18005550100` → sent immediately +- Message to `+18005550101` → sent after 6 seconds +- Message to `+18005550102` → sent after 12 seconds + +## Summary: Queue Decision Flow + +``` +Message received by httpSMS API + │ + ├── Has explicit `send_at` time? + │ │ + │ YES → Dispatch at exactly that time + │ (bypasses rate-limit AND schedule window) + │ + └── No `send_at` time + │ + ├── Calculate rate-based delay + │ (index × 60s ÷ messages_per_minute) + │ + └── Apply send schedule window + (hold until window opens if outside active hours) +``` + +## Key Points + +- **Explicit send time always wins** — Setting `send_at` bypasses all queue logic +- **Rate limiting prevents throttling** — Messages are spaced based on your configured rate +- **Schedule windows respect quiet hours** — Messages without a send time are held until the window opens +- **Per-phone independence** — Each sending phone has its own rate counter and schedule +- **Past send times are handled gracefully** — If `send_at` is in the past, the message sends immediately From 87de540b583cf522379aa7fbe07b37eac40f66a2 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 23:05:58 +0300 Subject: [PATCH 32/35] docs: use mermaid diagram for queue decision flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- outgoing-message-queue.md | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/outgoing-message-queue.md b/outgoing-message-queue.md index 0dfed24f..0e8ee144 100644 --- a/outgoing-message-queue.md +++ b/outgoing-message-queue.md @@ -147,21 +147,19 @@ In this example, with a default rate of 10 messages/minute: ## Summary: Queue Decision Flow -``` -Message received by httpSMS API - │ - ├── Has explicit `send_at` time? - │ │ - │ YES → Dispatch at exactly that time - │ (bypasses rate-limit AND schedule window) - │ - └── No `send_at` time - │ - ├── Calculate rate-based delay - │ (index × 60s ÷ messages_per_minute) - │ - └── Apply send schedule window - (hold until window opens if outside active hours) +```mermaid +flowchart TD + A[Message received by httpSMS API] --> B{Has explicit send_at time?} + B -->|YES| C[Dispatch at exactly that time] + C --> D[Bypasses rate-limit AND schedule window] + B -->|NO| E[Calculate rate-based delay] + E --> F["delay = index × (60s ÷ messages_per_minute)"] + F --> G{Send schedule window enabled?} + G -->|YES| H{Within active window?} + G -->|NO| I[Dispatch with rate delay only] + H -->|YES| I + H -->|NO| J[Hold until window opens] + J --> I ``` ## Key Points From 1840217002c22d664a547d97a4d6fe008b0528b7 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 23:21:17 +0300 Subject: [PATCH 33/35] refactor: rename send_schedule to message_send_schedule for consistency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/docs/docs.go | 146 +- api/docs/swagger.json | 9743 ++++++++--------- api/docs/swagger.yaml | 2591 ++--- api/pkg/di/container.go | 62 +- ...d_schedule.go => message_send_schedule.go} | 0 ..._test.go => message_send_schedule_test.go} | 0 ...er.go => message_send_schedule_handler.go} | 44 +- ...r.go => message_send_schedule_listener.go} | 20 +- ... gorm_message_send_schedule_repository.go} | 28 +- ...go => message_send_schedule_repository.go} | 4 +- .../message_send_schedule_store_request.go | 52 + .../requests/send_schedule_store_request.go | 48 - .../message_send_schedule_responses.go | 15 + api/pkg/responses/send_schedule_responses.go | 13 - ...ce.go => message_send_schedule_service.go} | 40 +- .../services/phone_notification_service.go | 32 +- ...essage_send_schedule_handler_validator.go} | 44 +- 17 files changed, 6228 insertions(+), 6654 deletions(-) rename api/pkg/entities/{send_schedule.go => message_send_schedule.go} (100%) rename api/pkg/entities/{send_schedule_test.go => message_send_schedule_test.go} (100%) rename api/pkg/handlers/{send_schedule_handler.go => message_send_schedule_handler.go} (83%) rename api/pkg/listeners/{send_schedule_listener.go => message_send_schedule_listener.go} (68%) rename api/pkg/repositories/{gorm_send_schedule_repository.go => gorm_message_send_schedule_repository.go} (82%) rename api/pkg/repositories/{send_schedule_repository.go => message_send_schedule_repository.go} (89%) create mode 100644 api/pkg/requests/message_send_schedule_store_request.go delete mode 100644 api/pkg/requests/send_schedule_store_request.go create mode 100644 api/pkg/responses/message_send_schedule_responses.go delete mode 100644 api/pkg/responses/send_schedule_responses.go rename api/pkg/services/{send_schedule_service.go => message_send_schedule_service.go} (78%) rename api/pkg/validators/{send_schedule_handler_validator.go => message_send_schedule_handler_validator.go} (69%) diff --git a/api/docs/docs.go b/api/docs/docs.go index 934cd282..018614fa 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -2242,7 +2242,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/requests.SendScheduleStore" + "$ref": "#/definitions/requests.MessageSendScheduleStore" } } ], @@ -2250,7 +2250,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/responses.SendScheduleResponse" + "$ref": "#/definitions/responses.MessageSendScheduleResponse" } }, "400": { @@ -2265,6 +2265,12 @@ const docTemplate = `{ "$ref": "#/definitions/responses.Unauthorized" } }, + "402": { + "description": "Payment Required", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, "422": { "description": "Unprocessable Entity", "schema": { @@ -2312,7 +2318,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/requests.SendScheduleStore" + "$ref": "#/definitions/requests.MessageSendScheduleStore" } } ], @@ -2320,7 +2326,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/responses.SendScheduleResponse" + "$ref": "#/definitions/responses.MessageSendScheduleResponse" } }, "400": { @@ -4246,6 +4252,51 @@ const docTemplate = `{ } } }, + "requests.MessageSendScheduleStore": { + "type": "object", + "required": [ + "is_active", + "name", + "timezone", + "windows" + ], + "properties": { + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "windows": { + "type": "array", + "items": { + "$ref": "#/definitions/requests.MessageSendScheduleWindow" + } + } + } + }, + "requests.MessageSendScheduleWindow": { + "type": "object", + "required": [ + "day_of_week", + "end_minute", + "start_minute" + ], + "properties": { + "day_of_week": { + "type": "integer" + }, + "end_minute": { + "type": "integer" + }, + "start_minute": { + "type": "integer" + } + } + }, "requests.MessageThreadUpdate": { "type": "object", "required": [ @@ -4343,51 +4394,6 @@ const docTemplate = `{ } } }, - "requests.SendScheduleStore": { - "type": "object", - "required": [ - "is_active", - "name", - "timezone", - "windows" - ], - "properties": { - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "timezone": { - "type": "string" - }, - "windows": { - "type": "array", - "items": { - "$ref": "#/definitions/requests.SendScheduleWindow" - } - } - } - }, - "requests.SendScheduleWindow": { - "type": "object", - "required": [ - "day_of_week", - "end_minute", - "start_minute" - ], - "properties": { - "day_of_week": { - "type": "integer" - }, - "end_minute": { - "type": "integer" - }, - "start_minute": { - "type": "integer" - } - } - }, "requests.UserNotificationUpdate": { "type": "object", "required": [ @@ -4735,6 +4741,27 @@ const docTemplate = `{ } } }, + "responses.MessageSendScheduleResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.MessageSendSchedule" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.MessageThreadsResponse": { "type": "object", "required": [ @@ -4928,27 +4955,6 @@ const docTemplate = `{ } } }, - "responses.SendScheduleResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/entities.MessageSendSchedule" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } - }, "responses.Unauthorized": { "type": "object", "required": [ diff --git a/api/docs/swagger.json b/api/docs/swagger.json index e48dab66..3045ce7f 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1,5222 +1,4739 @@ { - "schemes": [ - "https" - ], - "swagger": "2.0", - "info": { - "description": "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.", - "title": "httpSMS API Reference", + "schemes": ["https"], + "swagger": "2.0", + "info": { + "description": "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.", + "title": "httpSMS API Reference", + "contact": { + "name": "support@httpsms.com", + "email": "support@httpsms.com" + }, + "license": { + "name": "AGPL-3.0", + "url": "https://raw.githubusercontent.com/NdoleStudio/http-sms-manager/main/LICENSE" + }, + "version": "1.0" + }, + "host": "api.httpsms.com", + "basePath": "/v1", + "paths": { + "/billing/usage": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the summary of sent and received messages for a user in the current month", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Billing"], + "summary": "Get Billing Usage.", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.BillingUsageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/billing/usage-history": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get billing usage records of sent and received messages for a user in the past. It will be sorted by timestamp in descending order.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Billing"], + "summary": "Get billing usage history.", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of heartbeats to skip", + "name": "skip", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "description": "number of heartbeats to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.BillingUsagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/bulk-messages": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).", + "consumes": ["multipart/form-data"], + "produces": ["application/json"], + "tags": ["BulkSMS"], + "summary": "Store bulk SMS file", + "parameters": [ + { + "type": "file", + "description": "The Excel or CSV file containing the messages to be sent.", + "name": "document", + "in": "formData", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/discord-integrations": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the discord integrations of a user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["DiscordIntegration"], + "summary": "Get discord integrations of a user", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of discord integrations to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter discord integrations containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of discord integrations to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.DiscordsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Store a discord integration for the authenticated user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["DiscordIntegration"], + "summary": "Store discord integration", + "parameters": [ + { + "description": "Payload of the discord integration request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.DiscordStore" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.DiscordResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/discord-integrations/{discordID}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a discord integration for the currently authenticated user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["DiscordIntegration"], + "summary": "Update a discord integration", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the discord integration", + "name": "discordID", + "in": "path", + "required": true + }, + { + "description": "Payload of discord integration to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.DiscordUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.DiscordResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a discord integration for a user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Webhooks"], + "summary": "Delete discord integration", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the discord integration", + "name": "discordID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/discord/event": { + "post": { + "description": "Publish a discord event to the registered listeners", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Discord"], + "summary": "Consume a discord event", + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/heartbeats": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the last time a phone number requested for outstanding messages. It will be sorted by timestamp in descending order.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Heartbeats"], + "summary": "Get heartbeats of an owner phone number", + "parameters": [ + { + "type": "string", + "default": "+18005550199", + "description": "the owner's phone number", + "name": "owner", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of heartbeats to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of heartbeats to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.HeartbeatsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Store the heartbeat to make notify that a phone number is still active", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Heartbeats"], + "summary": "Register heartbeat of an owner phone number", + "parameters": [ + { + "description": "Payload of the heartbeat request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.HeartbeatStore" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.HeartbeatResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/integration/3cx/messages": { + "post": { + "description": "Sends an SMS message from the 3CX platform", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["3CXIntegration"], + "summary": "Sends a 3CX SMS message", + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/message-threads": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["MessageThreads"], + "summary": "Get message threads for a phone number", + "parameters": [ + { + "type": "string", + "default": "+18005550199", + "description": "owner phone number", + "name": "owner", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter message threads containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageThreadsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/message-threads/{messageThreadID}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the details of a message thread", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["MessageThreads"], + "summary": "Update a message thread", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message thread", + "name": "messageThreadID", + "in": "path", + "required": true + }, + { + "description": "Payload of message thread details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageThreadUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a message thread from the database and also deletes all the messages in the thread.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["MessageThreads"], + "summary": "Delete a message thread from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message thread", + "name": "messageThreadID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/messages": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get list of messages which are sent between 2 phone numbers. It will be sorted by timestamp in descending order.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Get messages which are sent between 2 phone numbers", + "parameters": [ + { + "type": "string", + "default": "+18005550199", + "description": "the owner's phone number", + "name": "owner", + "in": "query", + "required": true + }, + { + "type": "string", + "default": "+18005550100", + "description": "the contact's phone number", + "name": "contact", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter messages containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/messages/bulk-send": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add bulk SMS messages to be sent by the android phone", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Send bulk SMS messages", + "parameters": [ + { + "description": "Bulk send message request payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageBulkSend" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/responses.MessagesResponse" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/messages/calls/missed": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Register a missed call event on the mobile phone", + "parameters": [ + { + "description": "Payload of the missed call event.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageCallMissed" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/messages/outstanding": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get an outstanding message to be sent by an android phone", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Get an outstanding message", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703cb", + "description": "The ID of the message", + "name": "message_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/messages/receive": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a new message received from a mobile phone", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Receive a new SMS message from a mobile phone", + "parameters": [ + { + "description": "Received message request payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageReceive" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/messages/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This returns the list of all messages based on the filter criteria including missed calls", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Search all messages of a user", + "parameters": [ + { + "type": "string", + "description": "Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/", + "name": "token", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "+18005550199,+18005550100", + "description": "the owner's phone numbers", + "name": "owners", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter messages containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 200, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/messages/send": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a new SMS message to be sent by your Android phone", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Send an SMS message", + "parameters": [ + { + "description": "Send message request payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageSend" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/messages/{messageID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a message from the database by the message ID.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Get a message from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message", + "name": "messageID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a message from the database and removes the message content from the list of threads.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Delete a message from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message", + "name": "messageID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/messages/{messageID}/events": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Use this endpoint to send events for a message when it is failed, sent or delivered by the mobile phone.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Upsert an event for a message on the mobile phone", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message", + "name": "messageID", + "in": "path", + "required": true + }, + { + "description": "Payload of the event emitted.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageEvent" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/phone-api-keys": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get list phone API keys which a user has registered on the httpSMS application", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["PhoneAPIKeys"], + "summary": "Get the phone API keys of a user", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of phone api keys to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter phone api keys with name containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "description": "number of phone api keys to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneAPIKeysResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new phone API key which can be used to log in to the httpSMS app on your Android phone", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["PhoneAPIKeys"], + "summary": "Store phone API key", + "parameters": [ + { + "description": "Payload of new phone API key.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.PhoneAPIKeyStoreRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneAPIKeyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/phone-api-keys/{phoneAPIKeyID}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a phone API Key from the database and cannot be used for authentication anymore.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["PhoneAPIKeys"], + "summary": "Delete a phone API key from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the phone API key", + "name": "phoneAPIKeyID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/phone-api-keys/{phoneAPIKeyID}/phones/{phoneID}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "You will need to login again to the httpSMS app on your Android phone with a new phone API key.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["PhoneAPIKeys"], + "summary": "Remove the association of a phone from the phone API key.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the phone API key", + "name": "phoneAPIKeyID", + "in": "path", + "required": true + }, + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the phone", + "name": "phoneID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/phones": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get list of phones which a user has registered on the http sms application", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Phones"], + "summary": "Get phones of a user", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of heartbeats to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter phones containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of phones to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhonesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Phones"], + "summary": "Upsert Phone", + "parameters": [ + { + "description": "Payload of new phone number.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.PhoneUpsert" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/phones/fcm-token": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Phones"], + "summary": "Upserts the FCM token of a phone", + "parameters": [ + { + "description": "Payload of new FCM token.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.PhoneFCMToken" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/phones/{phoneID}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a phone that has been sored in the database", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Phones"], + "summary": "Delete Phone", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the phone", + "name": "phoneID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/send-schedules": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "List all send schedules owned by the authenticated user.", + "produces": ["application/json"], + "tags": ["Send Schedules"], + "summary": "List send schedules", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.MessageSendSchedule" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create a new send schedule for the authenticated user.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Send Schedules"], + "summary": "Create send schedule", + "parameters": [ + { + "description": "Payload of new send schedule.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageSendScheduleStore" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.MessageSendScheduleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "402": { + "description": "Payment Required", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/send-schedules/{scheduleID}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a send schedule owned by the authenticated user.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Send Schedules"], + "summary": "Update send schedule", + "parameters": [ + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleID", + "in": "path", + "required": true + }, + { + "description": "Payload of updated send schedule.", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.MessageSendScheduleStore" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageSendScheduleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a send schedule owned by the authenticated user.", + "produces": ["application/json"], + "tags": ["Send Schedules"], + "summary": "Delete send schedule", + "parameters": [ + { + "type": "string", + "description": "Schedule ID", + "name": "scheduleID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/me": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get details of the currently authenticated user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the details of the currently authenticated user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Update a user", + "parameters": [ + { + "description": "Payload of user details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UserUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.PhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes the currently authenticated user together with all their data.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Delete a user", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/subscription": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Cancel the subscription of the authenticated user.", + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Cancel the user's subscription", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/subscription-update-url": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Fetches the subscription URL of the authenticated user.", + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Currently authenticated user subscription update URL", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.OkString" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/subscription/invoices/{subscriptionInvoiceID}": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.", + "consumes": ["application/json"], + "produces": ["application/pdf"], + "tags": ["Users"], + "summary": "Generate a subscription payment invoice", + "parameters": [ + { + "description": "Generate subscription payment invoice parameters", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UserPaymentInvoice" + } + }, + { + "type": "string", + "description": "ID of the subscription invoice to generate the PDF for", + "name": "subscriptionInvoiceID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/subscription/payments": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get the last 10 subscription payments.", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/{userID}/api-keys": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Rotate the user's API key in case the current API Key is compromised", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Rotate the user's API Key", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the user to update", + "name": "userID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/{userID}/notifications": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update the email notification settings for a user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Update notification settings", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the user to update", + "name": "userID", + "in": "path", + "required": true + }, + { + "description": "User notification details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UserNotificationUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}": { + "get": { + "description": "Download an MMS attachment by its path components", + "produces": ["application/octet-stream"], + "tags": ["Attachments"], + "summary": "Download a message attachment", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Message ID", + "name": "messageID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Attachment index", + "name": "attachmentIndex", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Filename with extension", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/webhooks": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get the webhooks of a user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Webhooks"], + "summary": "Get webhooks of a user", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of webhooks to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter webhooks containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 20, + "minimum": 1, + "type": "integer", + "description": "number of webhooks to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.WebhooksResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Store a webhook for the authenticated user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Webhooks"], + "summary": "Store a webhook", + "parameters": [ + { + "description": "Payload of the webhook request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.WebhookStore" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.WebhookResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/webhooks/{webhookID}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a webhook for the currently authenticated user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Webhooks"], + "summary": "Update a webhook", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the webhook", + "name": "webhookID", + "in": "path", + "required": true + }, + { + "description": "Payload of webhook details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.WebhookUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.WebhookResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a webhook for a user", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Webhooks"], + "summary": "Delete webhook", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the webhook", + "name": "webhookID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + } + }, + "definitions": { + "entities.BillingUsage": { + "type": "object", + "required": [ + "created_at", + "end_timestamp", + "id", + "received_messages", + "sent_messages", + "start_timestamp", + "total_cost", + "updated_at", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "end_timestamp": { + "type": "string", + "example": "2022-01-31T23:59:59+00:00" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "received_messages": { + "type": "integer", + "example": 465 + }, + "sent_messages": { + "type": "integer", + "example": 321 + }, + "start_timestamp": { + "type": "string", + "example": "2022-01-01T00:00:00+00:00" + }, + "total_cost": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } + }, + "entities.Discord": { + "type": "object", + "required": [ + "created_at", + "id", + "incoming_channel_id", + "name", + "server_id", + "updated_at", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "incoming_channel_id": { + "type": "string", + "example": "1095780203256627291" + }, + "name": { + "type": "string", + "example": "Game Server" + }, + "server_id": { + "type": "string", + "example": "1095778291488653372" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } + }, + "entities.Heartbeat": { + "type": "object", + "required": [ + "charging", + "id", + "owner", + "timestamp", + "user_id", + "version" + ], + "properties": { + "charging": { + "type": "boolean", + "example": true + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "owner": { + "type": "string", + "example": "+18005550199" + }, + "timestamp": { + "type": "string", + "example": "2022-06-05T14:26:01.520828+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + }, + "version": { + "type": "string", + "example": "344c10f" + } + } + }, + "entities.Message": { + "type": "object", + "required": [ + "attachments", + "contact", + "content", + "created_at", + "encrypted", + "id", + "max_send_attempts", + "order_timestamp", + "owner", + "request_received_at", + "send_attempt_count", + "sim", + "status", + "type", + "updated_at", + "user_id" + ], + "properties": { + "attachments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://example.com/image.jpg", + "https://example.com/video.mp4" + ] + }, + "contact": { + "type": "string", + "example": "+18005550100" + }, + "content": { + "type": "string", + "example": "This is a sample text message" + }, + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "delivered_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "encrypted": { + "type": "boolean", + "example": false + }, + "expired_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "failed_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "failure_reason": { + "type": "string", + "example": "UNKNOWN" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "last_attempted_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "max_send_attempts": { + "type": "integer", + "example": 1 + }, + "order_timestamp": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "owner": { + "type": "string", + "example": "+18005550199" + }, + "received_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "request_id": { + "type": "string", + "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" + }, + "request_received_at": { + "type": "string", + "example": "2022-06-05T14:26:01.520828+03:00" + }, + "scheduled_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "scheduled_send_time": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "send_attempt_count": { + "type": "integer", + "example": 0 + }, + "send_time": { + "description": "SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message", + "type": "integer", + "example": 133414 + }, + "sent_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "sim": { + "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], + "example": "DEFAULT" + }, + "status": { + "type": "string", + "example": "pending" + }, + "type": { + "type": "string", + "example": "mobile-terminated" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } + }, + "entities.MessageSendSchedule": { + "type": "object", + "required": [ + "created_at", + "id", + "is_active", + "name", + "timezone", + "updated_at", + "user_id", + "windows" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "is_active": { + "type": "boolean", + "example": true + }, + "name": { + "type": "string", + "example": "Business Hours" + }, + "timezone": { + "type": "string", + "example": "Europe/Tallinn" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + }, + "windows": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.MessageSendScheduleWindow" + } + } + } + }, + "entities.MessageSendScheduleWindow": { + "type": "object", + "required": ["day_of_week", "end_minute", "start_minute"], + "properties": { + "day_of_week": { + "type": "integer", + "example": 1 + }, + "end_minute": { + "type": "integer", + "example": 1020 + }, + "start_minute": { + "type": "integer", + "example": 540 + } + } + }, + "entities.MessageThread": { + "type": "object", + "required": [ + "color", + "contact", + "created_at", + "id", + "is_archived", + "last_message_content", + "last_message_id", + "order_timestamp", + "owner", + "status", + "updated_at", + "user_id" + ], + "properties": { + "color": { + "type": "string", + "example": "indigo" + }, "contact": { - "name": "support@httpsms.com", - "email": "support@httpsms.com" + "type": "string", + "example": "+18005550100" + }, + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" }, - "license": { - "name": "AGPL-3.0", - "url": "https://raw.githubusercontent.com/NdoleStudio/http-sms-manager/main/LICENSE" + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703ca" }, - "version": "1.0" + "is_archived": { + "type": "boolean", + "example": false + }, + "last_message_content": { + "type": "string", + "example": "This is a sample message content" + }, + "last_message_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703ca" + }, + "order_timestamp": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "owner": { + "type": "string", + "example": "+18005550199" + }, + "status": { + "type": "string", + "example": "PENDING" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } }, - "host": "api.httpsms.com", - "basePath": "/v1", - "paths": { - "/billing/usage": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get the summary of sent and received messages for a user in the current month", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Billing" - ], - "summary": "Get Billing Usage.", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.BillingUsageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "entities.Phone": { + "type": "object", + "required": [ + "created_at", + "id", + "max_send_attempts", + "message_expiration_seconds", + "messages_per_minute", + "phone_number", + "schedule_id", + "sim", + "updated_at", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" }, - "/billing/usage-history": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get billing usage records of sent and received messages for a user in the past. It will be sorted by timestamp in descending order.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Billing" - ], - "summary": "Get billing usage history.", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of heartbeats to skip", - "name": "skip", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "description": "number of heartbeats to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.BillingUsagesResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "fcm_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, - "/bulk-messages": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "BulkSMS" - ], - "summary": "Store bulk SMS file", - "parameters": [ - { - "type": "file", - "description": "The Excel or CSV file containing the messages to be sent.", - "name": "document", - "in": "formData", - "required": true - } - ], - "responses": { - "202": { - "description": "Accepted", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" }, - "/discord-integrations": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get the discord integrations of a user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "DiscordIntegration" - ], - "summary": "Get discord integrations of a user", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of discord integrations to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter discord integrations containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of discord integrations to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.DiscordsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Store a discord integration for the authenticated user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "DiscordIntegration" - ], - "summary": "Store discord integration", - "parameters": [ - { - "description": "Payload of the discord integration request", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.DiscordStore" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/responses.DiscordResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "max_send_attempts": { + "description": "MaxSendAttempts determines how many times to retry sending an SMS message", + "type": "integer", + "example": 2 }, - "/discord-integrations/{discordID}": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Update a discord integration for the currently authenticated user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "DiscordIntegration" - ], - "summary": "Update a discord integration", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the discord integration", - "name": "discordID", - "in": "path", - "required": true - }, - { - "description": "Payload of discord integration to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.DiscordUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.DiscordResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a discord integration for a user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Webhooks" - ], - "summary": "Delete discord integration", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the discord integration", - "name": "discordID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "message_expiration_seconds": { + "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.", + "type": "integer" }, - "/discord/event": { - "post": { - "description": "Publish a discord event to the registered listeners", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Discord" - ], - "summary": "Consume a discord event", - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "messages_per_minute": { + "type": "integer", + "example": 1 }, - "/heartbeats": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get the last time a phone number requested for outstanding messages. It will be sorted by timestamp in descending order.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Heartbeats" - ], - "summary": "Get heartbeats of an owner phone number", - "parameters": [ - { - "type": "string", - "default": "+18005550199", - "description": "the owner's phone number", - "name": "owner", - "in": "query", - "required": true - }, - { - "minimum": 0, - "type": "integer", - "description": "number of heartbeats to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of heartbeats to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.HeartbeatsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Store the heartbeat to make notify that a phone number is still active", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Heartbeats" - ], - "summary": "Register heartbeat of an owner phone number", - "parameters": [ - { - "description": "Payload of the heartbeat request", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.HeartbeatStore" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.HeartbeatResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "missed_call_auto_reply": { + "type": "string", + "example": "This phone cannot receive calls. Please send an SMS instead." }, - "/integration/3cx/messages": { - "post": { - "description": "Sends an SMS message from the 3CX platform", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "3CXIntegration" - ], - "summary": "Sends a 3CX SMS message", - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "phone_number": { + "type": "string", + "example": "+18005550199" }, - "/message-threads": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "MessageThreads" - ], - "summary": "Get message threads for a phone number", - "parameters": [ - { - "type": "string", - "default": "+18005550199", - "description": "owner phone number", - "name": "owner", - "in": "query", - "required": true - }, - { - "minimum": 0, - "type": "integer", - "description": "number of messages to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter message threads containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of messages to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageThreadsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "schedule_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" }, - "/message-threads/{messageThreadID}": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the details of a message thread", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "MessageThreads" - ], - "summary": "Update a message thread", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the message thread", - "name": "messageThreadID", - "in": "path", - "required": true - }, - { - "description": "Payload of message thread details to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageThreadUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a message thread from the database and also deletes all the messages in the thread.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "MessageThreads" - ], - "summary": "Delete a message thread from the database.", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the message thread", - "name": "messageThreadID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "sim": { + "$ref": "#/definitions/entities.SIM" }, - "/messages": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get list of messages which are sent between 2 phone numbers. It will be sorted by timestamp in descending order.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Get messages which are sent between 2 phone numbers", - "parameters": [ - { - "type": "string", - "default": "+18005550199", - "description": "the owner's phone number", - "name": "owner", - "in": "query", - "required": true - }, - { - "type": "string", - "default": "+18005550100", - "description": "the contact's phone number", - "name": "contact", - "in": "query", - "required": true - }, - { - "minimum": 0, - "type": "integer", - "description": "number of messages to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter messages containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of messages to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessagesResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" }, - "/messages/bulk-send": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Add bulk SMS messages to be sent by the android phone", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Send bulk SMS messages", - "parameters": [ - { - "description": "Bulk send message request payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageBulkSend" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/responses.MessagesResponse" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } + }, + "entities.PhoneAPIKey": { + "type": "object", + "required": [ + "api_key", + "created_at", + "id", + "name", + "phone_ids", + "phone_numbers", + "updated_at", + "user_email", + "user_id" + ], + "properties": { + "api_key": { + "type": "string", + "example": "pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx" }, - "/messages/calls/missed": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Register a missed call event on the mobile phone", - "parameters": [ - { - "description": "Payload of the missed call event.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageCallMissed" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" }, - "/messages/outstanding": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get an outstanding message to be sent by an android phone", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Get an outstanding message", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703cb", - "description": "The ID of the message", - "name": "message_id", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" }, - "/messages/receive": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Add a new message received from a mobile phone", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Receive a new SMS message from a mobile phone", - "parameters": [ - { - "description": "Received message request payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageReceive" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "name": { + "type": "string", + "example": "Business Phone Key" }, - "/messages/search": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "This returns the list of all messages based on the filter criteria including missed calls", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Search all messages of a user", - "parameters": [ - { - "type": "string", - "description": "Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/", - "name": "token", - "in": "header", - "required": true - }, - { - "type": "string", - "default": "+18005550199,+18005550100", - "description": "the owner's phone numbers", - "name": "owners", - "in": "query", - "required": true - }, - { - "minimum": 0, - "type": "integer", - "description": "number of messages to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter messages containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 200, - "minimum": 1, - "type": "integer", - "description": "number of messages to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessagesResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "phone_ids": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "32343a19-da5e-4b1b-a767-3298a73703cb", + "32343a19-da5e-4b1b-a767-3298a73703cc" + ] }, - "/messages/send": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Add a new SMS message to be sent by your Android phone", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Send an SMS message", - "parameters": [ - { - "description": "Send message request payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageSend" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["+18005550199", "+18005550100"] }, - "/messages/{messageID}": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get a message from the database by the message ID.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Get a message from the database.", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the message", - "name": "messageID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a message from the database and removes the message content from the list of threads.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Delete a message from the database.", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the message", - "name": "messageID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" }, - "/messages/{messageID}/events": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Use this endpoint to send events for a message when it is failed, sent or delivered by the mobile phone.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Messages" - ], - "summary": "Upsert an event for a message on the mobile phone", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the message", - "name": "messageID", - "in": "path", - "required": true - }, - { - "description": "Payload of the event emitted.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.MessageEvent" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.MessageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } + "user_email": { + "type": "string", + "example": "user@gmail.com" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } + }, + "entities.SIM": { + "type": "string", + "enum": ["SIM1", "SIM2"], + "x-enum-varnames": ["SIM1", "SIM2"] + }, + "entities.SubscriptionName": { + "type": "string", + "enum": [ + "free", + "pro-monthly", + "pro-yearly", + "ultra-monthly", + "ultra-yearly", + "pro-lifetime", + "20k-monthly", + "100k-monthly", + "50k-monthly", + "200k-monthly", + "20k-yearly" + ], + "x-enum-varnames": [ + "SubscriptionNameFree", + "SubscriptionNameProMonthly", + "SubscriptionNameProYearly", + "SubscriptionNameUltraMonthly", + "SubscriptionNameUltraYearly", + "SubscriptionNameProLifetime", + "SubscriptionName20KMonthly", + "SubscriptionName100KMonthly", + "SubscriptionName50KMonthly", + "SubscriptionName200KMonthly", + "SubscriptionName20KYearly" + ] + }, + "entities.User": { + "type": "object", + "required": [ + "api_key", + "created_at", + "email", + "id", + "notification_heartbeat_enabled", + "notification_message_status_enabled", + "notification_newsletter_enabled", + "notification_webhook_enabled", + "subscription_id", + "subscription_name", + "timezone", + "updated_at" + ], + "properties": { + "active_phone_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "api_key": { + "type": "string", + "example": "x-api-key" + }, + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "email": { + "type": "string", + "example": "name@email.com" + }, + "id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + }, + "notification_heartbeat_enabled": { + "type": "boolean", + "example": true + }, + "notification_message_status_enabled": { + "type": "boolean", + "example": true + }, + "notification_newsletter_enabled": { + "type": "boolean", + "example": true + }, + "notification_webhook_enabled": { + "type": "boolean", + "example": true + }, + "subscription_ends_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "subscription_id": { + "type": "string", + "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" + }, + "subscription_name": { + "allOf": [ + { + "$ref": "#/definitions/entities.SubscriptionName" } + ], + "example": "free" + }, + "subscription_renews_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "subscription_status": { + "type": "string", + "example": "on_trial" + }, + "timezone": { + "type": "string", + "example": "Europe/Helsinki" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + } + } + }, + "entities.Webhook": { + "type": "object", + "required": [ + "created_at", + "events", + "id", + "phone_numbers", + "signing_key", + "updated_at", + "url", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "events": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["message.phone.received"] + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["+18005550199", "+18005550100"] + }, + "signing_key": { + "type": "string", + "example": "DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:10.303278+03:00" + }, + "url": { + "type": "string", + "example": "https://example.com" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } + }, + "requests.DiscordStore": { + "type": "object", + "required": ["incoming_channel_id", "name", "server_id"], + "properties": { + "incoming_channel_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "server_id": { + "type": "string" + } + } + }, + "requests.DiscordUpdate": { + "type": "object", + "required": ["incoming_channel_id", "name", "server_id"], + "properties": { + "incoming_channel_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "server_id": { + "type": "string" + } + } + }, + "requests.HeartbeatStore": { + "type": "object", + "required": ["charging", "phone_numbers"], + "properties": { + "charging": { + "type": "boolean" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "requests.MessageAttachment": { + "type": "object", + "required": ["content", "content_type", "name"], + "properties": { + "content": { + "description": "Content is the base64-encoded attachment data", + "type": "string", + "example": "base64data..." + }, + "content_type": { + "description": "ContentType is the MIME type of the attachment", + "type": "string", + "example": "image/jpeg" + }, + "name": { + "description": "Name is the original filename of the attachment", + "type": "string", + "example": "photo.jpg" + } + } + }, + "requests.MessageBulkSend": { + "type": "object", + "required": ["content", "from", "to"], + "properties": { + "attachments": { + "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS", + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "type": "string", + "example": "This is a sample text message" + }, + "encrypted": { + "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", + "type": "boolean", + "example": false + }, + "from": { + "type": "string", + "example": "+18005550199" + }, + "request_id": { + "description": "RequestID is an optional parameter used to track a request from the client's perspective", + "type": "string", + "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" + }, + "to": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["+18005550100", "+18005550100"] + } + } + }, + "requests.MessageCallMissed": { + "type": "object", + "required": ["from", "sim", "timestamp", "to"], + "properties": { + "from": { + "type": "string", + "example": "+18005550199" + }, + "sim": { + "type": "string", + "example": "SIM1" + }, + "timestamp": { + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + }, + "to": { + "type": "string", + "example": "+18005550100" + } + } + }, + "requests.MessageEvent": { + "type": "object", + "required": ["event_name", "reason", "timestamp"], + "properties": { + "event_name": { + "description": "EventName is the type of event\n* SENT: is emitted when a message is sent by the mobile phone\n* FAILED: is event is emitted when the message could not be sent by the mobile phone\n* DELIVERED: is event is emitted when a delivery report has been received by the mobile phone", + "type": "string", + "example": "SENT" + }, + "reason": { + "description": "Reason is the exact error message in case the event is an error", + "type": "string" + }, + "timestamp": { + "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible", + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" + } + } + }, + "requests.MessageReceive": { + "type": "object", + "required": ["content", "encrypted", "from", "sim", "timestamp", "to"], + "properties": { + "attachments": { + "description": "Attachments is the list of MMS attachments received with the message", + "type": "array", + "items": { + "$ref": "#/definitions/requests.MessageAttachment" + } + }, + "content": { + "type": "string", + "example": "This is a sample text message received on a phone" + }, + "encrypted": { + "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", + "type": "boolean", + "example": false + }, + "from": { + "type": "string", + "example": "+18005550199" }, - "/phone-api-keys": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get list phone API keys which a user has registered on the httpSMS application", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "PhoneAPIKeys" - ], - "summary": "Get the phone API keys of a user", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of phone api keys to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter phone api keys with name containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "description": "number of phone api keys to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneAPIKeysResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Creates a new phone API key which can be used to log in to the httpSMS app on your Android phone", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "PhoneAPIKeys" - ], - "summary": "Store phone API key", - "parameters": [ - { - "description": "Payload of new phone API key.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.PhoneAPIKeyStoreRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneAPIKeyResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } + "sim": { + "description": "SIM card that received the message", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" } + ], + "example": "SIM1" }, - "/phone-api-keys/{phoneAPIKeyID}": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a phone API Key from the database and cannot be used for authentication anymore.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "PhoneAPIKeys" - ], - "summary": "Delete a phone API key from the database.", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the phone API key", - "name": "phoneAPIKeyID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "timestamp": { + "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible", + "type": "string", + "example": "2022-06-05T14:26:09.527976+03:00" }, - "/phone-api-keys/{phoneAPIKeyID}/phones/{phoneID}": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "You will need to login again to the httpSMS app on your Android phone with a new phone API key.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "PhoneAPIKeys" - ], - "summary": "Remove the association of a phone from the phone API key.", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the phone API key", - "name": "phoneAPIKeyID", - "in": "path", - "required": true - }, - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the phone", - "name": "phoneID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "to": { + "type": "string", + "example": "+18005550100" + } + } + }, + "requests.MessageSend": { + "type": "object", + "required": ["content", "from", "to"], + "properties": { + "attachments": { + "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://example.com/image.jpg", + "https://example.com/video.mp4" + ] }, - "/phones": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get list of phones which a user has registered on the http sms application", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Phones" - ], - "summary": "Get phones of a user", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of heartbeats to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter phones containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of phones to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhonesResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Phones" - ], - "summary": "Upsert Phone", - "parameters": [ - { - "description": "Payload of new phone number.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.PhoneUpsert" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "content": { + "type": "string", + "example": "This is a sample text message" }, - "/phones/fcm-token": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Phones" - ], - "summary": "Upserts the FCM token of a phone", - "parameters": [ - { - "description": "Payload of new FCM token.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.PhoneFCMToken" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "encrypted": { + "description": "Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", + "type": "boolean", + "example": false }, - "/phones/{phoneID}": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a phone that has been sored in the database", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Phones" - ], - "summary": "Delete Phone", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the phone", - "name": "phoneID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "from": { + "type": "string", + "example": "+18005550199" }, - "/send-schedules": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "List all send schedules owned by the authenticated user.", - "produces": [ - "application/json" - ], - "tags": [ - "Send Schedules" - ], - "summary": "List send schedules", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.MessageSendSchedule" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Create a new send schedule for the authenticated user.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Send Schedules" - ], - "summary": "Create send schedule", - "parameters": [ - { - "description": "Payload of new send schedule.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.SendScheduleStore" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/responses.SendScheduleResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "request_id": { + "description": "RequestID is an optional parameter used to track a request from the client's perspective", + "type": "string", + "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" }, - "/send-schedules/{scheduleID}": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Update a send schedule owned by the authenticated user.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Send Schedules" - ], - "summary": "Update send schedule", - "parameters": [ - { - "type": "string", - "description": "Schedule ID", - "name": "scheduleID", - "in": "path", - "required": true - }, - { - "description": "Payload of updated send schedule.", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.SendScheduleStore" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.SendScheduleResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a send schedule owned by the authenticated user.", - "produces": [ - "application/json" - ], - "tags": [ - "Send Schedules" - ], - "summary": "Delete send schedule", - "parameters": [ - { - "type": "string", - "description": "Schedule ID", - "name": "scheduleID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "send_at": { + "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.", + "type": "string", + "example": "2025-12-19T16:39:57-08:00" }, - "/users/me": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get details of the currently authenticated user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Get current user", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.UserResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the details of the currently authenticated user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Update a user", - "parameters": [ - { - "description": "Payload of user details to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.UserUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.PhoneResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Deletes the currently authenticated user together with all their data.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Delete a user", - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "to": { + "type": "string", + "example": "+18005550100" + } + } + }, + "requests.MessageSendScheduleStore": { + "type": "object", + "required": ["is_active", "name", "timezone", "windows"], + "properties": { + "is_active": { + "type": "boolean" }, - "/users/subscription": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Cancel the subscription of the authenticated user.", - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Cancel the user's subscription", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "name": { + "type": "string" }, - "/users/subscription-update-url": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Fetches the subscription URL of the authenticated user.", - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Currently authenticated user subscription update URL", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.OkString" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "timezone": { + "type": "string" }, - "/users/subscription/invoices/{subscriptionInvoiceID}": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/pdf" - ], - "tags": [ - "Users" - ], - "summary": "Generate a subscription payment invoice", - "parameters": [ - { - "description": "Generate subscription payment invoice parameters", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.UserPaymentInvoice" - } - }, - { - "type": "string", - "description": "ID of the subscription invoice to generate the PDF for", - "name": "subscriptionInvoiceID", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "windows": { + "type": "array", + "items": { + "$ref": "#/definitions/requests.MessageSendScheduleWindow" + } + } + } + }, + "requests.MessageSendScheduleWindow": { + "type": "object", + "required": ["day_of_week", "end_minute", "start_minute"], + "properties": { + "day_of_week": { + "type": "integer" }, - "/users/subscription/payments": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Get the last 10 subscription payments.", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "end_minute": { + "type": "integer" }, - "/users/{userID}/api-keys": { - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Rotate the user's API key in case the current API Key is compromised", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Rotate the user's API Key", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the user to update", - "name": "userID", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.UserResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "start_minute": { + "type": "integer" + } + } + }, + "requests.MessageThreadUpdate": { + "type": "object", + "required": ["is_archived"], + "properties": { + "is_archived": { + "type": "boolean", + "example": true + } + } + }, + "requests.PhoneAPIKeyStoreRequest": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "example": "My Phone API Key" + } + } + }, + "requests.PhoneFCMToken": { + "type": "object", + "required": ["fcm_token", "phone_number", "sim"], + "properties": { + "fcm_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, - "/users/{userID}/notifications": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Update the email notification settings for a user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Update notification settings", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the user to update", - "name": "userID", - "in": "path", - "required": true - }, - { - "description": "User notification details to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.UserNotificationUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.UserResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "phone_number": { + "type": "string", + "example": "[+18005550199]" }, - "/v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}": { - "get": { - "description": "Download an MMS attachment by its path components", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "Attachments" - ], - "summary": "Download a message attachment", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "userID", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Message ID", - "name": "messageID", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Attachment index", - "name": "attachmentIndex", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Filename with extension", - "name": "filename", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/responses.NotFound" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "sim": { + "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", + "type": "string", + "example": "SIM1" + } + } + }, + "requests.PhoneUpsert": { + "type": "object", + "required": [ + "fcm_token", + "max_send_attempts", + "message_expiration_seconds", + "messages_per_minute", + "missed_call_auto_reply", + "phone_number", + "schedule_id", + "sim" + ], + "properties": { + "fcm_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, - "/webhooks": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Get the webhooks of a user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Webhooks" - ], - "summary": "Get webhooks of a user", - "parameters": [ - { - "minimum": 0, - "type": "integer", - "description": "number of webhooks to skip", - "name": "skip", - "in": "query" - }, - { - "type": "string", - "description": "filter webhooks containing query", - "name": "query", - "in": "query" - }, - { - "maximum": 20, - "minimum": 1, - "type": "integer", - "description": "number of webhooks to return", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.WebhooksResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Store a webhook for the authenticated user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Webhooks" - ], - "summary": "Store a webhook", - "parameters": [ - { - "description": "Payload of the webhook request", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.WebhookStore" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.WebhookResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "max_send_attempts": { + "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.", + "type": "integer", + "example": 2 }, - "/webhooks/{webhookID}": { - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Update a webhook for the currently authenticated user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Webhooks" - ], - "summary": "Update a webhook", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the webhook", - "name": "webhookID", - "in": "path", - "required": true - }, - { - "description": "Payload of webhook details to update", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.WebhookUpdate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/responses.WebhookResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete a webhook for a user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Webhooks" - ], - "summary": "Delete webhook", - "parameters": [ - { - "type": "string", - "default": "32343a19-da5e-4b1b-a767-3298a73703ca", - "description": "ID of the webhook", - "name": "webhookID", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } + "message_expiration_seconds": { + "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.", + "type": "integer", + "example": 12345 + }, + "messages_per_minute": { + "type": "integer", + "example": 1 + }, + "missed_call_auto_reply": { + "type": "string", + "example": "e.g. This phone cannot receive calls. Please send an SMS instead." + }, + "phone_number": { + "type": "string", + "example": "+18005550199" + }, + "schedule_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "sim": { + "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", + "type": "string", + "example": "SIM1" } + } }, - "definitions": { - "entities.BillingUsage": { - "type": "object", - "required": [ - "created_at", - "end_timestamp", - "id", - "received_messages", - "sent_messages", - "start_timestamp", - "total_cost", - "updated_at", - "user_id" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "end_timestamp": { - "type": "string", - "example": "2022-01-31T23:59:59+00:00" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "received_messages": { - "type": "integer", - "example": 465 - }, - "sent_messages": { - "type": "integer", - "example": 321 - }, - "start_timestamp": { - "type": "string", - "example": "2022-01-01T00:00:00+00:00" - }, - "total_cost": { - "type": "integer", - "example": 0 - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } + "requests.UserNotificationUpdate": { + "type": "object", + "required": [ + "heartbeat_enabled", + "message_status_enabled", + "newsletter_enabled", + "webhook_enabled" + ], + "properties": { + "heartbeat_enabled": { + "type": "boolean", + "example": true }, - "entities.Discord": { - "type": "object", - "required": [ - "created_at", - "id", - "incoming_channel_id", - "name", - "server_id", - "updated_at", - "user_id" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "incoming_channel_id": { - "type": "string", - "example": "1095780203256627291" - }, - "name": { - "type": "string", - "example": "Game Server" - }, - "server_id": { - "type": "string", - "example": "1095778291488653372" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } + "message_status_enabled": { + "type": "boolean", + "example": true }, - "entities.Heartbeat": { - "type": "object", - "required": [ - "charging", - "id", - "owner", - "timestamp", - "user_id", - "version" - ], - "properties": { - "charging": { - "type": "boolean", - "example": true - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "owner": { - "type": "string", - "example": "+18005550199" - }, - "timestamp": { - "type": "string", - "example": "2022-06-05T14:26:01.520828+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - }, - "version": { - "type": "string", - "example": "344c10f" - } - } + "newsletter_enabled": { + "type": "boolean", + "example": true }, - "entities.Message": { - "type": "object", - "required": [ - "attachments", - "contact", - "content", - "created_at", - "encrypted", - "id", - "max_send_attempts", - "order_timestamp", - "owner", - "request_received_at", - "send_attempt_count", - "sim", - "status", - "type", - "updated_at", - "user_id" - ], - "properties": { - "attachments": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "https://example.com/image.jpg", - "https://example.com/video.mp4" - ] - }, - "contact": { - "type": "string", - "example": "+18005550100" - }, - "content": { - "type": "string", - "example": "This is a sample text message" - }, - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "delivered_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "encrypted": { - "type": "boolean", - "example": false - }, - "expired_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "failed_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "failure_reason": { - "type": "string", - "example": "UNKNOWN" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "last_attempted_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "max_send_attempts": { - "type": "integer", - "example": 1 - }, - "order_timestamp": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "owner": { - "type": "string", - "example": "+18005550199" - }, - "received_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "request_id": { - "type": "string", - "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" - }, - "request_received_at": { - "type": "string", - "example": "2022-06-05T14:26:01.520828+03:00" - }, - "scheduled_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "scheduled_send_time": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "send_attempt_count": { - "type": "integer", - "example": 0 - }, - "send_time": { - "description": "SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message", - "type": "integer", - "example": 133414 - }, - "sent_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "sim": { - "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card", - "allOf": [ - { - "$ref": "#/definitions/entities.SIM" - } - ], - "example": "DEFAULT" - }, - "status": { - "type": "string", - "example": "pending" - }, - "type": { - "type": "string", - "example": "mobile-terminated" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } + "webhook_enabled": { + "type": "boolean", + "example": true + } + } + }, + "requests.UserPaymentInvoice": { + "type": "object", + "required": [ + "address", + "city", + "country", + "name", + "notes", + "state", + "zip_code" + ], + "properties": { + "address": { + "type": "string", + "example": "221B Baker Street, London" }, - "entities.MessageSendSchedule": { - "type": "object", - "required": [ - "created_at", - "id", - "is_active", - "name", - "timezone", - "updated_at", - "user_id", - "windows" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "is_active": { - "type": "boolean", - "example": true - }, - "name": { - "type": "string", - "example": "Business Hours" - }, - "timezone": { - "type": "string", - "example": "Europe/Tallinn" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - }, - "windows": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.MessageSendScheduleWindow" - } - } - } + "city": { + "type": "string", + "example": "Los Angeles" }, - "entities.MessageSendScheduleWindow": { - "type": "object", - "required": [ - "day_of_week", - "end_minute", - "start_minute" - ], - "properties": { - "day_of_week": { - "type": "integer", - "example": 1 - }, - "end_minute": { - "type": "integer", - "example": 1020 - }, - "start_minute": { - "type": "integer", - "example": 540 - } - } + "country": { + "type": "string", + "example": "US" }, - "entities.MessageThread": { - "type": "object", - "required": [ - "color", - "contact", - "created_at", - "id", - "is_archived", - "last_message_content", - "last_message_id", - "order_timestamp", - "owner", - "status", - "updated_at", - "user_id" - ], - "properties": { - "color": { - "type": "string", - "example": "indigo" - }, - "contact": { - "type": "string", - "example": "+18005550100" - }, - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703ca" - }, - "is_archived": { - "type": "boolean", - "example": false - }, - "last_message_content": { - "type": "string", - "example": "This is a sample message content" - }, - "last_message_id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703ca" - }, - "order_timestamp": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "owner": { - "type": "string", - "example": "+18005550199" - }, - "status": { - "type": "string", - "example": "PENDING" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } + "name": { + "type": "string", + "example": "Acme Corp" }, - "entities.Phone": { - "type": "object", - "required": [ - "created_at", - "id", - "max_send_attempts", - "message_expiration_seconds", - "messages_per_minute", - "phone_number", - "schedule_id", - "sim", - "updated_at", - "user_id" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "fcm_token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "max_send_attempts": { - "description": "MaxSendAttempts determines how many times to retry sending an SMS message", - "type": "integer", - "example": 2 - }, - "message_expiration_seconds": { - "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.", - "type": "integer" - }, - "messages_per_minute": { - "type": "integer", - "example": 1 - }, - "missed_call_auto_reply": { - "type": "string", - "example": "This phone cannot receive calls. Please send an SMS instead." - }, - "phone_number": { - "type": "string", - "example": "+18005550199" - }, - "schedule_id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "sim": { - "$ref": "#/definitions/entities.SIM" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } + "notes": { + "type": "string", + "example": "Thank you for your business!" }, - "entities.PhoneAPIKey": { - "type": "object", - "required": [ - "api_key", - "created_at", - "id", - "name", - "phone_ids", - "phone_numbers", - "updated_at", - "user_email", - "user_id" - ], - "properties": { - "api_key": { - "type": "string", - "example": "pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx" - }, - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "name": { - "type": "string", - "example": "Business Phone Key" - }, - "phone_ids": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "32343a19-da5e-4b1b-a767-3298a73703cb", - "32343a19-da5e-4b1b-a767-3298a73703cc" - ] - }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "+18005550199", - "+18005550100" - ] - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "user_email": { - "type": "string", - "example": "user@gmail.com" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } + "state": { + "type": "string", + "example": "CA" }, - "entities.SIM": { - "type": "string", - "enum": [ - "SIM1", - "SIM2" - ], - "x-enum-varnames": [ - "SIM1", - "SIM2" - ] - }, - "entities.SubscriptionName": { - "type": "string", - "enum": [ - "free", - "pro-monthly", - "pro-yearly", - "ultra-monthly", - "ultra-yearly", - "pro-lifetime", - "20k-monthly", - "100k-monthly", - "50k-monthly", - "200k-monthly", - "20k-yearly" - ], - "x-enum-varnames": [ - "SubscriptionNameFree", - "SubscriptionNameProMonthly", - "SubscriptionNameProYearly", - "SubscriptionNameUltraMonthly", - "SubscriptionNameUltraYearly", - "SubscriptionNameProLifetime", - "SubscriptionName20KMonthly", - "SubscriptionName100KMonthly", - "SubscriptionName50KMonthly", - "SubscriptionName200KMonthly", - "SubscriptionName20KYearly" - ] - }, - "entities.User": { - "type": "object", - "required": [ - "api_key", - "created_at", - "email", - "id", - "notification_heartbeat_enabled", - "notification_message_status_enabled", - "notification_newsletter_enabled", - "notification_webhook_enabled", - "subscription_id", - "subscription_name", - "timezone", - "updated_at" - ], - "properties": { - "active_phone_id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "api_key": { - "type": "string", - "example": "x-api-key" - }, - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "email": { - "type": "string", - "example": "name@email.com" - }, - "id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - }, - "notification_heartbeat_enabled": { - "type": "boolean", - "example": true - }, - "notification_message_status_enabled": { - "type": "boolean", - "example": true - }, - "notification_newsletter_enabled": { - "type": "boolean", - "example": true - }, - "notification_webhook_enabled": { - "type": "boolean", - "example": true - }, - "subscription_ends_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "subscription_id": { - "type": "string", - "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" - }, - "subscription_name": { - "allOf": [ - { - "$ref": "#/definitions/entities.SubscriptionName" - } - ], - "example": "free" - }, - "subscription_renews_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "subscription_status": { - "type": "string", - "example": "on_trial" - }, - "timezone": { - "type": "string", - "example": "Europe/Helsinki" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - } - } + "zip_code": { + "type": "string", + "example": "9800" + } + } + }, + "requests.UserUpdate": { + "type": "object", + "required": ["active_phone_id", "timezone"], + "properties": { + "active_phone_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" }, - "entities.Webhook": { - "type": "object", - "required": [ - "created_at", - "events", - "id", - "phone_numbers", - "signing_key", - "updated_at", - "url", - "user_id" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2022-06-05T14:26:02.302718+03:00" - }, - "events": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "message.phone.received" - ] - }, - "id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "+18005550199", - "+18005550100" - ] - }, - "signing_key": { - "type": "string", - "example": "DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" - }, - "updated_at": { - "type": "string", - "example": "2022-06-05T14:26:10.303278+03:00" - }, - "url": { - "type": "string", - "example": "https://example.com" - }, - "user_id": { - "type": "string", - "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" - } - } + "timezone": { + "type": "string", + "example": "Europe/Helsinki" + } + } + }, + "requests.WebhookStore": { + "type": "object", + "required": ["events", "phone_numbers", "signing_key", "url"], + "properties": { + "events": { + "type": "array", + "items": { + "type": "string" + } }, - "requests.DiscordStore": { - "type": "object", - "required": [ - "incoming_channel_id", - "name", - "server_id" - ], - "properties": { - "incoming_channel_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "server_id": { - "type": "string" - } - } + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["+18005550100", "+18005550100"] }, - "requests.DiscordUpdate": { - "type": "object", - "required": [ - "incoming_channel_id", - "name", - "server_id" - ], - "properties": { - "incoming_channel_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "server_id": { - "type": "string" - } - } + "signing_key": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "requests.WebhookUpdate": { + "type": "object", + "required": ["events", "phone_numbers", "signing_key", "url"], + "properties": { + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["+18005550100", "+18005550100"] + }, + "signing_key": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "responses.BadRequest": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "string", + "example": "The request body is not a valid JSON string" + }, + "message": { + "type": "string", + "example": "The request isn't properly formed" }, - "requests.HeartbeatStore": { - "type": "object", - "required": [ - "charging", - "phone_numbers" - ], - "properties": { - "charging": { - "type": "boolean" - }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - } - } - } + "status": { + "type": "string", + "example": "error" + } + } + }, + "responses.BillingUsageResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.BillingUsage" }, - "requests.MessageAttachment": { - "type": "object", - "required": [ - "content", - "content_type", - "name" - ], - "properties": { - "content": { - "description": "Content is the base64-encoded attachment data", - "type": "string", - "example": "base64data..." - }, - "content_type": { - "description": "ContentType is the MIME type of the attachment", - "type": "string", - "example": "image/jpeg" - }, - "name": { - "description": "Name is the original filename of the attachment", - "type": "string", - "example": "photo.jpg" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "requests.MessageBulkSend": { - "type": "object", - "required": [ - "content", - "from", - "to" - ], - "properties": { - "attachments": { - "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS", - "type": "array", - "items": { - "type": "string" - } - }, - "content": { - "type": "string", - "example": "This is a sample text message" - }, - "encrypted": { - "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", - "type": "boolean", - "example": false - }, - "from": { - "type": "string", - "example": "+18005550199" - }, - "request_id": { - "description": "RequestID is an optional parameter used to track a request from the client's perspective", - "type": "string", - "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" - }, - "to": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "+18005550100", - "+18005550100" - ] - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.BillingUsagesResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.BillingUsage" + } }, - "requests.MessageCallMissed": { - "type": "object", - "required": [ - "from", - "sim", - "timestamp", - "to" - ], - "properties": { - "from": { - "type": "string", - "example": "+18005550199" - }, - "sim": { - "type": "string", - "example": "SIM1" - }, - "timestamp": { - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "to": { - "type": "string", - "example": "+18005550100" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "requests.MessageEvent": { - "type": "object", - "required": [ - "event_name", - "reason", - "timestamp" - ], - "properties": { - "event_name": { - "description": "EventName is the type of event\n* SENT: is emitted when a message is sent by the mobile phone\n* FAILED: is event is emitted when the message could not be sent by the mobile phone\n* DELIVERED: is event is emitted when a delivery report has been received by the mobile phone", - "type": "string", - "example": "SENT" - }, - "reason": { - "description": "Reason is the exact error message in case the event is an error", - "type": "string" - }, - "timestamp": { - "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible", - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.DiscordResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.Discord" }, - "requests.MessageReceive": { - "type": "object", - "required": [ - "content", - "encrypted", - "from", - "sim", - "timestamp", - "to" - ], - "properties": { - "attachments": { - "description": "Attachments is the list of MMS attachments received with the message", - "type": "array", - "items": { - "$ref": "#/definitions/requests.MessageAttachment" - } - }, - "content": { - "type": "string", - "example": "This is a sample text message received on a phone" - }, - "encrypted": { - "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", - "type": "boolean", - "example": false - }, - "from": { - "type": "string", - "example": "+18005550199" - }, - "sim": { - "description": "SIM card that received the message", - "allOf": [ - { - "$ref": "#/definitions/entities.SIM" - } - ], - "example": "SIM1" - }, - "timestamp": { - "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible", - "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" - }, - "to": { - "type": "string", - "example": "+18005550100" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "requests.MessageSend": { - "type": "object", - "required": [ - "content", - "from", - "to" - ], - "properties": { - "attachments": { - "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS", - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "https://example.com/image.jpg", - "https://example.com/video.mp4" - ] - }, - "content": { - "type": "string", - "example": "This is a sample text message" - }, - "encrypted": { - "description": "Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app", - "type": "boolean", - "example": false - }, - "from": { - "type": "string", - "example": "+18005550199" - }, - "request_id": { - "description": "RequestID is an optional parameter used to track a request from the client's perspective", - "type": "string", - "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" - }, - "send_at": { - "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.", - "type": "string", - "example": "2025-12-19T16:39:57-08:00" - }, - "to": { - "type": "string", - "example": "+18005550100" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.DiscordsResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Discord" + } }, - "requests.MessageThreadUpdate": { - "type": "object", - "required": [ - "is_archived" - ], - "properties": { - "is_archived": { - "type": "boolean", - "example": true - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "requests.PhoneAPIKeyStoreRequest": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "example": "My Phone API Key" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.HeartbeatResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.Heartbeat" }, - "requests.PhoneFCMToken": { - "type": "object", - "required": [ - "fcm_token", - "phone_number", - "sim" - ], - "properties": { - "fcm_token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." - }, - "phone_number": { - "type": "string", - "example": "[+18005550199]" - }, - "sim": { - "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", - "type": "string", - "example": "SIM1" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "requests.PhoneUpsert": { - "type": "object", - "required": [ - "fcm_token", - "max_send_attempts", - "message_expiration_seconds", - "messages_per_minute", - "missed_call_auto_reply", - "phone_number", - "schedule_id", - "sim" - ], - "properties": { - "fcm_token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." - }, - "max_send_attempts": { - "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.", - "type": "integer", - "example": 2 - }, - "message_expiration_seconds": { - "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.", - "type": "integer", - "example": 12345 - }, - "messages_per_minute": { - "type": "integer", - "example": 1 - }, - "missed_call_auto_reply": { - "type": "string", - "example": "e.g. This phone cannot receive calls. Please send an SMS instead." - }, - "phone_number": { - "type": "string", - "example": "+18005550199" - }, - "schedule_id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "sim": { - "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", - "type": "string", - "example": "SIM1" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.HeartbeatsResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Heartbeat" + } }, - "requests.SendScheduleStore": { - "type": "object", - "required": [ - "is_active", - "name", - "timezone", - "windows" - ], - "properties": { - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "timezone": { - "type": "string" - }, - "windows": { - "type": "array", - "items": { - "$ref": "#/definitions/requests.SendScheduleWindow" - } - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "requests.SendScheduleWindow": { - "type": "object", - "required": [ - "day_of_week", - "end_minute", - "start_minute" - ], - "properties": { - "day_of_week": { - "type": "integer" - }, - "end_minute": { - "type": "integer" - }, - "start_minute": { - "type": "integer" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.InternalServerError": { + "type": "object", + "required": ["message", "status"], + "properties": { + "message": { + "type": "string", + "example": "We ran into an internal error while handling the request." }, - "requests.UserNotificationUpdate": { - "type": "object", - "required": [ - "heartbeat_enabled", - "message_status_enabled", - "newsletter_enabled", - "webhook_enabled" - ], - "properties": { - "heartbeat_enabled": { - "type": "boolean", - "example": true - }, - "message_status_enabled": { - "type": "boolean", - "example": true - }, - "newsletter_enabled": { - "type": "boolean", - "example": true - }, - "webhook_enabled": { - "type": "boolean", - "example": true - } - } + "status": { + "type": "string", + "example": "error" + } + } + }, + "responses.MessageResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.Message" }, - "requests.UserPaymentInvoice": { - "type": "object", - "required": [ - "address", - "city", - "country", - "name", - "notes", - "state", - "zip_code" - ], - "properties": { - "address": { - "type": "string", - "example": "221B Baker Street, London" - }, - "city": { - "type": "string", - "example": "Los Angeles" - }, - "country": { - "type": "string", - "example": "US" - }, - "name": { - "type": "string", - "example": "Acme Corp" - }, - "notes": { - "type": "string", - "example": "Thank you for your business!" - }, - "state": { - "type": "string", - "example": "CA" - }, - "zip_code": { - "type": "string", - "example": "9800" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "requests.UserUpdate": { - "type": "object", - "required": [ - "active_phone_id", - "timezone" - ], - "properties": { - "active_phone_id": { - "type": "string", - "example": "32343a19-da5e-4b1b-a767-3298a73703cb" - }, - "timezone": { - "type": "string", - "example": "Europe/Helsinki" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.MessageSendScheduleResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.MessageSendSchedule" }, - "requests.WebhookStore": { - "type": "object", - "required": [ - "events", - "phone_numbers", - "signing_key", - "url" - ], - "properties": { - "events": { - "type": "array", - "items": { - "type": "string" - } - }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "+18005550100", - "+18005550100" - ] - }, - "signing_key": { - "type": "string" - }, - "url": { - "type": "string" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "requests.WebhookUpdate": { - "type": "object", - "required": [ - "events", - "phone_numbers", - "signing_key", - "url" - ], - "properties": { - "events": { - "type": "array", - "items": { - "type": "string" - } - }, - "phone_numbers": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "+18005550100", - "+18005550100" - ] - }, - "signing_key": { - "type": "string" - }, - "url": { - "type": "string" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.MessageThreadsResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.MessageThread" + } }, - "responses.BadRequest": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "string", - "example": "The request body is not a valid JSON string" - }, - "message": { - "type": "string", - "example": "The request isn't properly formed" - }, - "status": { - "type": "string", - "example": "error" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "responses.BillingUsageResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/entities.BillingUsage" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.MessagesResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Message" + } }, - "responses.BillingUsagesResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.BillingUsage" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "responses.DiscordResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/entities.Discord" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.NoContent": { + "type": "object", + "required": ["message", "status"], + "properties": { + "message": { + "type": "string", + "example": "action performed successfully" }, - "responses.DiscordsResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Discord" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.NotFound": { + "type": "object", + "required": ["message", "status"], + "properties": { + "message": { + "type": "string", + "example": "cannot find message with ID [32343a19-da5e-4b1b-a767-3298a73703ca]" }, - "responses.HeartbeatResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/entities.Heartbeat" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "status": { + "type": "string", + "example": "error" + } + } + }, + "responses.OkString": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "string" }, - "responses.HeartbeatsResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Heartbeat" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "responses.InternalServerError": { - "type": "object", - "required": [ - "message", - "status" - ], - "properties": { - "message": { - "type": "string", - "example": "We ran into an internal error while handling the request." - }, - "status": { - "type": "string", - "example": "error" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.PhoneAPIKeyResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.PhoneAPIKey" }, - "responses.MessageResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/entities.Message" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "responses.MessageThreadsResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.MessageThread" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.PhoneAPIKeysResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.PhoneAPIKey" + } }, - "responses.MessagesResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Message" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "responses.NoContent": { - "type": "object", - "required": [ - "message", - "status" - ], - "properties": { - "message": { - "type": "string", - "example": "action performed successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.PhoneResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.Phone" }, - "responses.NotFound": { - "type": "object", - "required": [ - "message", - "status" - ], - "properties": { - "message": { - "type": "string", - "example": "cannot find message with ID [32343a19-da5e-4b1b-a767-3298a73703ca]" - }, - "status": { - "type": "string", - "example": "error" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "responses.OkString": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "string" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.PhonesResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Phone" + } }, - "responses.PhoneAPIKeyResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/entities.PhoneAPIKey" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "responses.PhoneAPIKeysResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.PhoneAPIKey" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.Unauthorized": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "string", + "example": "Make sure your API key is set in the [X-API-Key] header in the request" }, - "responses.PhoneResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/entities.Phone" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "message": { + "type": "string", + "example": "You are not authorized to carry out this request." }, - "responses.PhonesResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Phone" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } + "status": { + "type": "string", + "example": "error" + } + } + }, + "responses.UnprocessableEntity": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" } + } }, - "responses.SendScheduleResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/entities.MessageSendSchedule" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "message": { + "type": "string", + "example": "validation errors while handling request" }, - "responses.Unauthorized": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "string", - "example": "Make sure your API key is set in the [X-API-Key] header in the request" - }, - "message": { - "type": "string", - "example": "You are not authorized to carry out this request." - }, - "status": { - "type": "string", - "example": "error" - } - } + "status": { + "type": "string", + "example": "error" + } + } + }, + "responses.UserResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.User" }, - "responses.UnprocessableEntity": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "message": { - "type": "string", - "example": "validation errors while handling request" - }, - "status": { - "type": "string", - "example": "error" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "responses.UserResponse": { + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.UserSubscriptionPaymentsResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { "type": "object", - "required": [ - "data", - "message", - "status" - ], + "required": ["attributes", "id", "type"], "properties": { - "data": { - "$ref": "#/definitions/entities.User" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" + "attributes": { + "type": "object", + "required": [ + "billing_reason", + "card_brand", + "card_last_four", + "created_at", + "currency", + "currency_rate", + "discount_total", + "discount_total_formatted", + "discount_total_usd", + "refunded", + "refunded_amount", + "refunded_amount_formatted", + "refunded_amount_usd", + "refunded_at", + "status", + "status_formatted", + "subtotal", + "subtotal_formatted", + "subtotal_usd", + "tax", + "tax_formatted", + "tax_inclusive", + "tax_usd", + "total", + "total_formatted", + "total_usd", + "updated_at" + ], + "properties": { + "billing_reason": { + "type": "string" + }, + "card_brand": { + "type": "string" + }, + "card_last_four": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "currency_rate": { + "type": "string" + }, + "discount_total": { + "type": "integer" + }, + "discount_total_formatted": { + "type": "string" + }, + "discount_total_usd": { + "type": "integer" + }, + "refunded": { + "type": "boolean" + }, + "refunded_amount": { + "type": "integer" + }, + "refunded_amount_formatted": { + "type": "string" + }, + "refunded_amount_usd": { + "type": "integer" + }, + "refunded_at": {}, + "status": { + "type": "string" + }, + "status_formatted": { + "type": "string" + }, + "subtotal": { + "type": "integer" + }, + "subtotal_formatted": { + "type": "string" + }, + "subtotal_usd": { + "type": "integer" + }, + "tax": { + "type": "integer" + }, + "tax_formatted": { + "type": "string" + }, + "tax_inclusive": { + "type": "boolean" + }, + "tax_usd": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_formatted": { + "type": "string" + }, + "total_usd": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } } + } }, - "responses.UserSubscriptionPaymentsResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "attributes", - "id", - "type" - ], - "properties": { - "attributes": { - "type": "object", - "required": [ - "billing_reason", - "card_brand", - "card_last_four", - "created_at", - "currency", - "currency_rate", - "discount_total", - "discount_total_formatted", - "discount_total_usd", - "refunded", - "refunded_amount", - "refunded_amount_formatted", - "refunded_amount_usd", - "refunded_at", - "status", - "status_formatted", - "subtotal", - "subtotal_formatted", - "subtotal_usd", - "tax", - "tax_formatted", - "tax_inclusive", - "tax_usd", - "total", - "total_formatted", - "total_usd", - "updated_at" - ], - "properties": { - "billing_reason": { - "type": "string" - }, - "card_brand": { - "type": "string" - }, - "card_last_four": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "currency_rate": { - "type": "string" - }, - "discount_total": { - "type": "integer" - }, - "discount_total_formatted": { - "type": "string" - }, - "discount_total_usd": { - "type": "integer" - }, - "refunded": { - "type": "boolean" - }, - "refunded_amount": { - "type": "integer" - }, - "refunded_amount_formatted": { - "type": "string" - }, - "refunded_amount_usd": { - "type": "integer" - }, - "refunded_at": {}, - "status": { - "type": "string" - }, - "status_formatted": { - "type": "string" - }, - "subtotal": { - "type": "integer" - }, - "subtotal_formatted": { - "type": "string" - }, - "subtotal_usd": { - "type": "integer" - }, - "tax": { - "type": "integer" - }, - "tax_formatted": { - "type": "string" - }, - "tax_inclusive": { - "type": "boolean" - }, - "tax_usd": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "total_formatted": { - "type": "string" - }, - "total_usd": { - "type": "integer" - }, - "updated_at": { - "type": "string" - } - } - }, - "id": { - "type": "string" - }, - "type": { - "type": "string" - } - } - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" }, - "responses.WebhookResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/entities.Webhook" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.WebhookResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.Webhook" }, - "responses.WebhooksResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Webhook" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" } + } }, - "securityDefinitions": { - "ApiKeyAuth": { - "type": "apiKey", - "name": "x-api-Key", - "in": "header" + "responses.WebhooksResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Webhook" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "x-api-Key", + "in": "header" } -} \ No newline at end of file + } +} diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 118671d8..58cee295 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -30,15 +30,15 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - created_at - - end_timestamp - - id - - received_messages - - sent_messages - - start_timestamp - - total_cost - - updated_at - - user_id + - created_at + - end_timestamp + - id + - received_messages + - sent_messages + - start_timestamp + - total_cost + - updated_at + - user_id type: object entities.Discord: properties: @@ -64,13 +64,13 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - created_at - - id - - incoming_channel_id - - name - - server_id - - updated_at - - user_id + - created_at + - id + - incoming_channel_id + - name + - server_id + - updated_at + - user_id type: object entities.Heartbeat: properties: @@ -93,19 +93,19 @@ definitions: example: 344c10f type: string required: - - charging - - id - - owner - - timestamp - - user_id - - version + - charging + - id + - owner + - timestamp + - user_id + - version type: object entities.Message: properties: attachments: example: - - https://example.com/image.jpg - - https://example.com/video.mp4 + - https://example.com/image.jpg + - https://example.com/video.mp4 items: type: string type: array @@ -167,7 +167,8 @@ definitions: example: 0 type: integer send_time: - description: SendDuration is the number of nanoseconds from when the request + description: + SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message example: 133414 type: integer @@ -176,7 +177,7 @@ definitions: type: string sim: allOf: - - $ref: '#/definitions/entities.SIM' + - $ref: "#/definitions/entities.SIM" description: |- SIM is the SIM card to use to send the message * SMS1: use the SIM card in slot 1 @@ -196,22 +197,22 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - attachments - - contact - - content - - created_at - - encrypted - - id - - max_send_attempts - - order_timestamp - - owner - - request_received_at - - send_attempt_count - - sim - - status - - type - - updated_at - - user_id + - attachments + - contact + - content + - created_at + - encrypted + - id + - max_send_attempts + - order_timestamp + - owner + - request_received_at + - send_attempt_count + - sim + - status + - type + - updated_at + - user_id type: object entities.MessageSendSchedule: properties: @@ -238,17 +239,17 @@ definitions: type: string windows: items: - $ref: '#/definitions/entities.MessageSendScheduleWindow' + $ref: "#/definitions/entities.MessageSendScheduleWindow" type: array required: - - created_at - - id - - is_active - - name - - timezone - - updated_at - - user_id - - windows + - created_at + - id + - is_active + - name + - timezone + - updated_at + - user_id + - windows type: object entities.MessageSendScheduleWindow: properties: @@ -262,9 +263,9 @@ definitions: example: 540 type: integer required: - - day_of_week - - end_minute - - start_minute + - day_of_week + - end_minute + - start_minute type: object entities.MessageThread: properties: @@ -305,18 +306,18 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - color - - contact - - created_at - - id - - is_archived - - last_message_content - - last_message_id - - order_timestamp - - owner - - status - - updated_at - - user_id + - color + - contact + - created_at + - id + - is_archived + - last_message_content + - last_message_id + - order_timestamp + - owner + - status + - updated_at + - user_id type: object entities.Phone: properties: @@ -330,12 +331,14 @@ definitions: example: 32343a19-da5e-4b1b-a767-3298a73703cb type: string max_send_attempts: - description: MaxSendAttempts determines how many times to retry sending an + description: + MaxSendAttempts determines how many times to retry sending an SMS message example: 2 type: integer message_expiration_seconds: - description: MessageExpirationSeconds is the duration in seconds after sending + description: + MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired. type: integer messages_per_minute: @@ -351,7 +354,7 @@ definitions: example: 32343a19-da5e-4b1b-a767-3298a73703cb type: string sim: - $ref: '#/definitions/entities.SIM' + $ref: "#/definitions/entities.SIM" updated_at: example: "2022-06-05T14:26:10.303278+03:00" type: string @@ -359,16 +362,16 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - created_at - - id - - max_send_attempts - - message_expiration_seconds - - messages_per_minute - - phone_number - - schedule_id - - sim - - updated_at - - user_id + - created_at + - id + - max_send_attempts + - message_expiration_seconds + - messages_per_minute + - phone_number + - schedule_id + - sim + - updated_at + - user_id type: object entities.PhoneAPIKey: properties: @@ -386,15 +389,15 @@ definitions: type: string phone_ids: example: - - 32343a19-da5e-4b1b-a767-3298a73703cb - - 32343a19-da5e-4b1b-a767-3298a73703cc + - 32343a19-da5e-4b1b-a767-3298a73703cb + - 32343a19-da5e-4b1b-a767-3298a73703cc items: type: string type: array phone_numbers: example: - - "+18005550199" - - "+18005550100" + - "+18005550199" + - "+18005550100" items: type: string type: array @@ -408,50 +411,50 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - api_key - - created_at - - id - - name - - phone_ids - - phone_numbers - - updated_at - - user_email - - user_id + - api_key + - created_at + - id + - name + - phone_ids + - phone_numbers + - updated_at + - user_email + - user_id type: object entities.SIM: enum: - - SIM1 - - SIM2 + - SIM1 + - SIM2 type: string x-enum-varnames: - - SIM1 - - SIM2 + - SIM1 + - SIM2 entities.SubscriptionName: enum: - - free - - pro-monthly - - pro-yearly - - ultra-monthly - - ultra-yearly - - pro-lifetime - - 20k-monthly - - 100k-monthly - - 50k-monthly - - 200k-monthly - - 20k-yearly + - free + - pro-monthly + - pro-yearly + - ultra-monthly + - ultra-yearly + - pro-lifetime + - 20k-monthly + - 100k-monthly + - 50k-monthly + - 200k-monthly + - 20k-yearly type: string x-enum-varnames: - - SubscriptionNameFree - - SubscriptionNameProMonthly - - SubscriptionNameProYearly - - SubscriptionNameUltraMonthly - - SubscriptionNameUltraYearly - - SubscriptionNameProLifetime - - SubscriptionName20KMonthly - - SubscriptionName100KMonthly - - SubscriptionName50KMonthly - - SubscriptionName200KMonthly - - SubscriptionName20KYearly + - SubscriptionNameFree + - SubscriptionNameProMonthly + - SubscriptionNameProYearly + - SubscriptionNameUltraMonthly + - SubscriptionNameUltraYearly + - SubscriptionNameProLifetime + - SubscriptionName20KMonthly + - SubscriptionName100KMonthly + - SubscriptionName50KMonthly + - SubscriptionName200KMonthly + - SubscriptionName20KYearly entities.User: properties: active_phone_id: @@ -489,7 +492,7 @@ definitions: type: string subscription_name: allOf: - - $ref: '#/definitions/entities.SubscriptionName' + - $ref: "#/definitions/entities.SubscriptionName" example: free subscription_renews_at: example: "2022-06-05T14:26:02.302718+03:00" @@ -504,18 +507,18 @@ definitions: example: "2022-06-05T14:26:10.303278+03:00" type: string required: - - api_key - - created_at - - email - - id - - notification_heartbeat_enabled - - notification_message_status_enabled - - notification_newsletter_enabled - - notification_webhook_enabled - - subscription_id - - subscription_name - - timezone - - updated_at + - api_key + - created_at + - email + - id + - notification_heartbeat_enabled + - notification_message_status_enabled + - notification_newsletter_enabled + - notification_webhook_enabled + - subscription_id + - subscription_name + - timezone + - updated_at type: object entities.Webhook: properties: @@ -524,7 +527,7 @@ definitions: type: string events: example: - - message.phone.received + - message.phone.received items: type: string type: array @@ -533,8 +536,8 @@ definitions: type: string phone_numbers: example: - - "+18005550199" - - "+18005550100" + - "+18005550199" + - "+18005550100" items: type: string type: array @@ -551,14 +554,14 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - created_at - - events - - id - - phone_numbers - - signing_key - - updated_at - - url - - user_id + - created_at + - events + - id + - phone_numbers + - signing_key + - updated_at + - url + - user_id type: object requests.DiscordStore: properties: @@ -569,9 +572,9 @@ definitions: server_id: type: string required: - - incoming_channel_id - - name - - server_id + - incoming_channel_id + - name + - server_id type: object requests.DiscordUpdate: properties: @@ -582,9 +585,9 @@ definitions: server_id: type: string required: - - incoming_channel_id - - name - - server_id + - incoming_channel_id + - name + - server_id type: object requests.HeartbeatStore: properties: @@ -595,8 +598,8 @@ definitions: type: string type: array required: - - charging - - phone_numbers + - charging + - phone_numbers type: object requests.MessageAttachment: properties: @@ -613,14 +616,15 @@ definitions: example: photo.jpg type: string required: - - content - - content_type - - name + - content + - content_type + - name type: object requests.MessageBulkSend: properties: attachments: - description: Attachments are optional. When you provide a list of attachments, + description: + Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS items: type: string @@ -629,7 +633,8 @@ definitions: example: This is a sample text message type: string encrypted: - description: Encrypted is used to determine if the content is end-to-end encrypted. + description: + Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app example: false type: boolean @@ -637,21 +642,22 @@ definitions: example: "+18005550199" type: string request_id: - description: RequestID is an optional parameter used to track a request from + description: + RequestID is an optional parameter used to track a request from the client's perspective example: 153554b5-ae44-44a0-8f4f-7bbac5657ad4 type: string to: example: - - "+18005550100" - - "+18005550100" + - "+18005550100" + - "+18005550100" items: type: string type: array required: - - content - - from - - to + - content + - from + - to type: object requests.MessageCallMissed: properties: @@ -668,10 +674,10 @@ definitions: example: "+18005550100" type: string required: - - from - - sim - - timestamp - - to + - from + - sim + - timestamp + - to type: object requests.MessageEvent: properties: @@ -687,28 +693,31 @@ definitions: description: Reason is the exact error message in case the event is an error type: string timestamp: - description: Timestamp is the time when the event was emitted, Please send + description: + Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible example: "2022-06-05T14:26:09.527976+03:00" type: string required: - - event_name - - reason - - timestamp + - event_name + - reason + - timestamp type: object requests.MessageReceive: properties: attachments: - description: Attachments is the list of MMS attachments received with the + description: + Attachments is the list of MMS attachments received with the message items: - $ref: '#/definitions/requests.MessageAttachment' + $ref: "#/definitions/requests.MessageAttachment" type: array content: example: This is a sample text message received on a phone type: string encrypted: - description: Encrypted is used to determine if the content is end-to-end encrypted. + description: + Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app example: false type: boolean @@ -717,11 +726,12 @@ definitions: type: string sim: allOf: - - $ref: '#/definitions/entities.SIM' + - $ref: "#/definitions/entities.SIM" description: SIM card that received the message example: SIM1 timestamp: - description: Timestamp is the time when the event was emitted, Please send + description: + Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible example: "2022-06-05T14:26:09.527976+03:00" type: string @@ -729,21 +739,22 @@ definitions: example: "+18005550100" type: string required: - - content - - encrypted - - from - - sim - - timestamp - - to + - content + - encrypted + - from + - sim + - timestamp + - to type: object requests.MessageSend: properties: attachments: - description: Attachments are optional. When you provide a list of attachments, + description: + Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS example: - - https://example.com/image.jpg - - https://example.com/video.mp4 + - https://example.com/image.jpg + - https://example.com/video.mp4 items: type: string type: array @@ -751,7 +762,8 @@ definitions: example: This is a sample text message type: string encrypted: - description: Encrypted is an optional parameter used to determine if the content + description: + Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app example: false @@ -760,12 +772,14 @@ definitions: example: "+18005550199" type: string request_id: - description: RequestID is an optional parameter used to track a request from + description: + RequestID is an optional parameter used to track a request from the client's perspective example: 153554b5-ae44-44a0-8f4f-7bbac5657ad4 type: string send_at: - description: SendAt is an optional parameter used to schedule a message to + description: + SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future. @@ -775,9 +789,40 @@ definitions: example: "+18005550100" type: string required: - - content - - from - - to + - content + - from + - to + type: object + requests.MessageSendScheduleStore: + properties: + is_active: + type: boolean + name: + type: string + timezone: + type: string + windows: + items: + $ref: "#/definitions/requests.MessageSendScheduleWindow" + type: array + required: + - is_active + - name + - timezone + - windows + type: object + requests.MessageSendScheduleWindow: + properties: + day_of_week: + type: integer + end_minute: + type: integer + start_minute: + type: integer + required: + - day_of_week + - end_minute + - start_minute type: object requests.MessageThreadUpdate: properties: @@ -785,7 +830,7 @@ definitions: example: true type: boolean required: - - is_archived + - is_archived type: object requests.PhoneAPIKeyStoreRequest: properties: @@ -793,7 +838,7 @@ definitions: example: My Phone API Key type: string required: - - name + - name type: object requests.PhoneFCMToken: properties: @@ -801,17 +846,18 @@ definitions: example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string phone_number: - example: '[+18005550199]' + example: "[+18005550199]" type: string sim: - description: SIM is the SIM slot of the phone in case the phone has more than + description: + SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot example: SIM1 type: string required: - - fcm_token - - phone_number - - sim + - fcm_token + - phone_number + - sim type: object requests.PhoneUpsert: properties: @@ -819,12 +865,14 @@ definitions: example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string max_send_attempts: - description: MaxSendAttempts is the number of attempts when sending an SMS + description: + MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline. example: 2 type: integer message_expiration_seconds: - description: MessageExpirationSeconds is the duration in seconds after sending + description: + MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired. example: 12345 type: integer @@ -841,50 +889,20 @@ definitions: example: 32343a19-da5e-4b1b-a767-3298a73703cb type: string sim: - description: SIM is the SIM slot of the phone in case the phone has more than + description: + SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot example: SIM1 type: string required: - - fcm_token - - max_send_attempts - - message_expiration_seconds - - messages_per_minute - - missed_call_auto_reply - - phone_number - - schedule_id - - sim - type: object - requests.SendScheduleStore: - properties: - is_active: - type: boolean - name: - type: string - timezone: - type: string - windows: - items: - $ref: '#/definitions/requests.SendScheduleWindow' - type: array - required: - - is_active - - name - - timezone - - windows - type: object - requests.SendScheduleWindow: - properties: - day_of_week: - type: integer - end_minute: - type: integer - start_minute: - type: integer - required: - - day_of_week - - end_minute - - start_minute + - fcm_token + - max_send_attempts + - message_expiration_seconds + - messages_per_minute + - missed_call_auto_reply + - phone_number + - schedule_id + - sim type: object requests.UserNotificationUpdate: properties: @@ -901,10 +919,10 @@ definitions: example: true type: boolean required: - - heartbeat_enabled - - message_status_enabled - - newsletter_enabled - - webhook_enabled + - heartbeat_enabled + - message_status_enabled + - newsletter_enabled + - webhook_enabled type: object requests.UserPaymentInvoice: properties: @@ -930,13 +948,13 @@ definitions: example: "9800" type: string required: - - address - - city - - country - - name - - notes - - state - - zip_code + - address + - city + - country + - name + - notes + - state + - zip_code type: object requests.UserUpdate: properties: @@ -947,8 +965,8 @@ definitions: example: Europe/Helsinki type: string required: - - active_phone_id - - timezone + - active_phone_id + - timezone type: object requests.WebhookStore: properties: @@ -958,8 +976,8 @@ definitions: type: array phone_numbers: example: - - "+18005550100" - - "+18005550100" + - "+18005550100" + - "+18005550100" items: type: string type: array @@ -968,10 +986,10 @@ definitions: url: type: string required: - - events - - phone_numbers - - signing_key - - url + - events + - phone_numbers + - signing_key + - url type: object requests.WebhookUpdate: properties: @@ -981,8 +999,8 @@ definitions: type: array phone_numbers: example: - - "+18005550100" - - "+18005550100" + - "+18005550100" + - "+18005550100" items: type: string type: array @@ -991,10 +1009,10 @@ definitions: url: type: string required: - - events - - phone_numbers - - signing_key - - url + - events + - phone_numbers + - signing_key + - url type: object responses.BadRequest: properties: @@ -1008,14 +1026,14 @@ definitions: example: error type: string required: - - data - - message - - status + - data + - message + - status type: object responses.BillingUsageResponse: properties: data: - $ref: '#/definitions/entities.BillingUsage' + $ref: "#/definitions/entities.BillingUsage" message: example: Request handled successfully type: string @@ -1023,15 +1041,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.BillingUsagesResponse: properties: data: items: - $ref: '#/definitions/entities.BillingUsage' + $ref: "#/definitions/entities.BillingUsage" type: array message: example: Request handled successfully @@ -1040,14 +1058,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.DiscordResponse: properties: data: - $ref: '#/definitions/entities.Discord' + $ref: "#/definitions/entities.Discord" message: example: Request handled successfully type: string @@ -1055,15 +1073,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.DiscordsResponse: properties: data: items: - $ref: '#/definitions/entities.Discord' + $ref: "#/definitions/entities.Discord" type: array message: example: Request handled successfully @@ -1072,14 +1090,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.HeartbeatResponse: properties: data: - $ref: '#/definitions/entities.Heartbeat' + $ref: "#/definitions/entities.Heartbeat" message: example: Request handled successfully type: string @@ -1087,15 +1105,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.HeartbeatsResponse: properties: data: items: - $ref: '#/definitions/entities.Heartbeat' + $ref: "#/definitions/entities.Heartbeat" type: array message: example: Request handled successfully @@ -1104,9 +1122,9 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.InternalServerError: properties: @@ -1117,13 +1135,13 @@ definitions: example: error type: string required: - - message - - status + - message + - status type: object responses.MessageResponse: properties: data: - $ref: '#/definitions/entities.Message' + $ref: "#/definitions/entities.Message" message: example: Request handled successfully type: string @@ -1131,15 +1149,30 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status + type: object + responses.MessageSendScheduleResponse: + properties: + data: + $ref: "#/definitions/entities.MessageSendSchedule" + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status type: object responses.MessageThreadsResponse: properties: data: items: - $ref: '#/definitions/entities.MessageThread' + $ref: "#/definitions/entities.MessageThread" type: array message: example: Request handled successfully @@ -1148,15 +1181,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.MessagesResponse: properties: data: items: - $ref: '#/definitions/entities.Message' + $ref: "#/definitions/entities.Message" type: array message: example: Request handled successfully @@ -1165,9 +1198,9 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.NoContent: properties: @@ -1178,8 +1211,8 @@ definitions: example: success type: string required: - - message - - status + - message + - status type: object responses.NotFound: properties: @@ -1190,8 +1223,8 @@ definitions: example: error type: string required: - - message - - status + - message + - status type: object responses.OkString: properties: @@ -1204,14 +1237,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.PhoneAPIKeyResponse: properties: data: - $ref: '#/definitions/entities.PhoneAPIKey' + $ref: "#/definitions/entities.PhoneAPIKey" message: example: Request handled successfully type: string @@ -1219,15 +1252,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.PhoneAPIKeysResponse: properties: data: items: - $ref: '#/definitions/entities.PhoneAPIKey' + $ref: "#/definitions/entities.PhoneAPIKey" type: array message: example: Request handled successfully @@ -1236,14 +1269,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.PhoneResponse: properties: data: - $ref: '#/definitions/entities.Phone' + $ref: "#/definitions/entities.Phone" message: example: Request handled successfully type: string @@ -1251,15 +1284,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.PhonesResponse: properties: data: items: - $ref: '#/definitions/entities.Phone' + $ref: "#/definitions/entities.Phone" type: array message: example: Request handled successfully @@ -1268,24 +1301,9 @@ definitions: example: success type: string required: - - data - - message - - status - type: object - responses.SendScheduleResponse: - properties: - data: - $ref: '#/definitions/entities.MessageSendSchedule' - message: - example: Request handled successfully - type: string - status: - example: success - type: string - required: - - data - - message - - status + - data + - message + - status type: object responses.Unauthorized: properties: @@ -1299,9 +1317,9 @@ definitions: example: error type: string required: - - data - - message - - status + - data + - message + - status type: object responses.UnprocessableEntity: properties: @@ -1318,14 +1336,14 @@ definitions: example: error type: string required: - - data - - message - - status + - data + - message + - status type: object responses.UserResponse: properties: data: - $ref: '#/definitions/entities.User' + $ref: "#/definitions/entities.User" message: example: Request handled successfully type: string @@ -1333,9 +1351,9 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.UserSubscriptionPaymentsResponse: properties: @@ -1398,42 +1416,42 @@ definitions: updated_at: type: string required: - - billing_reason - - card_brand - - card_last_four - - created_at - - currency - - currency_rate - - discount_total - - discount_total_formatted - - discount_total_usd - - refunded - - refunded_amount - - refunded_amount_formatted - - refunded_amount_usd - - refunded_at - - status - - status_formatted - - subtotal - - subtotal_formatted - - subtotal_usd - - tax - - tax_formatted - - tax_inclusive - - tax_usd - - total - - total_formatted - - total_usd - - updated_at + - billing_reason + - card_brand + - card_last_four + - created_at + - currency + - currency_rate + - discount_total + - discount_total_formatted + - discount_total_usd + - refunded + - refunded_amount + - refunded_amount_formatted + - refunded_amount_usd + - refunded_at + - status + - status_formatted + - subtotal + - subtotal_formatted + - subtotal_usd + - tax + - tax_formatted + - tax_inclusive + - tax_usd + - total + - total_formatted + - total_usd + - updated_at type: object id: type: string type: type: string required: - - attributes - - id - - type + - attributes + - id + - type type: object type: array message: @@ -1443,14 +1461,14 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.WebhookResponse: properties: data: - $ref: '#/definitions/entities.Webhook' + $ref: "#/definitions/entities.Webhook" message: example: Request handled successfully type: string @@ -1458,15 +1476,15 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object responses.WebhooksResponse: properties: data: items: - $ref: '#/definitions/entities.Webhook' + $ref: "#/definitions/entities.Webhook" type: array message: example: Request handled successfully @@ -1475,16 +1493,17 @@ definitions: example: success type: string required: - - data - - message - - status + - data + - message + - status type: object host: api.httpsms.com info: contact: email: support@httpsms.com name: support@httpsms.com - description: Use your Android phone to send and receive SMS messages via a simple + description: + Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption. license: name: AGPL-3.0 @@ -1495,1834 +1514,1859 @@ paths: /billing/usage: get: consumes: - - application/json - description: Get the summary of sent and received messages for a user in the + - application/json + description: + Get the summary of sent and received messages for a user in the current month produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.BillingUsageResponse' + $ref: "#/definitions/responses.BillingUsageResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get Billing Usage. tags: - - Billing + - Billing /billing/usage-history: get: consumes: - - application/json - description: Get billing usage records of sent and received messages for a user + - application/json + description: + Get billing usage records of sent and received messages for a user in the past. It will be sorted by timestamp in descending order. parameters: - - description: number of heartbeats to skip - in: query - minimum: 0 - name: skip - type: integer - - description: number of heartbeats to return - in: query - maximum: 100 - minimum: 1 - name: limit - type: integer + - description: number of heartbeats to skip + in: query + minimum: 0 + name: skip + type: integer + - description: number of heartbeats to return + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.BillingUsagesResponse' + $ref: "#/definitions/responses.BillingUsagesResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get billing usage history. tags: - - Billing + - Billing /bulk-messages: post: consumes: - - multipart/form-data - description: Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) + - multipart/form-data + description: + Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx). parameters: - - description: The Excel or CSV file containing the messages to be sent. - in: formData - name: document - required: true - type: file + - description: The Excel or CSV file containing the messages to be sent. + in: formData + name: document + required: true + type: file produces: - - application/json + - application/json responses: "202": description: Accepted schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Store bulk SMS file tags: - - BulkSMS + - BulkSMS /discord-integrations: get: consumes: - - application/json + - application/json description: Get the discord integrations of a user parameters: - - description: number of discord integrations to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter discord integrations containing query - in: query - name: query - type: string - - description: number of discord integrations to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - description: number of discord integrations to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter discord integrations containing query + in: query + name: query + type: string + - description: number of discord integrations to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.DiscordsResponse' + $ref: "#/definitions/responses.DiscordsResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get discord integrations of a user tags: - - DiscordIntegration + - DiscordIntegration post: consumes: - - application/json + - application/json description: Store a discord integration for the authenticated user parameters: - - description: Payload of the discord integration request - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.DiscordStore' + - description: Payload of the discord integration request + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.DiscordStore" produces: - - application/json + - application/json responses: "201": description: Created schema: - $ref: '#/definitions/responses.DiscordResponse' + $ref: "#/definitions/responses.DiscordResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Store discord integration tags: - - DiscordIntegration + - DiscordIntegration /discord-integrations/{discordID}: delete: consumes: - - application/json + - application/json description: Delete a discord integration for a user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the discord integration - in: path - name: discordID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the discord integration + in: path + name: discordID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete discord integration tags: - - Webhooks + - Webhooks put: consumes: - - application/json + - application/json description: Update a discord integration for the currently authenticated user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the discord integration - in: path - name: discordID - required: true - type: string - - description: Payload of discord integration to update - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.DiscordUpdate' + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the discord integration + in: path + name: discordID + required: true + type: string + - description: Payload of discord integration to update + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.DiscordUpdate" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.DiscordResponse' + $ref: "#/definitions/responses.DiscordResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update a discord integration tags: - - DiscordIntegration + - DiscordIntegration /discord/event: post: consumes: - - application/json + - application/json description: Publish a discord event to the registered listeners produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" summary: Consume a discord event tags: - - Discord + - Discord /heartbeats: get: consumes: - - application/json - description: Get the last time a phone number requested for outstanding messages. + - application/json + description: + Get the last time a phone number requested for outstanding messages. It will be sorted by timestamp in descending order. parameters: - - default: "+18005550199" - description: the owner's phone number - in: query - name: owner - required: true - type: string - - description: number of heartbeats to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter containing query - in: query - name: query - type: string - - description: number of heartbeats to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - default: "+18005550199" + description: the owner's phone number + in: query + name: owner + required: true + type: string + - description: number of heartbeats to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter containing query + in: query + name: query + type: string + - description: number of heartbeats to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.HeartbeatsResponse' + $ref: "#/definitions/responses.HeartbeatsResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get heartbeats of an owner phone number tags: - - Heartbeats + - Heartbeats post: consumes: - - application/json - description: Store the heartbeat to make notify that a phone number is still + - application/json + description: + Store the heartbeat to make notify that a phone number is still active parameters: - - description: Payload of the heartbeat request - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.HeartbeatStore' + - description: Payload of the heartbeat request + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.HeartbeatStore" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.HeartbeatResponse' + $ref: "#/definitions/responses.HeartbeatResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Register heartbeat of an owner phone number tags: - - Heartbeats + - Heartbeats /integration/3cx/messages: post: consumes: - - application/json + - application/json description: Sends an SMS message from the 3CX platform produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" summary: Sends a 3CX SMS message tags: - - 3CXIntegration + - 3CXIntegration /message-threads: get: consumes: - - application/json - description: Get list of contacts which a phone number has communicated with + - application/json + description: + Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. parameters: - - default: "+18005550199" - description: owner phone number - in: query - name: owner - required: true - type: string - - description: number of messages to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter message threads containing query - in: query - name: query - type: string - - description: number of messages to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - default: "+18005550199" + description: owner phone number + in: query + name: owner + required: true + type: string + - description: number of messages to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter message threads containing query + in: query + name: query + type: string + - description: number of messages to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.MessageThreadsResponse' + $ref: "#/definitions/responses.MessageThreadsResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get message threads for a phone number tags: - - MessageThreads + - MessageThreads /message-threads/{messageThreadID}: delete: consumes: - - application/json - description: Delete a message thread from the database and also deletes all + - application/json + description: + Delete a message thread from the database and also deletes all the messages in the thread. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the message thread - in: path - name: messageThreadID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message thread + in: path + name: messageThreadID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete a message thread from the database. tags: - - MessageThreads + - MessageThreads put: consumes: - - application/json + - application/json description: Updates the details of a message thread parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the message thread - in: path - name: messageThreadID - required: true - type: string - - description: Payload of message thread details to update - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.MessageThreadUpdate' + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message thread + in: path + name: messageThreadID + required: true + type: string + - description: Payload of message thread details to update + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.MessageThreadUpdate" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.PhoneResponse' + $ref: "#/definitions/responses.PhoneResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update a message thread tags: - - MessageThreads + - MessageThreads /messages: get: consumes: - - application/json - description: Get list of messages which are sent between 2 phone numbers. It + - application/json + description: + Get list of messages which are sent between 2 phone numbers. It will be sorted by timestamp in descending order. parameters: - - default: "+18005550199" - description: the owner's phone number - in: query - name: owner - required: true - type: string - - default: "+18005550100" - description: the contact's phone number - in: query - name: contact - required: true - type: string - - description: number of messages to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter messages containing query - in: query - name: query - type: string - - description: number of messages to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - default: "+18005550199" + description: the owner's phone number + in: query + name: owner + required: true + type: string + - default: "+18005550100" + description: the contact's phone number + in: query + name: contact + required: true + type: string + - description: number of messages to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter messages containing query + in: query + name: query + type: string + - description: number of messages to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.MessagesResponse' + $ref: "#/definitions/responses.MessagesResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get messages which are sent between 2 phone numbers tags: - - Messages + - Messages /messages/{messageID}: delete: consumes: - - application/json - description: Delete a message from the database and removes the message content + - application/json + description: + Delete a message from the database and removes the message content from the list of threads. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the message - in: path - name: messageID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message + in: path + name: messageID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete a message from the database. tags: - - Messages + - Messages get: consumes: - - application/json + - application/json description: Get a message from the database by the message ID. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the message - in: path - name: messageID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message + in: path + name: messageID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.MessageResponse' + $ref: "#/definitions/responses.MessageResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get a message from the database. tags: - - Messages + - Messages /messages/{messageID}/events: post: consumes: - - application/json - description: Use this endpoint to send events for a message when it is failed, + - application/json + description: + Use this endpoint to send events for a message when it is failed, sent or delivered by the mobile phone. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the message - in: path - name: messageID - required: true - type: string - - description: Payload of the event emitted. - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.MessageEvent' + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message + in: path + name: messageID + required: true + type: string + - description: Payload of the event emitted. + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.MessageEvent" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.MessageResponse' + $ref: "#/definitions/responses.MessageResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Upsert an event for a message on the mobile phone tags: - - Messages + - Messages /messages/bulk-send: post: consumes: - - application/json + - application/json description: Add bulk SMS messages to be sent by the android phone parameters: - - description: Bulk send message request payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.MessageBulkSend' + - description: Bulk send message request payload + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.MessageBulkSend" produces: - - application/json + - application/json responses: "200": description: OK schema: items: - $ref: '#/definitions/responses.MessagesResponse' + $ref: "#/definitions/responses.MessagesResponse" type: array "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Send bulk SMS messages tags: - - Messages + - Messages /messages/calls/missed: post: consumes: - - application/json - description: This endpoint is called by the httpSMS android app to register + - application/json + description: + This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone. parameters: - - description: Payload of the missed call event. - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.MessageCallMissed' + - description: Payload of the missed call event. + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.MessageCallMissed" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.MessageResponse' + $ref: "#/definitions/responses.MessageResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Register a missed call event on the mobile phone tags: - - Messages + - Messages /messages/outstanding: get: consumes: - - application/json + - application/json description: Get an outstanding message to be sent by an android phone parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703cb - description: The ID of the message - in: query - name: message_id - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703cb + description: The ID of the message + in: query + name: message_id + required: true + type: string produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.MessageResponse' + $ref: "#/definitions/responses.MessageResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get an outstanding message tags: - - Messages + - Messages /messages/receive: post: consumes: - - application/json + - application/json description: Add a new message received from a mobile phone parameters: - - description: Received message request payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.MessageReceive' + - description: Received message request payload + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.MessageReceive" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.MessageResponse' + $ref: "#/definitions/responses.MessageResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Receive a new SMS message from a mobile phone tags: - - Messages + - Messages /messages/search: get: consumes: - - application/json - description: This returns the list of all messages based on the filter criteria + - application/json + description: + This returns the list of all messages based on the filter criteria including missed calls parameters: - - description: Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/ - in: header - name: token - required: true - type: string - - default: +18005550199,+18005550100 - description: the owner's phone numbers - in: query - name: owners - required: true - type: string - - description: number of messages to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter messages containing query - in: query - name: query - type: string - - description: number of messages to return - in: query - maximum: 200 - minimum: 1 - name: limit - type: integer + - description: Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/ + in: header + name: token + required: true + type: string + - default: +18005550199,+18005550100 + description: the owner's phone numbers + in: query + name: owners + required: true + type: string + - description: number of messages to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter messages containing query + in: query + name: query + type: string + - description: number of messages to return + in: query + maximum: 200 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.MessagesResponse' + $ref: "#/definitions/responses.MessagesResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Search all messages of a user tags: - - Messages + - Messages /messages/send: post: consumes: - - application/json + - application/json description: Add a new SMS message to be sent by your Android phone parameters: - - description: Send message request payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.MessageSend' + - description: Send message request payload + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.MessageSend" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.MessageResponse' + $ref: "#/definitions/responses.MessageResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Send an SMS message tags: - - Messages + - Messages /phone-api-keys: get: consumes: - - application/json - description: Get list phone API keys which a user has registered on the httpSMS + - application/json + description: + Get list phone API keys which a user has registered on the httpSMS application parameters: - - description: number of phone api keys to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter phone api keys with name containing query - in: query - name: query - type: string - - description: number of phone api keys to return - in: query - maximum: 100 - minimum: 1 - name: limit - type: integer + - description: number of phone api keys to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter phone api keys with name containing query + in: query + name: query + type: string + - description: number of phone api keys to return + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.PhoneAPIKeysResponse' + $ref: "#/definitions/responses.PhoneAPIKeysResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get the phone API keys of a user tags: - - PhoneAPIKeys + - PhoneAPIKeys post: consumes: - - application/json - description: Creates a new phone API key which can be used to log in to the + - application/json + description: + Creates a new phone API key which can be used to log in to the httpSMS app on your Android phone parameters: - - description: Payload of new phone API key. - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.PhoneAPIKeyStoreRequest' + - description: Payload of new phone API key. + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.PhoneAPIKeyStoreRequest" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.PhoneAPIKeyResponse' + $ref: "#/definitions/responses.PhoneAPIKeyResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Store phone API key tags: - - PhoneAPIKeys + - PhoneAPIKeys /phone-api-keys/{phoneAPIKeyID}: delete: consumes: - - application/json - description: Delete a phone API Key from the database and cannot be used for + - application/json + description: + Delete a phone API Key from the database and cannot be used for authentication anymore. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the phone API key - in: path - name: phoneAPIKeyID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the phone API key + in: path + name: phoneAPIKeyID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete a phone API key from the database. tags: - - PhoneAPIKeys + - PhoneAPIKeys /phone-api-keys/{phoneAPIKeyID}/phones/{phoneID}: delete: consumes: - - application/json - description: You will need to login again to the httpSMS app on your Android + - application/json + description: + You will need to login again to the httpSMS app on your Android phone with a new phone API key. parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the phone API key - in: path - name: phoneAPIKeyID - required: true - type: string - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the phone - in: path - name: phoneID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the phone API key + in: path + name: phoneAPIKeyID + required: true + type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the phone + in: path + name: phoneID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Remove the association of a phone from the phone API key. tags: - - PhoneAPIKeys + - PhoneAPIKeys /phones: get: consumes: - - application/json - description: Get list of phones which a user has registered on the http sms + - application/json + description: + Get list of phones which a user has registered on the http sms application parameters: - - description: number of heartbeats to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter phones containing query - in: query - name: query - type: string - - description: number of phones to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - description: number of heartbeats to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter phones containing query + in: query + name: query + type: string + - description: number of phones to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.PhonesResponse' + $ref: "#/definitions/responses.PhonesResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get phones of a user tags: - - Phones + - Phones put: consumes: - - application/json - description: Updates properties of a user's phone. If the phone with this number + - application/json + description: + Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' parameters: - - description: Payload of new phone number. - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.PhoneUpsert' + - description: Payload of new phone number. + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.PhoneUpsert" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.PhoneResponse' + $ref: "#/definitions/responses.PhoneResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Upsert Phone tags: - - Phones + - Phones /phones/{phoneID}: delete: consumes: - - application/json + - application/json description: Delete a phone that has been sored in the database parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the phone - in: path - name: phoneID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the phone + in: path + name: phoneID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete Phone tags: - - Phones + - Phones /phones/fcm-token: put: consumes: - - application/json - description: Updates the FCM token of a phone. If the phone with this number + - application/json + description: + Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' parameters: - - description: Payload of new FCM token. - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.PhoneFCMToken' + - description: Payload of new FCM token. + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.PhoneFCMToken" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.PhoneResponse' + $ref: "#/definitions/responses.PhoneResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Upserts the FCM token of a phone tags: - - Phones + - Phones /send-schedules: get: description: List all send schedules owned by the authenticated user. produces: - - application/json + - application/json responses: "200": description: OK schema: items: - $ref: '#/definitions/entities.MessageSendSchedule' + $ref: "#/definitions/entities.MessageSendSchedule" type: array "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: List send schedules tags: - - Send Schedules + - Send Schedules post: consumes: - - application/json + - application/json description: Create a new send schedule for the authenticated user. parameters: - - description: Payload of new send schedule. - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.SendScheduleStore' + - description: Payload of new send schedule. + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.MessageSendScheduleStore" produces: - - application/json + - application/json responses: "201": description: Created schema: - $ref: '#/definitions/responses.SendScheduleResponse' + $ref: "#/definitions/responses.MessageSendScheduleResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" + "402": + description: Payment Required + schema: + $ref: "#/definitions/responses.BadRequest" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Create send schedule tags: - - Send Schedules + - Send Schedules /send-schedules/{scheduleID}: delete: description: Delete a send schedule owned by the authenticated user. parameters: - - description: Schedule ID - in: path - name: scheduleID - required: true - type: string + - description: Schedule ID + in: path + name: scheduleID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete send schedule tags: - - Send Schedules + - Send Schedules put: consumes: - - application/json + - application/json description: Update a send schedule owned by the authenticated user. parameters: - - description: Schedule ID - in: path - name: scheduleID - required: true - type: string - - description: Payload of updated send schedule. - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.SendScheduleStore' + - description: Schedule ID + in: path + name: scheduleID + required: true + type: string + - description: Payload of updated send schedule. + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.MessageSendScheduleStore" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.SendScheduleResponse' + $ref: "#/definitions/responses.MessageSendScheduleResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update send schedule tags: - - Send Schedules + - Send Schedules /users/{userID}/api-keys: delete: consumes: - - application/json + - application/json description: Rotate the user's API key in case the current API Key is compromised parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the user to update - in: path - name: userID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the user to update + in: path + name: userID + required: true + type: string produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.UserResponse' + $ref: "#/definitions/responses.UserResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Rotate the user's API Key tags: - - Users + - Users /users/{userID}/notifications: put: consumes: - - application/json + - application/json description: Update the email notification settings for a user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the user to update - in: path - name: userID - required: true - type: string - - description: User notification details to update - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.UserNotificationUpdate' + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the user to update + in: path + name: userID + required: true + type: string + - description: User notification details to update + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.UserNotificationUpdate" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.UserResponse' + $ref: "#/definitions/responses.UserResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update notification settings tags: - - Users + - Users /users/me: delete: consumes: - - application/json - description: Deletes the currently authenticated user together with all their + - application/json + description: + Deletes the currently authenticated user together with all their data. produces: - - application/json + - application/json responses: "201": description: Created schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete a user tags: - - Users + - Users get: consumes: - - application/json + - application/json description: Get details of the currently authenticated user produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.UserResponse' + $ref: "#/definitions/responses.UserResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get current user tags: - - Users + - Users put: consumes: - - application/json + - application/json description: Updates the details of the currently authenticated user parameters: - - description: Payload of user details to update - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.UserUpdate' + - description: Payload of user details to update + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.UserUpdate" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.PhoneResponse' + $ref: "#/definitions/responses.PhoneResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update a user tags: - - Users + - Users /users/subscription: delete: description: Cancel the subscription of the authenticated user. produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Cancel the user's subscription tags: - - Users + - Users /users/subscription-update-url: get: description: Fetches the subscription URL of the authenticated user. produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.OkString' + $ref: "#/definitions/responses.OkString" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Currently authenticated user subscription update URL tags: - - Users + - Users /users/subscription/invoices/{subscriptionInvoiceID}: post: consumes: - - application/json - description: Generates a new invoice PDF file for the given subscription payment + - application/json + description: + Generates a new invoice PDF file for the given subscription payment with given parameters. parameters: - - description: Generate subscription payment invoice parameters - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.UserPaymentInvoice' - - description: ID of the subscription invoice to generate the PDF for - in: path - name: subscriptionInvoiceID - required: true - type: string + - description: Generate subscription payment invoice parameters + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.UserPaymentInvoice" + - description: ID of the subscription invoice to generate the PDF for + in: path + name: subscriptionInvoiceID + required: true + type: string produces: - - application/pdf + - application/pdf responses: "200": description: OK @@ -3331,85 +3375,86 @@ paths: "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Generate a subscription payment invoice tags: - - Users + - Users /users/subscription/payments: get: consumes: - - application/json - description: Subscription payments are generated throughout the lifecycle of + - application/json + description: + Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal. produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.UserSubscriptionPaymentsResponse' + $ref: "#/definitions/responses.UserSubscriptionPaymentsResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get the last 10 subscription payments. tags: - - Users + - Users /v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}: get: description: Download an MMS attachment by its path components parameters: - - description: User ID - in: path - name: userID - required: true - type: string - - description: Message ID - in: path - name: messageID - required: true - type: string - - description: Attachment index - in: path - name: attachmentIndex - required: true - type: string - - description: Filename with extension - in: path - name: filename - required: true - type: string + - description: User ID + in: path + name: userID + required: true + type: string + - description: Message ID + in: path + name: messageID + required: true + type: string + - description: Attachment index + in: path + name: attachmentIndex + required: true + type: string + - description: Filename with extension + in: path + name: filename + required: true + type: string produces: - - application/octet-stream + - application/octet-stream responses: "200": description: OK @@ -3418,189 +3463,189 @@ paths: "404": description: Not Found schema: - $ref: '#/definitions/responses.NotFound' + $ref: "#/definitions/responses.NotFound" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" summary: Download a message attachment tags: - - Attachments + - Attachments /webhooks: get: consumes: - - application/json + - application/json description: Get the webhooks of a user parameters: - - description: number of webhooks to skip - in: query - minimum: 0 - name: skip - type: integer - - description: filter webhooks containing query - in: query - name: query - type: string - - description: number of webhooks to return - in: query - maximum: 20 - minimum: 1 - name: limit - type: integer + - description: number of webhooks to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter webhooks containing query + in: query + name: query + type: string + - description: number of webhooks to return + in: query + maximum: 20 + minimum: 1 + name: limit + type: integer produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.WebhooksResponse' + $ref: "#/definitions/responses.WebhooksResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Get webhooks of a user tags: - - Webhooks + - Webhooks post: consumes: - - application/json + - application/json description: Store a webhook for the authenticated user parameters: - - description: Payload of the webhook request - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.WebhookStore' + - description: Payload of the webhook request + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.WebhookStore" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.WebhookResponse' + $ref: "#/definitions/responses.WebhookResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Store a webhook tags: - - Webhooks + - Webhooks /webhooks/{webhookID}: delete: consumes: - - application/json + - application/json description: Delete a webhook for a user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the webhook - in: path - name: webhookID - required: true - type: string + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the webhook + in: path + name: webhookID + required: true + type: string produces: - - application/json + - application/json responses: "204": description: No Content schema: - $ref: '#/definitions/responses.NoContent' + $ref: "#/definitions/responses.NoContent" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Delete webhook tags: - - Webhooks + - Webhooks put: consumes: - - application/json + - application/json description: Update a webhook for the currently authenticated user parameters: - - default: 32343a19-da5e-4b1b-a767-3298a73703ca - description: ID of the webhook - in: path - name: webhookID - required: true - type: string - - description: Payload of webhook details to update - in: body - name: payload - required: true - schema: - $ref: '#/definitions/requests.WebhookUpdate' + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the webhook + in: path + name: webhookID + required: true + type: string + - description: Payload of webhook details to update + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.WebhookUpdate" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/responses.WebhookResponse' + $ref: "#/definitions/responses.WebhookResponse" "400": description: Bad Request schema: - $ref: '#/definitions/responses.BadRequest' + $ref: "#/definitions/responses.BadRequest" "401": description: Unauthorized schema: - $ref: '#/definitions/responses.Unauthorized' + $ref: "#/definitions/responses.Unauthorized" "422": description: Unprocessable Entity schema: - $ref: '#/definitions/responses.UnprocessableEntity' + $ref: "#/definitions/responses.UnprocessableEntity" "500": description: Internal Server Error schema: - $ref: '#/definitions/responses.InternalServerError' + $ref: "#/definitions/responses.InternalServerError" security: - - ApiKeyAuth: [] + - ApiKeyAuth: [] summary: Update a webhook tags: - - Webhooks + - Webhooks schemes: -- https + - https securityDefinitions: ApiKeyAuth: in: header diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 5727f6f7..66b57468 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -128,8 +128,8 @@ func NewContainer(projectID string, version string) (container *Container) { container.RegisterHeartbeatListeners() container.RegisterUserRoutes() - container.RegisterSendScheduleRoutes() - container.RegisterSendScheduleListeners() + container.RegisterMessageSendScheduleRoutes() + container.RegisterMessageSendScheduleListeners() container.RegisterUserListeners() container.RegisterPhoneRoutes() @@ -751,43 +751,43 @@ func (container *Container) PhoneRepository() (repository repositories.PhoneRepo ) } -// SendScheduleRepository creates a new instance of repositories.SendScheduleRepository -func (container *Container) SendScheduleRepository() repositories.SendScheduleRepository { - container.logger.Debug("creating GORM repositories.SendScheduleRepository") - return repositories.NewGormSendScheduleRepository( +// MessageSendScheduleRepository creates a new instance of repositories.MessageSendScheduleRepository +func (container *Container) MessageSendScheduleRepository() repositories.MessageSendScheduleRepository { + container.logger.Debug("creating GORM repositories.MessageSendScheduleRepository") + return repositories.NewGormMessageSendScheduleRepository( container.Logger(), container.Tracer(), container.DB(), ) } -// SendScheduleService creates a new instance of services.SendScheduleService -func (container *Container) SendScheduleService() *services.SendScheduleService { - container.logger.Debug("creating services.SendScheduleService") - return services.NewSendScheduleService( +// MessageSendScheduleService creates a new instance of services.MessageSendScheduleService +func (container *Container) MessageSendScheduleService() *services.MessageSendScheduleService { + container.logger.Debug("creating services.MessageSendScheduleService") + return services.NewMessageSendScheduleService( container.Logger(), container.Tracer(), - container.SendScheduleRepository(), + container.MessageSendScheduleRepository(), ) } -// SendScheduleHandlerValidator creates a new instance of validators.SendScheduleHandlerValidator -func (container *Container) SendScheduleHandlerValidator() *validators.SendScheduleHandlerValidator { - container.logger.Debug("creating validators.SendScheduleHandlerValidator") - return validators.NewSendScheduleHandlerValidator( +// MessageSendScheduleHandlerValidator creates a new instance of validators.MessageSendScheduleHandlerValidator +func (container *Container) MessageSendScheduleHandlerValidator() *validators.MessageSendScheduleHandlerValidator { + container.logger.Debug("creating validators.MessageSendScheduleHandlerValidator") + return validators.NewMessageSendScheduleHandlerValidator( container.Logger(), container.Tracer(), ) } -// SendScheduleHandler creates a new instance of handlers.SendScheduleHandler -func (container *Container) SendScheduleHandler() *handlers.SendScheduleHandler { - container.logger.Debug("creating handlers.SendScheduleHandler") - return handlers.NewSendScheduleHandler( +// MessageSendScheduleHandler creates a new instance of handlers.MessageSendScheduleHandler +func (container *Container) MessageSendScheduleHandler() *handlers.MessageSendScheduleHandler { + container.logger.Debug("creating handlers.MessageSendScheduleHandler") + return handlers.NewMessageSendScheduleHandler( container.Logger(), container.Tracer(), - container.SendScheduleHandlerValidator(), - container.SendScheduleService(), + container.MessageSendScheduleHandlerValidator(), + container.MessageSendScheduleService(), container.EntitlementService(), ) } @@ -1147,13 +1147,13 @@ func (container *Container) RegisterMessageListeners() { } } -// RegisterSendScheduleListeners registers event listeners for listeners.SendScheduleListener -func (container *Container) RegisterSendScheduleListeners() { - container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.SendScheduleListener{})) - _, routes := listeners.NewSendScheduleListener( +// RegisterMessageSendScheduleListeners registers event listeners for listeners.MessageSendScheduleListener +func (container *Container) RegisterMessageSendScheduleListeners() { + container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.MessageSendScheduleListener{})) + _, routes := listeners.NewMessageSendScheduleListener( container.Logger(), container.Tracer(), - container.SendScheduleService(), + container.MessageSendScheduleService(), ) for event, handler := range routes { @@ -1574,7 +1574,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi container.FirebaseMessagingClient(), container.PhoneRepository(), container.PhoneNotificationRepository(), - container.SendScheduleRepository(), + container.MessageSendScheduleRepository(), container.EventDispatcher(), ) } @@ -1630,10 +1630,10 @@ func (container *Container) RegisterUserRoutes() { container.UserHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } -// RegisterSendScheduleRoutes registers routes for the /send-schedules prefix -func (container *Container) RegisterSendScheduleRoutes() { - container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.SendScheduleHandler{})) - container.SendScheduleHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) +// RegisterMessageSendScheduleRoutes registers routes for the /send-schedules prefix +func (container *Container) RegisterMessageSendScheduleRoutes() { + container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.MessageSendScheduleHandler{})) + container.MessageSendScheduleHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } // RegisterEventRoutes registers routes for the /events prefix diff --git a/api/pkg/entities/send_schedule.go b/api/pkg/entities/message_send_schedule.go similarity index 100% rename from api/pkg/entities/send_schedule.go rename to api/pkg/entities/message_send_schedule.go diff --git a/api/pkg/entities/send_schedule_test.go b/api/pkg/entities/message_send_schedule_test.go similarity index 100% rename from api/pkg/entities/send_schedule_test.go rename to api/pkg/entities/message_send_schedule_test.go diff --git a/api/pkg/handlers/send_schedule_handler.go b/api/pkg/handlers/message_send_schedule_handler.go similarity index 83% rename from api/pkg/handlers/send_schedule_handler.go rename to api/pkg/handlers/message_send_schedule_handler.go index ebf475a3..d7df8cdb 100644 --- a/api/pkg/handlers/send_schedule_handler.go +++ b/api/pkg/handlers/message_send_schedule_handler.go @@ -14,26 +14,26 @@ import ( "github.com/palantir/stacktrace" ) -// SendScheduleHandler handles HTTP requests for message send schedules. -type SendScheduleHandler struct { +// MessageSendScheduleHandler handles HTTP requests for message send schedules. +type MessageSendScheduleHandler struct { handler logger telemetry.Logger tracer telemetry.Tracer - validator *validators.SendScheduleHandlerValidator - service *services.SendScheduleService + validator *validators.MessageSendScheduleHandlerValidator + service *services.MessageSendScheduleService entitlementService *services.EntitlementService } -// NewSendScheduleHandler creates a new SendScheduleHandler. -func NewSendScheduleHandler( +// NewMessageSendScheduleHandler creates a new MessageSendScheduleHandler. +func NewMessageSendScheduleHandler( logger telemetry.Logger, tracer telemetry.Tracer, - validator *validators.SendScheduleHandlerValidator, - service *services.SendScheduleService, + validator *validators.MessageSendScheduleHandlerValidator, + service *services.MessageSendScheduleService, entitlementService *services.EntitlementService, -) *SendScheduleHandler { - return &SendScheduleHandler{ - logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandler{})), +) *MessageSendScheduleHandler { + return &MessageSendScheduleHandler{ + logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleHandler{})), tracer: tracer, validator: validator, service: service, @@ -42,7 +42,7 @@ func NewSendScheduleHandler( } // RegisterRoutes registers send schedule routes. -func (h *SendScheduleHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { +func (h *MessageSendScheduleHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { router.Get("/v1/send-schedules", h.computeRoute(middlewares, h.Index)...) router.Post("/v1/send-schedules", h.computeRoute(middlewares, h.Store)...) router.Put("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Update)...) @@ -60,7 +60,7 @@ func (h *SendScheduleHandler) RegisterRoutes(router fiber.Router, middlewares .. // @Failure 401 {object} responses.Unauthorized // @Failure 500 {object} responses.InternalServerError // @Router /send-schedules [get] -func (h *SendScheduleHandler) Index(c *fiber.Ctx) error { +func (h *MessageSendScheduleHandler) Index(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() @@ -81,15 +81,15 @@ func (h *SendScheduleHandler) Index(c *fiber.Ctx) error { // @Tags Send Schedules // @Accept json // @Produce json -// @Param payload body requests.SendScheduleStore true "Payload of new send schedule." -// @Success 201 {object} responses.SendScheduleResponse +// @Param payload body requests.MessageSendScheduleStore true "Payload of new send schedule." +// @Success 201 {object} responses.MessageSendScheduleResponse // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized // @Failure 402 {object} responses.BadRequest // @Failure 422 {object} responses.UnprocessableEntity // @Failure 500 {object} responses.InternalServerError // @Router /send-schedules [post] -func (h *SendScheduleHandler) Store(c *fiber.Ctx) error { +func (h *MessageSendScheduleHandler) Store(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() @@ -110,7 +110,7 @@ func (h *SendScheduleHandler) Store(c *fiber.Ctx) error { return h.responsePaymentRequired(c, result.Message) } - var request requests.SendScheduleStore + var request requests.MessageSendScheduleStore if err := c.BodyParser(&request); err != nil { return h.responseBadRequest(c, err) } @@ -143,15 +143,15 @@ func (h *SendScheduleHandler) Store(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param scheduleID path string true "Schedule ID" -// @Param payload body requests.SendScheduleStore true "Payload of updated send schedule." -// @Success 200 {object} responses.SendScheduleResponse +// @Param payload body requests.MessageSendScheduleStore true "Payload of updated send schedule." +// @Success 200 {object} responses.MessageSendScheduleResponse // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized // @Failure 404 {object} responses.NotFound // @Failure 422 {object} responses.UnprocessableEntity // @Failure 500 {object} responses.InternalServerError // @Router /send-schedules/{scheduleID} [put] -func (h *SendScheduleHandler) Update(c *fiber.Ctx) error { +func (h *MessageSendScheduleHandler) Update(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() @@ -160,7 +160,7 @@ func (h *SendScheduleHandler) Update(c *fiber.Ctx) error { return h.responseBadRequest(c, err) } - var request requests.SendScheduleStore + var request requests.MessageSendScheduleStore if err = c.BodyParser(&request); err != nil { return h.responseBadRequest(c, err) } @@ -201,7 +201,7 @@ func (h *SendScheduleHandler) Update(c *fiber.Ctx) error { // @Failure 404 {object} responses.NotFound // @Failure 500 {object} responses.InternalServerError // @Router /send-schedules/{scheduleID} [delete] -func (h *SendScheduleHandler) Delete(c *fiber.Ctx) error { +func (h *MessageSendScheduleHandler) Delete(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() diff --git a/api/pkg/listeners/send_schedule_listener.go b/api/pkg/listeners/message_send_schedule_listener.go similarity index 68% rename from api/pkg/listeners/send_schedule_listener.go rename to api/pkg/listeners/message_send_schedule_listener.go index ac65eddd..61e57b24 100644 --- a/api/pkg/listeners/send_schedule_listener.go +++ b/api/pkg/listeners/message_send_schedule_listener.go @@ -11,21 +11,21 @@ import ( "github.com/palantir/stacktrace" ) -// SendScheduleListener handles cloud events related to message send schedules. -type SendScheduleListener struct { +// MessageSendScheduleListener handles cloud events related to message send schedules. +type MessageSendScheduleListener struct { logger telemetry.Logger tracer telemetry.Tracer - service *services.SendScheduleService + service *services.MessageSendScheduleService } -// NewSendScheduleListener creates a new instance of SendScheduleListener. -func NewSendScheduleListener( +// NewMessageSendScheduleListener creates a new instance of MessageSendScheduleListener. +func NewMessageSendScheduleListener( logger telemetry.Logger, tracer telemetry.Tracer, - service *services.SendScheduleService, -) (l *SendScheduleListener, routes map[string]events.EventListener) { - l = &SendScheduleListener{ - logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleListener{})), + service *services.MessageSendScheduleService, +) (l *MessageSendScheduleListener, routes map[string]events.EventListener) { + l = &MessageSendScheduleListener{ + logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleListener{})), tracer: tracer, service: service, } @@ -36,7 +36,7 @@ func NewSendScheduleListener( } // onUserAccountDeleted removes all message send schedules for a deleted user account. -func (listener *SendScheduleListener) onUserAccountDeleted( +func (listener *MessageSendScheduleListener) onUserAccountDeleted( ctx context.Context, event cloudevents.Event, ) error { diff --git a/api/pkg/repositories/gorm_send_schedule_repository.go b/api/pkg/repositories/gorm_message_send_schedule_repository.go similarity index 82% rename from api/pkg/repositories/gorm_send_schedule_repository.go rename to api/pkg/repositories/gorm_message_send_schedule_repository.go index 4ee3c408..919eb6a9 100644 --- a/api/pkg/repositories/gorm_send_schedule_repository.go +++ b/api/pkg/repositories/gorm_message_send_schedule_repository.go @@ -12,28 +12,28 @@ import ( "gorm.io/gorm" ) -// gormSendScheduleRepository persists and loads entities.MessageSendSchedule using GORM. -type gormSendScheduleRepository struct { +// gormMessageSendScheduleRepository persists and loads entities.MessageSendSchedule using GORM. +type gormMessageSendScheduleRepository struct { logger telemetry.Logger tracer telemetry.Tracer db *gorm.DB } -// NewGormSendScheduleRepository creates a new GORM-backed SendScheduleRepository. -func NewGormSendScheduleRepository( +// NewGormMessageSendScheduleRepository creates a new GORM-backed MessageSendScheduleRepository. +func NewGormMessageSendScheduleRepository( logger telemetry.Logger, tracer telemetry.Tracer, db *gorm.DB, -) SendScheduleRepository { - return &gormSendScheduleRepository{ - logger: logger.WithService(fmt.Sprintf("%T", &gormSendScheduleRepository{})), +) MessageSendScheduleRepository { + return &gormMessageSendScheduleRepository{ + logger: logger.WithService(fmt.Sprintf("%T", &gormMessageSendScheduleRepository{})), tracer: tracer, db: db, } } // Store saves a new message send schedule. -func (r *gormSendScheduleRepository) Store( +func (r *gormMessageSendScheduleRepository) Store( ctx context.Context, schedule *entities.MessageSendSchedule, ) error { @@ -51,7 +51,7 @@ func (r *gormSendScheduleRepository) Store( } // Update persists changes to an existing message send schedule. -func (r *gormSendScheduleRepository) Update( +func (r *gormMessageSendScheduleRepository) Update( ctx context.Context, schedule *entities.MessageSendSchedule, ) error { @@ -69,7 +69,7 @@ func (r *gormSendScheduleRepository) Update( } // Load fetches a message send schedule by user ID and schedule ID. -func (r *gormSendScheduleRepository) Load( +func (r *gormMessageSendScheduleRepository) Load( ctx context.Context, userID entities.UserID, scheduleID uuid.UUID, @@ -104,7 +104,7 @@ func (r *gormSendScheduleRepository) Load( } // Index lists all message send schedules owned by the given user. -func (r *gormSendScheduleRepository) Index( +func (r *gormMessageSendScheduleRepository) Index( ctx context.Context, userID entities.UserID, ) ([]entities.MessageSendSchedule, error) { @@ -126,7 +126,7 @@ func (r *gormSendScheduleRepository) Index( } // Delete removes a message send schedule owned by the given user. -func (r *gormSendScheduleRepository) Delete( +func (r *gormMessageSendScheduleRepository) Delete( ctx context.Context, userID entities.UserID, scheduleID uuid.UUID, @@ -148,7 +148,7 @@ func (r *gormSendScheduleRepository) Delete( } // DeleteAllForUser removes all message send schedules owned by the given user. -func (r *gormSendScheduleRepository) DeleteAllForUser( +func (r *gormMessageSendScheduleRepository) DeleteAllForUser( ctx context.Context, userID entities.UserID, ) error { @@ -168,7 +168,7 @@ func (r *gormSendScheduleRepository) DeleteAllForUser( } // CountByUser returns the number of schedules owned by a user. -func (r *gormSendScheduleRepository) CountByUser( +func (r *gormMessageSendScheduleRepository) CountByUser( ctx context.Context, userID entities.UserID, ) (int, error) { diff --git a/api/pkg/repositories/send_schedule_repository.go b/api/pkg/repositories/message_send_schedule_repository.go similarity index 89% rename from api/pkg/repositories/send_schedule_repository.go rename to api/pkg/repositories/message_send_schedule_repository.go index d57b42d7..82ef4518 100644 --- a/api/pkg/repositories/send_schedule_repository.go +++ b/api/pkg/repositories/message_send_schedule_repository.go @@ -7,8 +7,8 @@ import ( "github.com/google/uuid" ) -// SendScheduleRepository loads and persists entities.MessageSendSchedule. -type SendScheduleRepository interface { +// MessageSendScheduleRepository loads and persists entities.MessageSendSchedule. +type MessageSendScheduleRepository interface { // Store persists a new message send schedule. Store(ctx context.Context, schedule *entities.MessageSendSchedule) error diff --git a/api/pkg/requests/message_send_schedule_store_request.go b/api/pkg/requests/message_send_schedule_store_request.go new file mode 100644 index 00000000..1796e4bd --- /dev/null +++ b/api/pkg/requests/message_send_schedule_store_request.go @@ -0,0 +1,52 @@ +package requests + +import ( + "sort" + "strings" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/services" +) + +// MessageSendScheduleWindow represents a single request window for a message send schedule. +type MessageSendScheduleWindow struct { + DayOfWeek int `json:"day_of_week"` + StartMinute int `json:"start_minute"` + EndMinute int `json:"end_minute"` +} + +// MessageSendScheduleStore contains the payload used to create or update a message send schedule. +type MessageSendScheduleStore struct { + request + Name string `json:"name"` + Timezone string `json:"timezone"` + IsActive bool `json:"is_active"` + Windows []MessageSendScheduleWindow `json:"windows"` +} + +// Sanitize trims and sorts the message send schedule payload before validation. +func (input *MessageSendScheduleStore) Sanitize() MessageSendScheduleStore { + input.Name = strings.TrimSpace(input.Name) + input.Timezone = strings.TrimSpace(input.Timezone) + windows := make([]MessageSendScheduleWindow, 0, len(input.Windows)) + for _, item := range input.Windows { + windows = append(windows, MessageSendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute}) + } + sort.SliceStable(windows, func(i, j int) bool { + if windows[i].DayOfWeek == windows[j].DayOfWeek { + return windows[i].StartMinute < windows[j].StartMinute + } + return windows[i].DayOfWeek < windows[j].DayOfWeek + }) + input.Windows = windows + return *input +} + +// ToParams converts the request payload into message send schedule service params. +func (input *MessageSendScheduleStore) ToParams(user entities.AuthContext) *services.MessageSendScheduleUpsertParams { + windows := make([]entities.MessageSendScheduleWindow, 0, len(input.Windows)) + for _, item := range input.Windows { + windows = append(windows, entities.MessageSendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute}) + } + return &services.MessageSendScheduleUpsertParams{UserID: user.ID, Name: input.Name, Timezone: input.Timezone, IsActive: input.IsActive, Windows: windows} +} diff --git a/api/pkg/requests/send_schedule_store_request.go b/api/pkg/requests/send_schedule_store_request.go deleted file mode 100644 index 8a6a2c3d..00000000 --- a/api/pkg/requests/send_schedule_store_request.go +++ /dev/null @@ -1,48 +0,0 @@ -package requests - -import ( - "sort" - "strings" - - "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/NdoleStudio/httpsms/pkg/services" -) - -type SendScheduleWindow struct { - DayOfWeek int `json:"day_of_week"` - StartMinute int `json:"start_minute"` - EndMinute int `json:"end_minute"` -} - -type SendScheduleStore struct { - request - Name string `json:"name"` - Timezone string `json:"timezone"` - IsActive bool `json:"is_active"` - Windows []SendScheduleWindow `json:"windows"` -} - -func (input *SendScheduleStore) Sanitize() SendScheduleStore { - input.Name = strings.TrimSpace(input.Name) - input.Timezone = strings.TrimSpace(input.Timezone) - windows := make([]SendScheduleWindow, 0, len(input.Windows)) - for _, item := range input.Windows { - windows = append(windows, SendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute}) - } - sort.SliceStable(windows, func(i, j int) bool { - if windows[i].DayOfWeek == windows[j].DayOfWeek { - return windows[i].StartMinute < windows[j].StartMinute - } - return windows[i].DayOfWeek < windows[j].DayOfWeek - }) - input.Windows = windows - return *input -} - -func (input *SendScheduleStore) ToParams(user entities.AuthContext) *services.SendScheduleUpsertParams { - windows := make([]entities.MessageSendScheduleWindow, 0, len(input.Windows)) - for _, item := range input.Windows { - windows = append(windows, entities.MessageSendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute}) - } - return &services.SendScheduleUpsertParams{UserID: user.ID, Name: input.Name, Timezone: input.Timezone, IsActive: input.IsActive, Windows: windows} -} diff --git a/api/pkg/responses/message_send_schedule_responses.go b/api/pkg/responses/message_send_schedule_responses.go new file mode 100644 index 00000000..630dba13 --- /dev/null +++ b/api/pkg/responses/message_send_schedule_responses.go @@ -0,0 +1,15 @@ +package responses + +import "github.com/NdoleStudio/httpsms/pkg/entities" + +// MessageSendSchedulesResponse represents a collection of message send schedules. +type MessageSendSchedulesResponse struct { + response + Data []entities.MessageSendSchedule `json:"data"` +} + +// MessageSendScheduleResponse represents a single message send schedule. +type MessageSendScheduleResponse struct { + response + Data entities.MessageSendSchedule `json:"data"` +} diff --git a/api/pkg/responses/send_schedule_responses.go b/api/pkg/responses/send_schedule_responses.go deleted file mode 100644 index 9834de86..00000000 --- a/api/pkg/responses/send_schedule_responses.go +++ /dev/null @@ -1,13 +0,0 @@ -package responses - -import "github.com/NdoleStudio/httpsms/pkg/entities" - -type SendSchedulesResponse struct { - response - Data []entities.MessageSendSchedule `json:"data"` -} - -type SendScheduleResponse struct { - response - Data entities.MessageSendSchedule `json:"data"` -} diff --git a/api/pkg/services/send_schedule_service.go b/api/pkg/services/message_send_schedule_service.go similarity index 78% rename from api/pkg/services/send_schedule_service.go rename to api/pkg/services/message_send_schedule_service.go index b984b32d..e9140b5d 100644 --- a/api/pkg/services/send_schedule_service.go +++ b/api/pkg/services/message_send_schedule_service.go @@ -13,29 +13,29 @@ import ( "github.com/palantir/stacktrace" ) -// SendScheduleService manages message send schedules for a user. -type SendScheduleService struct { +// MessageSendScheduleService manages message send schedules for a user. +type MessageSendScheduleService struct { service logger telemetry.Logger tracer telemetry.Tracer - repository repositories.SendScheduleRepository + repository repositories.MessageSendScheduleRepository } -// NewSendScheduleService creates a new SendScheduleService. -func NewSendScheduleService( +// NewMessageSendScheduleService creates a new MessageSendScheduleService. +func NewMessageSendScheduleService( logger telemetry.Logger, tracer telemetry.Tracer, - repository repositories.SendScheduleRepository, -) *SendScheduleService { - return &SendScheduleService{ - logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleService{})), + repository repositories.MessageSendScheduleRepository, +) *MessageSendScheduleService { + return &MessageSendScheduleService{ + logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleService{})), tracer: tracer, repository: repository, } } -// SendScheduleUpsertParams contains the fields required to create or update a message send schedule. -type SendScheduleUpsertParams struct { +// MessageSendScheduleUpsertParams contains the fields required to create or update a message send schedule. +type MessageSendScheduleUpsertParams struct { UserID entities.UserID Name string Timezone string @@ -44,7 +44,7 @@ type SendScheduleUpsertParams struct { } // Index returns all message send schedules for a user. -func (service *SendScheduleService) Index( +func (service *MessageSendScheduleService) Index( ctx context.Context, userID entities.UserID, ) ([]entities.MessageSendSchedule, error) { @@ -52,7 +52,7 @@ func (service *SendScheduleService) Index( } // CountByUser returns the number of schedules owned by a user. -func (service *SendScheduleService) CountByUser( +func (service *MessageSendScheduleService) CountByUser( ctx context.Context, userID entities.UserID, ) (int, error) { @@ -60,7 +60,7 @@ func (service *SendScheduleService) CountByUser( } // Load returns a single message send schedule for a user. -func (service *SendScheduleService) Load( +func (service *MessageSendScheduleService) Load( ctx context.Context, userID entities.UserID, scheduleID uuid.UUID, @@ -69,9 +69,9 @@ func (service *SendScheduleService) Load( } // Store creates a new message send schedule. -func (service *SendScheduleService) Store( +func (service *MessageSendScheduleService) Store( ctx context.Context, - params *SendScheduleUpsertParams, + params *MessageSendScheduleUpsertParams, ) (*entities.MessageSendSchedule, error) { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -101,11 +101,11 @@ func (service *SendScheduleService) Store( } // Update updates an existing message send schedule. -func (service *SendScheduleService) Update( +func (service *MessageSendScheduleService) Update( ctx context.Context, userID entities.UserID, scheduleID uuid.UUID, - params *SendScheduleUpsertParams, + params *MessageSendScheduleUpsertParams, ) (*entities.MessageSendSchedule, error) { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -135,7 +135,7 @@ func (service *SendScheduleService) Update( } // Delete removes a message send schedule for a user. -func (service *SendScheduleService) Delete( +func (service *MessageSendScheduleService) Delete( ctx context.Context, userID entities.UserID, scheduleID uuid.UUID, @@ -168,7 +168,7 @@ func sanitizeWindows( } // DeleteAllForUser removes all message send schedules owned by a user. -func (service *SendScheduleService) DeleteAllForUser( +func (service *MessageSendScheduleService) DeleteAllForUser( ctx context.Context, userID entities.UserID, ) error { diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 24db0de5..9e6fb296 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -20,13 +20,13 @@ import ( // PhoneNotificationService sends out notifications to mobile phones type PhoneNotificationService struct { service - logger telemetry.Logger - tracer telemetry.Tracer - phoneNotificationRepository repositories.PhoneNotificationRepository - phoneRepository repositories.PhoneRepository - sendScheduleRepository repositories.SendScheduleRepository - messagingClient *messaging.Client - eventDispatcher *EventDispatcher + logger telemetry.Logger + tracer telemetry.Tracer + phoneNotificationRepository repositories.PhoneNotificationRepository + phoneRepository repositories.PhoneRepository + messageSendScheduleRepository repositories.MessageSendScheduleRepository + messagingClient *messaging.Client + eventDispatcher *EventDispatcher } // NewNotificationService creates a new PhoneNotificationService @@ -36,17 +36,17 @@ func NewNotificationService( messagingClient *messaging.Client, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, - sendScheduleRepository repositories.SendScheduleRepository, + messageSendScheduleRepository repositories.MessageSendScheduleRepository, dispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ - logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), - tracer: tracer, - messagingClient: messagingClient, - phoneNotificationRepository: phoneNotificationRepository, - phoneRepository: phoneRepository, - sendScheduleRepository: sendScheduleRepository, - eventDispatcher: dispatcher, + logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), + tracer: tracer, + messagingClient: messagingClient, + phoneNotificationRepository: phoneNotificationRepository, + phoneRepository: phoneRepository, + messageSendScheduleRepository: messageSendScheduleRepository, + eventDispatcher: dispatcher, } } @@ -228,7 +228,7 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P var schedule *entities.MessageSendSchedule if phone.ScheduleID != nil { - schedule, err = service.sendScheduleRepository.Load(ctx, params.UserID, *phone.ScheduleID) + schedule, err = service.messageSendScheduleRepository.Load(ctx, params.UserID, *phone.ScheduleID) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { schedule = nil err = nil diff --git a/api/pkg/validators/send_schedule_handler_validator.go b/api/pkg/validators/message_send_schedule_handler_validator.go similarity index 69% rename from api/pkg/validators/send_schedule_handler_validator.go rename to api/pkg/validators/message_send_schedule_handler_validator.go index d402fa5c..00e405ca 100644 --- a/api/pkg/validators/send_schedule_handler_validator.go +++ b/api/pkg/validators/message_send_schedule_handler_validator.go @@ -14,28 +14,28 @@ import ( const maxWindowsPerDay = 6 -// SendScheduleHandlerValidator validates send schedule HTTP requests. -type SendScheduleHandlerValidator struct { +// MessageSendScheduleHandlerValidator validates send schedule HTTP requests. +type MessageSendScheduleHandlerValidator struct { validator logger telemetry.Logger tracer telemetry.Tracer } -// NewSendScheduleHandlerValidator creates a new SendScheduleHandlerValidator. -func NewSendScheduleHandlerValidator( +// NewMessageSendScheduleHandlerValidator creates a new MessageSendScheduleHandlerValidator. +func NewMessageSendScheduleHandlerValidator( logger telemetry.Logger, tracer telemetry.Tracer, -) *SendScheduleHandlerValidator { - return &SendScheduleHandlerValidator{ - logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandlerValidator{})), +) *MessageSendScheduleHandlerValidator { + return &MessageSendScheduleHandlerValidator{ + logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleHandlerValidator{})), tracer: tracer, } } // ValidateStore validates a send schedule create or update request. -func (validator *SendScheduleHandlerValidator) ValidateStore( +func (validator *MessageSendScheduleHandlerValidator) ValidateStore( _ context.Context, - request requests.SendScheduleStore, + request requests.MessageSendScheduleStore, ) url.Values { v := govalidator.New(govalidator.Options{ Data: &request, @@ -57,9 +57,9 @@ func (validator *SendScheduleHandlerValidator) ValidateStore( return result } -func (validator *SendScheduleHandlerValidator) validateWindows( +func (validator *MessageSendScheduleHandlerValidator) validateWindows( result url.Values, - windows []requests.SendScheduleWindow, + windows []requests.MessageSendScheduleWindow, ) { windowsPerDay := make(map[int]int) @@ -73,10 +73,10 @@ func (validator *SendScheduleHandlerValidator) validateWindows( validator.validateOverlappingWindows(result, windows) } -func (validator *SendScheduleHandlerValidator) validateDayOfWeek( +func (validator *MessageSendScheduleHandlerValidator) validateDayOfWeek( result url.Values, index int, - item requests.SendScheduleWindow, + item requests.MessageSendScheduleWindow, windowsPerDay map[int]int, ) { if item.DayOfWeek < 0 || item.DayOfWeek > 6 { @@ -93,41 +93,41 @@ func (validator *SendScheduleHandlerValidator) validateDayOfWeek( } } -func (validator *SendScheduleHandlerValidator) validateStartMinute( +func (validator *MessageSendScheduleHandlerValidator) validateStartMinute( result url.Values, index int, - item requests.SendScheduleWindow, + item requests.MessageSendScheduleWindow, ) { if item.StartMinute < 0 || item.StartMinute > 1439 { result.Add("windows", fmt.Sprintf("windows[%d].start_minute must be between 0 and 1439", index)) } } -func (validator *SendScheduleHandlerValidator) validateEndMinute( +func (validator *MessageSendScheduleHandlerValidator) validateEndMinute( result url.Values, index int, - item requests.SendScheduleWindow, + item requests.MessageSendScheduleWindow, ) { if item.EndMinute < 1 || item.EndMinute > 1440 { result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be between 1 and 1440", index)) } } -func (validator *SendScheduleHandlerValidator) validateWindowRange( +func (validator *MessageSendScheduleHandlerValidator) validateWindowRange( result url.Values, index int, - item requests.SendScheduleWindow, + item requests.MessageSendScheduleWindow, ) { if item.EndMinute <= item.StartMinute { result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be greater than start_minute", index)) } } -func (validator *SendScheduleHandlerValidator) validateOverlappingWindows( +func (validator *MessageSendScheduleHandlerValidator) validateOverlappingWindows( result url.Values, - windows []requests.SendScheduleWindow, + windows []requests.MessageSendScheduleWindow, ) { - grouped := make(map[int][]requests.SendScheduleWindow) + grouped := make(map[int][]requests.MessageSendScheduleWindow) for _, item := range windows { if item.DayOfWeek < 0 || item.DayOfWeek > 6 { From 93d8ac9f6dacddac8878a5077ed9a70b373dbd5a Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 23:24:01 +0300 Subject: [PATCH 34/35] docs: link to existing feature pages instead of re-explaining scheduling and send rate Link to https://docs.httpsms.com/features/scheduling-sms-messages and https://docs.httpsms.com/features/control-sms-send-rate instead of repeating their explanations. Add detailed MessageSendSchedule (send windows) section since it's the only new feature without its own docs page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-05-03-scheduling-send-refactor.md | 7 +- .../2026-05-03-entitlement-service-design.md | 2 +- ...6-05-03-scheduling-send-refactor-design.md | 79 ++++++++++++++++++- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-05-03-scheduling-send-refactor.md b/docs/superpowers/plans/2026-05-03-scheduling-send-refactor.md index a85b46ed..f92b60ed 100644 --- a/docs/superpowers/plans/2026-05-03-scheduling-send-refactor.md +++ b/docs/superpowers/plans/2026-05-03-scheduling-send-refactor.md @@ -4,7 +4,12 @@ **Goal:** Allow users to send SMS at an exact time (bypassing scheduling) when `SendAt` is specified, and replace the 1-second bulk hack with rate-based dispatch delays. -**Architecture:** Add a transient `ExactSendTime` flag flowing through the event system. When true, bypass rate-limit and schedule window logic in notification scheduling. For bulk sends without explicit time, compute dispatch delay from `MessagesPerMinute` per-phone instead of hardcoded 1s. +**Related docs:** + +- [Scheduling SMS Messages](https://docs.httpsms.com/features/scheduling-sms-messages) — the existing `SendAt`/`SendTime` feature +- [Control SMS Send Rate](https://docs.httpsms.com/features/control-sms-send-rate) — the existing `MessagesPerMinute` rate-limiting feature + +**Architecture:** Add a transient `ExactSendTime` flag flowing through the event system. When true, bypass [rate-limit](https://docs.httpsms.com/features/control-sms-send-rate) and schedule window logic in notification scheduling. For bulk sends without explicit time, compute dispatch delay from `MessagesPerMinute` per-phone instead of hardcoded 1s. **Tech Stack:** Go, Fiber, GORM, CockroachDB, Google Cloud Tasks (CloudEvents) diff --git a/docs/superpowers/specs/2026-05-03-entitlement-service-design.md b/docs/superpowers/specs/2026-05-03-entitlement-service-design.md index c22b8a9b..8ec3893c 100644 --- a/docs/superpowers/specs/2026-05-03-entitlement-service-design.md +++ b/docs/superpowers/specs/2026-05-03-entitlement-service-design.md @@ -2,7 +2,7 @@ ## Problem -The send schedule feature (and future features) need usage limits based on the user's subscription plan. Free users should be limited to 1 send schedule; paid users get unlimited. The system must be: +The [MessageSendSchedule](./2026-05-03-scheduling-send-refactor-design.md#messagesendschedule-send-windows--new-feature) feature (and future features) need usage limits based on the user's subscription plan. Free users should be limited to 1 send schedule; paid users get unlimited. The system must be: - **Scalable**: Easy to add new entity limits without architectural changes - **Configurable**: Disabled by default for self-hosted deployments, enabled via env var for cloud diff --git a/docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md b/docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md index 34d78445..1a02bc08 100644 --- a/docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md +++ b/docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md @@ -1,10 +1,15 @@ # Scheduling Send Refactor Design +## Related Documentation + +- [Scheduling SMS Messages](https://docs.httpsms.com/features/scheduling-sms-messages) — existing `SendAt`/`SendTime` scheduling feature +- [Control SMS Send Rate](https://docs.httpsms.com/features/control-sms-send-rate) — existing `MessagesPerMinute` rate-limiting feature + ## Problem Statement The current SMS scheduling logic has two issues: -1. **No way to send at an exact time without scheduling interference.** When a user specifies a `SendTime`/`SendAt`, the system still applies rate-limiting and schedule window logic, which may shift the actual send time. +1. **No way to send at an exact time without scheduling interference.** When a user specifies a `SendTime`/`SendAt` (see [Scheduling SMS Messages](https://docs.httpsms.com/features/scheduling-sms-messages)), the system still applies rate-limiting and schedule window logic, which may shift the actual send time. 2. **Bulk message contention.** When bulk messages (API or CSV) are sent, all events arrive at the Cloud Tasks queue near-simultaneously, causing DB serialization conflicts in `PhoneNotificationRepository.Schedule()` (which uses `SELECT ... ORDER BY scheduled_at DESC` in a transaction). The current workaround is a hardcoded 1-second spacing hack. @@ -12,8 +17,8 @@ The current SMS scheduling logic has two issues: ### Core Principle -- **Explicit `SendTime`** = send at exactly that time, bypass all scheduling logic. -- **No `SendTime`** = apply full scheduling logic (rate-limit + schedule windows), with rate-based Cloud Task dispatch delay to prevent DB contention. +- **Explicit `SendTime`** = send at exactly that time, bypass all scheduling logic. See [Scheduling SMS Messages](https://docs.httpsms.com/features/scheduling-sms-messages) for how `SendAt` works. +- **No `SendTime`** = apply full scheduling logic ([rate-limit](https://docs.httpsms.com/features/control-sms-send-rate) + schedule windows), with rate-based Cloud Task dispatch delay to prevent DB contention. ### Design @@ -111,7 +116,73 @@ User sends request ### What Does NOT Change - The `MessageSendSchedule` entity and its `ResolveScheduledAt()` logic -- The `SendScheduleService` CRUD operations +- The `MessageSendScheduleService` CRUD operations - The phone notification entity schema (no new DB columns) - The Android app behavior - The web frontend (models auto-generated from Swagger) + +--- + +## MessageSendSchedule (Send Windows) — New Feature + +This is the only scheduling mechanism that does **not** have a dedicated documentation page yet. Unlike [Scheduling SMS Messages](https://docs.httpsms.com/features/scheduling-sms-messages) (one-time `SendAt`) and [Control SMS Send Rate](https://docs.httpsms.com/features/control-sms-send-rate) (`MessagesPerMinute` throttling), MessageSendSchedule defines **recurring availability windows** that control when a phone is allowed to send outgoing SMS messages. + +### Concept + +A `MessageSendSchedule` is a named set of time windows (per day of week) that define when the phone can send. Messages arriving outside those windows are delayed until the next available window opens. + +### Entity + +```go +type MessageSendSchedule struct { + ID uuid.UUID + UserID UserID + Name string // e.g. "Business Hours" + Timezone string // IANA timezone e.g. "Europe/Tallinn" + IsActive bool + Windows []MessageSendScheduleWindow // per-day availability slots + CreatedAt time.Time + UpdatedAt time.Time +} + +type MessageSendScheduleWindow struct { + DayOfWeek int // 0=Sunday, 6=Saturday + StartMinute int // minutes from midnight (e.g. 540 = 9:00) + EndMinute int // minutes from midnight (e.g. 1020 = 17:00) +} +``` + +### How It Works + +1. A user creates a schedule via `POST /v1/send-schedules` with a name, timezone, and one or more windows. +2. The schedule is linked to a phone via a `ScheduleID` field on the phone entity. +3. When a message is queued (without an explicit `SendAt`), the `PhoneNotificationRepository.Schedule()` method calls `MessageSendSchedule.ResolveScheduledAt(now)` to find the next allowed send time. +4. If the current time falls within a window, the message sends immediately. If not, it's delayed to the start of the next available window. + +### API Endpoints + +| Method | Endpoint | Description | +| ------ | --------------------------------- | --------------------------- | +| GET | `/v1/send-schedules` | List all user schedules | +| POST | `/v1/send-schedules` | Create a new schedule | +| PUT | `/v1/send-schedules/{scheduleID}` | Update an existing schedule | +| DELETE | `/v1/send-schedules/{scheduleID}` | Delete a schedule | + +### Validation Rules + +- `name`: required, 2–100 characters +- `timezone`: required, valid IANA timezone +- `windows[].day_of_week`: 0–6 +- `windows[].start_minute`: 0–1439 +- `windows[].end_minute`: 1–1440, must be greater than `start_minute` +- Max 6 windows per day +- No overlapping windows on the same day + +### Entitlement + +Free users are limited to 1 schedule. Paid users get unlimited schedules. Enforced via `EntitlementService.Check()` in the handler before creation. + +### Interaction with Other Scheduling Features + +- **[Scheduling SMS Messages](https://docs.httpsms.com/features/scheduling-sms-messages)** (`SendAt`): When provided, bypasses send windows entirely (exact send time). +- **[Control SMS Send Rate](https://docs.httpsms.com/features/control-sms-send-rate)** (`MessagesPerMinute`): Applied independently — rate-limiting still applies within allowed windows. Both constraints compose: the message must be within a window AND respect the rate limit. From 2b9a45f2391e6b902b4005593784a9310d5f6897 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 3 May 2026 23:42:56 +0300 Subject: [PATCH 35/35] fix: address PR review comments for send schedule feature - Add schedule ownership check in phone handler to prevent cross-user schedule_id assignment (security fix) - Fix OpenAPI annotation for Index endpoint to use wrapper response type - Fix 402 response annotation to use proper PaymentRequired type - Add PaymentRequired response type to responses package - Mark schedule_id as optional in PhoneUpsert request struct - Fix scheduleWindowError matching to use backend error format (day_of_week) - Remove unnecessary Promise.all wrapping in store actions - Remove unused limit query param from getSendSchedules - Treat active schedule with empty windows as inactive (send immediately) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/di/container.go | 1 + api/pkg/entities/message_send_schedule.go | 9 ++- .../handlers/message_send_schedule_handler.go | 4 +- api/pkg/handlers/phone_handler.go | 31 ++++++--- api/pkg/requests/phone_update_request.go | 2 +- api/pkg/responses/response.go | 6 ++ web/pages/settings/index.vue | 4 +- web/store/index.ts | 67 +++++++------------ 8 files changed, 68 insertions(+), 56 deletions(-) diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 66b57468..cf8360f4 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -1117,6 +1117,7 @@ func (container *Container) PhoneHandler() (handler *handlers.PhoneHandler) { container.Logger(), container.Tracer(), container.PhoneService(), + container.MessageSendScheduleService(), container.PhoneHandlerValidator(), ) } diff --git a/api/pkg/entities/message_send_schedule.go b/api/pkg/entities/message_send_schedule.go index 9ffbad12..17b8a7b7 100644 --- a/api/pkg/entities/message_send_schedule.go +++ b/api/pkg/entities/message_send_schedule.go @@ -27,9 +27,14 @@ type MessageSendSchedule struct { // ResolveScheduledAt returns the next allowed send time based on the schedule. // If the schedule is inactive, has no windows, or has an invalid timezone, -// the current time is returned in UTC. +// the current time is returned in UTC. An active schedule with no windows +// is treated as inactive (messages are sent immediately). func (schedule *MessageSendSchedule) ResolveScheduledAt(current time.Time) time.Time { - if schedule == nil || !schedule.IsActive || len(schedule.Windows) == 0 { + if schedule == nil || !schedule.IsActive { + return current.UTC() + } + + if len(schedule.Windows) == 0 { return current.UTC() } diff --git a/api/pkg/handlers/message_send_schedule_handler.go b/api/pkg/handlers/message_send_schedule_handler.go index d7df8cdb..a688987c 100644 --- a/api/pkg/handlers/message_send_schedule_handler.go +++ b/api/pkg/handlers/message_send_schedule_handler.go @@ -56,7 +56,7 @@ func (h *MessageSendScheduleHandler) RegisterRoutes(router fiber.Router, middlew // @Security ApiKeyAuth // @Tags Send Schedules // @Produce json -// @Success 200 {array} entities.MessageSendSchedule +// @Success 200 {object} responses.MessageSendSchedulesResponse // @Failure 401 {object} responses.Unauthorized // @Failure 500 {object} responses.InternalServerError // @Router /send-schedules [get] @@ -85,7 +85,7 @@ func (h *MessageSendScheduleHandler) Index(c *fiber.Ctx) error { // @Success 201 {object} responses.MessageSendScheduleResponse // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized -// @Failure 402 {object} responses.BadRequest +// @Failure 402 {object} responses.PaymentRequired // @Failure 422 {object} responses.UnprocessableEntity // @Failure 500 {object} responses.InternalServerError // @Router /send-schedules [post] diff --git a/api/pkg/handlers/phone_handler.go b/api/pkg/handlers/phone_handler.go index 9e5cbe1c..62ec35b7 100644 --- a/api/pkg/handlers/phone_handler.go +++ b/api/pkg/handlers/phone_handler.go @@ -2,10 +2,13 @@ package handlers import ( "fmt" + "net/url" + "strings" "github.com/NdoleStudio/httpsms/pkg/requests" "github.com/NdoleStudio/httpsms/pkg/validators" "github.com/davecgh/go-spew/spew" + "github.com/google/uuid" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -16,10 +19,11 @@ import ( // PhoneHandler handles phone http requests. type PhoneHandler struct { handler - logger telemetry.Logger - tracer telemetry.Tracer - service *services.PhoneService - validator *validators.PhoneHandlerValidator + logger telemetry.Logger + tracer telemetry.Tracer + service *services.PhoneService + scheduleService *services.MessageSendScheduleService + validator *validators.PhoneHandlerValidator } // NewPhoneHandler creates a new PhoneHandler @@ -27,13 +31,15 @@ func NewPhoneHandler( logger telemetry.Logger, tracer telemetry.Tracer, service *services.PhoneService, + scheduleService *services.MessageSendScheduleService, validator *validators.PhoneHandlerValidator, ) (h *PhoneHandler) { return &PhoneHandler{ - logger: logger.WithService(fmt.Sprintf("%T", h)), - tracer: tracer, - validator: validator, - service: service, + logger: logger.WithService(fmt.Sprintf("%T", h)), + tracer: tracer, + validator: validator, + service: service, + scheduleService: scheduleService, } } @@ -127,6 +133,15 @@ func (h *PhoneHandler) Upsert(c *fiber.Ctx) error { return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } + if request.ScheduleID != nil && strings.TrimSpace(*request.ScheduleID) != "" { + scheduleID, _ := uuid.Parse(strings.TrimSpace(*request.ScheduleID)) + if _, err := h.scheduleService.Load(ctx, h.userFromContext(c).ID, scheduleID); err != nil { + validationErrors := url.Values{} + validationErrors.Add("schedule_id", "schedule_id does not belong to the authenticated user or does not exist") + return h.responseUnprocessableEntity(c, validationErrors, "validation errors while updating phones") + } + } + phone, err := h.service.Upsert(ctx, request.ToUpsertParams(h.userFromContext(c), c.OriginalURL())) if err != nil { msg := fmt.Sprintf("cannot update phones with params [%+#v]", request) diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index bb710024..06876de8 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -31,7 +31,7 @@ type PhoneUpsert struct { // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot SIM string `json:"sim" example:"SIM1"` - ScheduleID *string `json:"schedule_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + ScheduleID *string `json:"schedule_id,omitempty" example:"32343a19-da5e-4b1b-a767-3298a73703cb" validate:"optional"` } // Sanitize sets defaults to MessageOutstanding diff --git a/api/pkg/responses/response.go b/api/pkg/responses/response.go index c61c919e..e23fea0f 100644 --- a/api/pkg/responses/response.go +++ b/api/pkg/responses/response.go @@ -38,6 +38,12 @@ type Unauthorized struct { Data string `json:"data" example:"Make sure your API key is set in the [X-API-Key] header in the request"` } +// PaymentRequired is the response with status code is 402 +type PaymentRequired struct { + Status string `json:"status" example:"error"` + Message string `json:"message" example:"You have reached the maximum number of allowed resources. Please upgrade your plan."` +} + // NoContent is the response when status code is 204 type NoContent struct { Status string `json:"status" example:"success"` diff --git a/web/pages/settings/index.vue b/web/pages/settings/index.vue index de6c0b05..cbe4fe6e 100644 --- a/web/pages/settings/index.vue +++ b/web/pages/settings/index.vue @@ -1779,10 +1779,10 @@ export default Vue.extend({ } const message = messages.find((x: string) => - x.includes(`Day of week ${index}`), + x.includes(`day_of_week ${index}`), ) return message - ? message.replace(`Day of week ${index}`, this.getWeekday(index)) + ? message.replace(`day_of_week ${index}`, this.getWeekday(index)) : null }, diff --git a/web/store/index.ts b/web/store/index.ts index 9e52a240..bd3071e6 100644 --- a/web/store/index.ts +++ b/web/store/index.ts @@ -1111,23 +1111,17 @@ export const actions = { getSendSchedules(context: ActionContext) { return new Promise>((resolve, reject) => { axios - .get(`/v1/send-schedules`, { - params: { - limit: 100, - }, - }) + .get(`/v1/send-schedules`) .then((response: AxiosResponse) => { resolve(response.data.data) }) .catch(async (error: AxiosError) => { - await Promise.all([ - context.dispatch('addNotification', { - message: - (error.response?.data as any)?.message ?? - 'Error while fetching send schedules', - type: 'error', - }), - ]) + await context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while fetching send schedules', + type: 'error', + }) reject(getErrorMessages(error)) }) }) @@ -1144,14 +1138,12 @@ export const actions = { resolve(response.data.data) }) .catch(async (error: AxiosError) => { - await Promise.all([ - context.dispatch('addNotification', { - message: - (error.response?.data as any)?.message ?? - 'Error while creating send schedule', - type: 'error', - }), - ]) + await context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while creating send schedule', + type: 'error', + }) reject(getErrorMessages(error)) }) }) @@ -1171,23 +1163,18 @@ export const actions = { resolve(response.data.data) }) .catch(async (error: AxiosError) => { - await Promise.all([ - context.dispatch('addNotification', { - message: - (error.response?.data as any)?.message ?? - 'Error while updating send schedule', - type: 'error', - }), - ]) + await context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while updating send schedule', + type: 'error', + }) reject(getErrorMessages(error)) }) }) }, - deleteSendSchedule( - context: ActionContext, - payload: string, - ) { + deleteSendSchedule(context: ActionContext, payload: string) { return new Promise((resolve, reject) => { axios .delete(`/v1/send-schedules/${payload}`) @@ -1195,14 +1182,12 @@ export const actions = { resolve() }) .catch(async (error: AxiosError) => { - await Promise.all([ - context.dispatch('addNotification', { - message: - (error.response?.data as any)?.message ?? - 'Error while deleting send schedule', - type: 'error', - }), - ]) + await context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while deleting send schedule', + type: 'error', + }) reject(getErrorMessages(error)) }) })