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/docs/docs.go b/api/docs/docs.go index ab5a4ea6..018614fa 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -2179,6 +2179,242 @@ const docTemplate = `{ } } }, + "/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": [ @@ -3288,6 +3524,77 @@ const docTemplate = `{ } } }, + "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": [ @@ -3364,6 +3671,7 @@ const docTemplate = `{ "message_expiration_seconds", "messages_per_minute", "phone_number", + "schedule_id", "sim", "updated_at", "user_id" @@ -3402,6 +3710,10 @@ const docTemplate = `{ "type": "string", "example": "+18005550199" }, + "schedule_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, "sim": { "$ref": "#/definitions/entities.SIM" }, @@ -3940,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": [ @@ -3996,6 +4353,7 @@ const docTemplate = `{ "messages_per_minute", "missed_call_auto_reply", "phone_number", + "schedule_id", "sim" ], "properties": { @@ -4025,6 +4383,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", @@ -4379,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": [ diff --git a/api/docs/swagger.json b/api/docs/swagger.json index b8bc5739..3045ce7f 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1982,6 +1982,222 @@ } } }, + "/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": [ @@ -3013,6 +3229,73 @@ } } }, + "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": [ @@ -3089,6 +3372,7 @@ "message_expiration_seconds", "messages_per_minute", "phone_number", + "schedule_id", "sim", "updated_at", "user_id" @@ -3127,6 +3411,10 @@ "type": "string", "example": "+18005550199" }, + "schedule_id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, "sim": { "$ref": "#/definitions/entities.SIM" }, @@ -3609,6 +3897,42 @@ } } }, + "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": ["is_archived"], @@ -3657,6 +3981,7 @@ "messages_per_minute", "missed_call_auto_reply", "phone_number", + "schedule_id", "sim" ], "properties": { @@ -3686,6 +4011,10 @@ "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", @@ -3986,6 +4315,23 @@ } } }, + "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": ["data", "message", "status"], diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index f5563f2a..58cee295 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -214,6 +214,59 @@ definitions: - updated_at - user_id type: object + entities.MessageSendSchedule: + 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.MessageSendScheduleWindow" + type: array + required: + - created_at + - id + - is_active + - name + - timezone + - updated_at + - user_id + - windows + type: object + entities.MessageSendScheduleWindow: + 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.MessageThread: properties: color: @@ -297,6 +350,9 @@ definitions: phone_number: example: "+18005550199" type: string + schedule_id: + example: 32343a19-da5e-4b1b-a767-3298a73703cb + type: string sim: $ref: "#/definitions/entities.SIM" updated_at: @@ -312,6 +368,7 @@ definitions: - message_expiration_seconds - messages_per_minute - phone_number + - schedule_id - sim - updated_at - user_id @@ -736,6 +793,37 @@ definitions: - 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: is_archived: @@ -797,6 +885,9 @@ 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 @@ -810,6 +901,7 @@ definitions: - messages_per_minute - missed_call_auto_reply - phone_number + - schedule_id - sim type: object requests.UserNotificationUpdate: @@ -1061,6 +1153,21 @@ definitions: - 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: @@ -2842,6 +2949,157 @@ paths: summary: Upserts the FCM token of a phone tags: - Phones + /send-schedules: + get: + description: List all send schedules owned by the authenticated user. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: "#/definitions/entities.MessageSendSchedule" + type: array + "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: 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.MessageSendScheduleStore" + produces: + - application/json + 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" + security: + - ApiKeyAuth: [] + summary: Create send schedule + tags: + - 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 + produces: + - application/json + 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" + security: + - ApiKeyAuth: [] + summary: Delete send schedule + tags: + - Send Schedules + put: + consumes: + - 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.MessageSendScheduleStore" + produces: + - application/json + 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" + security: + - ApiKeyAuth: [] + summary: Update send schedule + tags: + - Send Schedules /users/{userID}/api-keys: delete: consumes: diff --git a/api/go.mod b/api/go.mod index 08f37e9b..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 @@ -43,7 +44,6 @@ require ( github.com/stretchr/testify v1.11.1 github.com/swaggo/swag v1.16.6 github.com/thedevsaddam/govalidator v1.9.10 - github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc github.com/uptrace/uptrace-go v1.43.0 github.com/xuri/excelize/v2 v2.10.1 go.opentelemetry.io/otel v1.43.0 @@ -55,7 +55,6 @@ require ( google.golang.org/api v0.277.0 google.golang.org/protobuf v1.36.11 gorm.io/driver/postgres v1.6.0 - gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 gorm.io/plugin/opentelemetry v0.1.16 ) @@ -94,13 +93,11 @@ require ( github.com/PuerkitoBio/goquery v1.12.0 // indirect github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect - github.com/coder/websocket v1.8.14 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/fatih/color v1.19.0 // indirect @@ -186,7 +183,6 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.50.0 // indirect - golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/api/go.sum b/api/go.sum index 7dc38312..02111796 100644 --- a/api/go.sum +++ b/api/go.sum @@ -66,8 +66,6 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= -github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k= github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -90,8 +88,6 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx1LW83W6RAlhw= github.com/cockroachdb/cockroach-go/v2 v2.4.3/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -116,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= @@ -322,8 +320,6 @@ github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOj github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90= github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= -github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0= -github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= github.com/uptrace/uptrace-go v1.43.0 h1:5QuCdyFJdWUEXx6Fr6sYfezdgO6n6lnkOvUTLlyQO7U= github.com/uptrace/uptrace-go v1.43.0/go.mod h1:ehDTIdtBSolg4Z0CCvg1C8yR6VX1YFDqBcg2KmsXWn0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -414,8 +410,6 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/api/main.go b/api/main.go index b85c9d66..5b7539c9 100644 --- a/api/main.go +++ b/api/main.go @@ -7,7 +7,6 @@ import ( "github.com/NdoleStudio/httpsms/docs" "github.com/NdoleStudio/httpsms/pkg/di" - _ "github.com/tursodatabase/libsql-client-go/libsql" ) // Version is injected at runtime diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 33d27b01..cf8360f4 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -10,11 +10,9 @@ import ( "strings" "time" + "github.com/NdoleStudio/httpsms/docs" plunk "github.com/NdoleStudio/plunk-go" "github.com/pusher/pusher-http-go/v5" - "gorm.io/driver/sqlite" - - "github.com/NdoleStudio/httpsms/docs" otelMetric "go.opentelemetry.io/otel/metric" @@ -130,6 +128,8 @@ func NewContainer(projectID string, version string) (container *Container) { container.RegisterHeartbeatListeners() container.RegisterUserRoutes() + container.RegisterMessageSendScheduleRoutes() + container.RegisterMessageSendScheduleListeners() container.RegisterUserListeners() container.RegisterPhoneRoutes() @@ -234,12 +234,6 @@ func (container *Container) GormLogger() gormLogger.Interface { } func (container *Container) connect(dsn string, config *gorm.Config) (db *gorm.DB, err error) { - if strings.HasPrefix(dsn, "libsql://") { - return gorm.Open(sqlite.New(sqlite.Config{ - DriverName: "libsql", - DSN: dsn, - }), config) - } return gorm.Open(postgres.Open(dsn), config) } @@ -364,6 +358,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.MessageSendSchedule{}); err != nil { + container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.MessageSendSchedule{}))) + } + if err = db.AutoMigrate(&entities.Phone{}); err != nil { container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Phone{}))) } @@ -753,6 +751,47 @@ func (container *Container) PhoneRepository() (repository repositories.PhoneRepo ) } +// 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(), + ) +} + +// 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.MessageSendScheduleRepository(), + ) +} + +// 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(), + ) +} + +// 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.MessageSendScheduleHandlerValidator(), + container.MessageSendScheduleService(), + container.EntitlementService(), + ) +} + // BillingUsageRepository creates a new instance of repositories.BillingUsageRepository func (container *Container) BillingUsageRepository() (repository repositories.BillingUsageRepository) { container.logger.Debug("creating GORM repositories.BillingUsageRepository") @@ -763,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") @@ -1067,6 +1117,7 @@ func (container *Container) PhoneHandler() (handler *handlers.PhoneHandler) { container.Logger(), container.Tracer(), container.PhoneService(), + container.MessageSendScheduleService(), container.PhoneHandlerValidator(), ) } @@ -1097,6 +1148,20 @@ func (container *Container) RegisterMessageListeners() { } } +// 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.MessageSendScheduleService(), + ) + + for event, handler := range routes { + container.EventDispatcher().Subscribe(event, handler) + } +} + // LemonsqueezyService creates a new instance of services.LemonsqueezyService func (container *Container) LemonsqueezyService() (service *services.LemonsqueezyService) { container.logger.Debug(fmt.Sprintf("creating %T", service)) @@ -1510,6 +1575,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi container.FirebaseMessagingClient(), container.PhoneRepository(), container.PhoneNotificationRepository(), + container.MessageSendScheduleRepository(), container.EventDispatcher(), ) } @@ -1565,6 +1631,12 @@ func (container *Container) RegisterUserRoutes() { container.UserHandler().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 func (container *Container) RegisterEventRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.EventsHandler{})) diff --git a/api/pkg/entities/message_send_schedule.go b/api/pkg/entities/message_send_schedule.go new file mode 100644 index 00000000..17b8a7b7 --- /dev/null +++ b/api/pkg/entities/message_send_schedule.go @@ -0,0 +1,108 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +// MessageSendScheduleWindow represents a single availability window for a day of the week. +type MessageSendScheduleWindow struct { + DayOfWeek int `json:"day_of_week" example:"1"` + StartMinute int `json:"start_minute" example:"540"` + EndMinute int `json:"end_minute" example:"1020"` +} + +// MessageSendSchedule controls when a phone is allowed to send outgoing SMS messages. +type MessageSendSchedule 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 []MessageSendScheduleWindow `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"` +} + +// 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. 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 { + return current.UTC() + } + + if 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() +} diff --git a/api/pkg/entities/message_send_schedule_test.go b/api/pkg/entities/message_send_schedule_test.go new file mode 100644 index 00000000..1480aa2f --- /dev/null +++ b/api/pkg/entities/message_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/entities/phone.go b/api/pkg/entities/phone.go index 83521759..f97212ce 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 *MessageSendSchedule `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/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"` 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/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/handlers/message_send_schedule_handler.go b/api/pkg/handlers/message_send_schedule_handler.go new file mode 100644 index 00000000..a688987c --- /dev/null +++ b/api/pkg/handlers/message_send_schedule_handler.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/repositories" + "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" +) + +// MessageSendScheduleHandler handles HTTP requests for message send schedules. +type MessageSendScheduleHandler struct { + handler + logger telemetry.Logger + tracer telemetry.Tracer + validator *validators.MessageSendScheduleHandlerValidator + service *services.MessageSendScheduleService + entitlementService *services.EntitlementService +} + +// NewMessageSendScheduleHandler creates a new MessageSendScheduleHandler. +func NewMessageSendScheduleHandler( + logger telemetry.Logger, + tracer telemetry.Tracer, + validator *validators.MessageSendScheduleHandlerValidator, + service *services.MessageSendScheduleService, + entitlementService *services.EntitlementService, +) *MessageSendScheduleHandler { + return &MessageSendScheduleHandler{ + logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleHandler{})), + tracer: tracer, + validator: validator, + service: service, + entitlementService: entitlementService, + } +} + +// RegisterRoutes registers send schedule routes. +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)...) + router.Delete("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Delete)...) +} + +// Index lists all send schedules for the authenticated user. +// +// @Summary List send schedules +// @Description List all send schedules owned by the authenticated user. +// @Security ApiKeyAuth +// @Tags Send Schedules +// @Produce json +// @Success 200 {object} responses.MessageSendSchedulesResponse +// @Failure 401 {object} responses.Unauthorized +// @Failure 500 {object} responses.InternalServerError +// @Router /send-schedules [get] +func (h *MessageSendScheduleHandler) 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) +} + +// Store creates a new send schedule for the authenticated user. +// +// @Summary Create send schedule +// @Description Create a new send schedule for the authenticated user. +// @Security ApiKeyAuth +// @Tags Send Schedules +// @Accept json +// @Produce json +// @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.PaymentRequired +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /send-schedules [post] +func (h *MessageSendScheduleHandler) 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.MessageSendScheduleStore + 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( + "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 updates a send schedule owned by the authenticated user. +// +// @Summary Update send schedule +// @Description Update 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.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 *MessageSendScheduleHandler) 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.MessageSendScheduleStore + 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) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, err.Error()) + } + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "send schedule updated successfully", schedule) +} + +// Delete removes a send schedule owned by the authenticated user. +// +// @Summary Delete send schedule +// @Description Delete a send schedule owned by the authenticated user. +// @Security ApiKeyAuth +// @Tags Send Schedules +// @Produce json +// @Param scheduleID path string true "Schedule ID" +// @Success 204 +// @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 *MessageSendScheduleHandler) 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.Load(ctx, h.userIDFomContext(c), scheduleID); err != nil { + ctxLogger.Error(stacktrace.Propagate(err, "cannot load send schedule for deletion")) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, err.Error()) + } + return h.responseInternalServerError(c) + } + + 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) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, err.Error()) + } + return h.responseInternalServerError(c) + } + + return h.responseNoContent(c, "send schedule deleted successfully") +} 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/listeners/message_send_schedule_listener.go b/api/pkg/listeners/message_send_schedule_listener.go new file mode 100644 index 00000000..61e57b24 --- /dev/null +++ b/api/pkg/listeners/message_send_schedule_listener.go @@ -0,0 +1,73 @@ +package listeners + +import ( + "context" + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/palantir/stacktrace" +) + +// MessageSendScheduleListener handles cloud events related to message send schedules. +type MessageSendScheduleListener struct { + logger telemetry.Logger + tracer telemetry.Tracer + service *services.MessageSendScheduleService +} + +// NewMessageSendScheduleListener creates a new instance of MessageSendScheduleListener. +func NewMessageSendScheduleListener( + logger telemetry.Logger, + tracer telemetry.Tracer, + service *services.MessageSendScheduleService, +) (l *MessageSendScheduleListener, routes map[string]events.EventListener) { + l = &MessageSendScheduleListener{ + logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleListener{})), + tracer: tracer, + service: service, + } + + return l, map[string]events.EventListener{ + events.UserAccountDeleted: l.onUserAccountDeleted, + } +} + +// onUserAccountDeleted removes all message send schedules for a deleted user account. +func (listener *MessageSendScheduleListener) onUserAccountDeleted( + ctx context.Context, + event cloudevents.Event, +) error { + ctx, span := listener.tracer.Start(ctx) + defer span.End() + + var payload events.UserAccountDeletedPayload + if err := event.DataAs(&payload); err != nil { + return listener.tracer.WrapErrorSpan( + span, + stacktrace.Propagate( + err, + "cannot decode [%s] into [%T]", + event.Data(), + payload, + ), + ) + } + + if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { + return listener.tracer.WrapErrorSpan( + span, + stacktrace.Propagate( + err, + "cannot delete [entities.MessageSendSchedule] for user [%s] on [%s] event with ID [%s]", + payload.UserID, + event.Type(), + event.ID(), + ), + ) + } + + return nil +} 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 { diff --git a/api/pkg/repositories/gorm_heartbeat_monitor_repository.go b/api/pkg/repositories/gorm_heartbeat_monitor_repository.go index fb892d87..e6f5aee5 100644 --- a/api/pkg/repositories/gorm_heartbeat_monitor_repository.go +++ b/api/pkg/repositories/gorm_heartbeat_monitor_repository.go @@ -39,13 +39,11 @@ func (repository *gormHeartbeatMonitorRepository) DeleteAllForUser(ctx context.C ctx, span := repository.tracer.Start(ctx) defer span.End() - return executeWithRetry(func() error { - if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.HeartbeatMonitor{}).Error; err != nil { - msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID) - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - return nil - }) + if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.HeartbeatMonitor{}).Error; err != nil { + msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return nil } // UpdatePhoneOnline updates the online status of a phone @@ -56,16 +54,14 @@ func (repository *gormHeartbeatMonitorRepository) UpdatePhoneOnline(ctx context. ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - err := executeWithRetry(func() error { - return repository.db. - Model(&entities.HeartbeatMonitor{}). - Where("id = ?", monitorID). - Where("user_id = ?", userID). - Updates(map[string]any{ - "phone_online": isOnline, - "updated_at": time.Now().UTC(), - }).Error - }) + err := repository.db. + Model(&entities.HeartbeatMonitor{}). + Where("id = ?", monitorID). + Where("user_id = ?", userID). + Updates(map[string]any{ + "phone_online": isOnline, + "updated_at": time.Now().UTC(), + }).Error if err != nil { msg := fmt.Sprintf("cannot update heartbeat monitor ID [%s] for user [%s]", monitorID, userID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) @@ -81,15 +77,13 @@ func (repository *gormHeartbeatMonitorRepository) UpdateQueueID(ctx context.Cont ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - err := executeWithRetry(func() error { - return repository.db. - Model(&entities.HeartbeatMonitor{}). - Where("id = ?", monitorID). - Updates(map[string]any{ - "queue_id": queueID, - "updated_at": time.Now().UTC(), - }).Error - }) + err := repository.db. + Model(&entities.HeartbeatMonitor{}). + Where("id = ?", monitorID). + Updates(map[string]any{ + "queue_id": queueID, + "updated_at": time.Now().UTC(), + }).Error if err != nil { msg := fmt.Sprintf("cannot update heartbeat monitor ID [%s]", monitorID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) @@ -104,12 +98,10 @@ func (repository *gormHeartbeatMonitorRepository) Delete(ctx context.Context, us ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - err := executeWithRetry(func() error { - return repository.db.WithContext(ctx). - Where("user_id = ?", userID). - Where("owner = ?", owner). - Delete(&entities.HeartbeatMonitor{}).Error - }) + err := repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("owner = ?", owner). + Delete(&entities.HeartbeatMonitor{}).Error if err != nil { msg := fmt.Sprintf("cannot delete heartbeat monitor with owner [%s] and userID [%s]", owner, userID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) @@ -128,9 +120,7 @@ func (repository *gormHeartbeatMonitorRepository) Index(ctx context.Context, use query := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("owner = ?", owner) heartbeats := new([]entities.Heartbeat) - if err := executeWithRetry(func() error { - return query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error - }); err != nil { + if err := query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error; err != nil { msg := fmt.Sprintf("cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params) return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } @@ -146,7 +136,7 @@ func (repository *gormHeartbeatMonitorRepository) Store(ctx context.Context, hea ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - if err := executeWithRetry(func() error { return repository.db.WithContext(ctx).Create(heartbeatMonitor).Error }); err != nil { + if err := repository.db.WithContext(ctx).Create(heartbeatMonitor).Error; err != nil { msg := fmt.Sprintf("cannot save heartbeatMonitor monitor with ID [%s]", heartbeatMonitor.ID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } @@ -163,12 +153,10 @@ func (repository *gormHeartbeatMonitorRepository) Load(ctx context.Context, user defer cancel() phone := new(entities.HeartbeatMonitor) - err := executeWithRetry(func() error { - return repository.db.WithContext(ctx). - Where("user_id = ?", userID). - Where("owner = ?", owner). - First(&phone).Error - }) + err := repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("owner = ?", owner). + First(&phone).Error if errors.Is(err, gorm.ErrRecordNotFound) { msg := fmt.Sprintf("heartbeat monitor with userID [%s] and owner [%s] does not exist", userID, owner) return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) @@ -191,14 +179,12 @@ func (repository *gormHeartbeatMonitorRepository) Exists(ctx context.Context, us defer cancel() var exists bool - err := executeWithRetry(func() error { - return repository.db.WithContext(ctx). - Model(&entities.HeartbeatMonitor{}). - Select("count(*) > 0"). - Where("user_id = ?", userID). - Where("id = ?", monitorID). - Find(&exists).Error - }) + err := repository.db.WithContext(ctx). + Model(&entities.HeartbeatMonitor{}). + Select("count(*) > 0"). + Where("user_id = ?", userID). + Where("id = ?", monitorID). + Find(&exists).Error if err != nil { msg := fmt.Sprintf("cannot check if heartbeat monitor exists with userID [%s] and montior ID [%s]", userID, monitorID) return exists, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) diff --git a/api/pkg/repositories/gorm_heartbeat_repository.go b/api/pkg/repositories/gorm_heartbeat_repository.go index 5b7794e9..e9ddf7ce 100644 --- a/api/pkg/repositories/gorm_heartbeat_repository.go +++ b/api/pkg/repositories/gorm_heartbeat_repository.go @@ -36,9 +36,7 @@ func (repository *gormHeartbeatRepository) DeleteAllForUser(ctx context.Context, ctx, span := repository.tracer.Start(ctx) defer span.End() - err := executeWithRetry(func() error { - return repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Heartbeat{}).Error - }) + err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Heartbeat{}).Error if err != nil { msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.Heartbeat{}, userID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) @@ -55,13 +53,11 @@ func (repository *gormHeartbeatRepository) Last(ctx context.Context, userID enti defer cancel() heartbeat := new(entities.Heartbeat) - err := executeWithRetry(func() error { - return repository.db.WithContext(ctx). - Where("user_id = ?", userID). - Where("owner = ?", owner). - Order("timestamp DESC"). - First(&heartbeat).Error - }) + err := repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("owner = ?", owner). + Order("timestamp DESC"). + First(&heartbeat).Error if errors.Is(err, gorm.ErrRecordNotFound) { msg := fmt.Sprintf("heartbeat with userID [%s] and owner [%s] does not exist", userID, owner) return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) @@ -90,9 +86,7 @@ func (repository *gormHeartbeatRepository) Index(ctx context.Context, userID ent } heartbeats := new([]entities.Heartbeat) - err := executeWithRetry(func() error { - return query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error - }) + err := query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error if err != nil { msg := fmt.Sprintf("cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params) return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) @@ -109,7 +103,7 @@ func (repository *gormHeartbeatRepository) Store(ctx context.Context, heartbeat ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - if err := executeWithRetry(func() error { return repository.db.WithContext(ctx).Create(heartbeat).Error }); err != nil { + if err := repository.db.WithContext(ctx).Create(heartbeat).Error; err != nil { msg := fmt.Sprintf("cannot save heartbeat with ID [%s]", heartbeat.ID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } diff --git a/api/pkg/repositories/gorm_message_send_schedule_repository.go b/api/pkg/repositories/gorm_message_send_schedule_repository.go new file mode 100644 index 00000000..919eb6a9 --- /dev/null +++ b/api/pkg/repositories/gorm_message_send_schedule_repository.go @@ -0,0 +1,190 @@ +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" +) + +// gormMessageSendScheduleRepository persists and loads entities.MessageSendSchedule using GORM. +type gormMessageSendScheduleRepository struct { + logger telemetry.Logger + tracer telemetry.Tracer + db *gorm.DB +} + +// NewGormMessageSendScheduleRepository creates a new GORM-backed MessageSendScheduleRepository. +func NewGormMessageSendScheduleRepository( + logger telemetry.Logger, + tracer telemetry.Tracer, + db *gorm.DB, +) MessageSendScheduleRepository { + return &gormMessageSendScheduleRepository{ + logger: logger.WithService(fmt.Sprintf("%T", &gormMessageSendScheduleRepository{})), + tracer: tracer, + db: db, + } +} + +// Store saves a new message send schedule. +func (r *gormMessageSendScheduleRepository) Store( + ctx context.Context, + schedule *entities.MessageSendSchedule, +) 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, "cannot store send schedule [%s]", schedule.ID), + ) + } + + return nil +} + +// Update persists changes to an existing message send schedule. +func (r *gormMessageSendScheduleRepository) Update( + ctx context.Context, + schedule *entities.MessageSendSchedule, +) 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, "cannot update send schedule [%s]", schedule.ID), + ) + } + + return nil +} + +// Load fetches a message send schedule by user ID and schedule ID. +func (r *gormMessageSendScheduleRepository) Load( + ctx context.Context, + userID entities.UserID, + scheduleID uuid.UUID, +) (*entities.MessageSendSchedule, error) { + ctx, span := r.tracer.Start(ctx) + defer span.End() + + item := new(entities.MessageSendSchedule) + 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, + "send schedule [%s] not found", + scheduleID, + ), + ) + } + if err != nil { + return nil, r.tracer.WrapErrorSpan( + span, + stacktrace.Propagate(err, "cannot load send schedule [%s]", scheduleID), + ) + } + + return item, nil +} + +// Index lists all message send schedules owned by the given user. +func (r *gormMessageSendScheduleRepository) Index( + ctx context.Context, + userID entities.UserID, +) ([]entities.MessageSendSchedule, error) { + ctx, span := r.tracer.Start(ctx) + defer span.End() + + items := make([]entities.MessageSendSchedule, 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, "cannot index send schedules for user [%s]", userID), + ) + } + + return items, nil +} + +// Delete removes a message send schedule owned by the given user. +func (r *gormMessageSendScheduleRepository) 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.MessageSendSchedule{}).Error; err != nil { + return r.tracer.WrapErrorSpan( + span, + stacktrace.Propagate(err, "cannot delete send schedule [%s]", scheduleID), + ) + } + + return nil +} + +// DeleteAllForUser removes all message send schedules owned by the given user. +func (r *gormMessageSendScheduleRepository) 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.MessageSendSchedule{}).Error; err != nil { + return r.tracer.WrapErrorSpan( + span, + stacktrace.Propagate(err, "cannot delete send schedules for user [%s]", userID), + ) + } + + return nil +} + +// CountByUser returns the number of schedules owned by a user. +func (r *gormMessageSendScheduleRepository) 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/gorm_phone_notification_repository.go b/api/pkg/repositories/gorm_phone_notification_repository.go index f491e4b3..a36bac07 100644 --- a/api/pkg/repositories/gorm_phone_notification_repository.go +++ b/api/pkg/repositories/gorm_phone_notification_repository.go @@ -15,40 +15,57 @@ import ( "gorm.io/gorm" ) -// gormPhoneNotificationRepository is responsible for persisting entities.PhoneNotification +// gormPhoneNotificationRepository persists entities.PhoneNotification records. type gormPhoneNotificationRepository struct { logger telemetry.Logger tracer telemetry.Tracer db *gorm.DB } -// NewGormPhoneNotificationRepository creates the GORM version of the PhoneNotificationRepository +// NewGormPhoneNotificationRepository creates a GORM-backed PhoneNotificationRepository. func NewGormPhoneNotificationRepository( logger telemetry.Logger, tracer telemetry.Tracer, db *gorm.DB, ) PhoneNotificationRepository { return &gormPhoneNotificationRepository{ - logger: logger.WithService(fmt.Sprintf("%T", &gormHeartbeatRepository{})), + logger: logger.WithService(fmt.Sprintf("%T", &gormPhoneNotificationRepository{})), tracer: tracer, db: db, } } -func (repository *gormPhoneNotificationRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { +// DeleteAllForUser deletes all phone notifications that belong to a user. +func (repository *gormPhoneNotificationRepository) DeleteAllForUser( + ctx context.Context, + userID entities.UserID, +) error { ctx, span := repository.tracer.Start(ctx) defer span.End() - if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.PhoneNotification{}).Error; err != nil { - msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.PhoneNotification{}, userID) - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + if err := repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Delete(&entities.PhoneNotification{}).Error; err != nil { + return repository.tracer.WrapErrorSpan( + span, + stacktrace.Propagate( + err, + "cannot delete all [%T] for user with ID [%s]", + &entities.PhoneNotification{}, + userID, + ), + ) } return nil } -// UpdateStatus of an entities.PhoneNotification -func (repository *gormPhoneNotificationRepository) UpdateStatus(ctx context.Context, notificationID uuid.UUID, status entities.PhoneNotificationStatus) error { +// UpdateStatus updates the status of a phone notification. +func (repository *gormPhoneNotificationRepository) UpdateStatus( + ctx context.Context, + notificationID uuid.UUID, + status entities.PhoneNotificationStatus, +) error { ctx, span := repository.tracer.Start(ctx) defer span.End() @@ -58,71 +75,166 @@ func (repository *gormPhoneNotificationRepository) UpdateStatus(ctx context.Cont Update("status", status). Error if err != nil { - msg := fmt.Sprintf("cannot update notification [%s] with status [%s]", notificationID, status) - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + return repository.tracer.WrapErrorSpan( + span, + stacktrace.Propagate( + err, + "cannot update notification [%s] with status [%s]", + notificationID, + status, + ), + ) } return nil } -// Schedule a notification to be sent in the future -func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context, messagesPerMinute uint, notification *entities.PhoneNotification) error { +// Schedule stores a phone notification and calculates its final scheduled time. +// The final time is determined by combining: +// 1. the next allowed time from the message send schedule +// 2. the phone send-rate limit derived from the latest scheduled notification +func (repository *gormPhoneNotificationRepository) Schedule( + ctx context.Context, + messagesPerMinute uint, + schedule *entities.MessageSendSchedule, + notification *entities.PhoneNotification, +) error { ctx, span := repository.tracer.Start(ctx) defer span.End() + now := time.Now().UTC() + if messagesPerMinute == 0 { + notification.ScheduledAt = repository.resolveScheduledAt(schedule, now) return repository.insert(ctx, notification) } err := crdbgorm.ExecuteTx(ctx, repository.db, nil, func(tx *gorm.DB) error { lastNotification := new(entities.PhoneNotification) + err := tx.WithContext(ctx). Where("phone_id = ?", notification.PhoneID). Order("scheduled_at desc"). First(lastNotification). Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - msg := fmt.Sprintf("cannot fetch last notification with phone ID [%s]", notification.PhoneID) - return stacktrace.Propagate(err, msg) + return stacktrace.Propagate( + err, + "cannot fetch last notification with phone ID [%s]", + notification.PhoneID, + ) } - notification.ScheduledAt = time.Now().UTC() + notification.ScheduledAt = repository.resolveScheduledAt(schedule, now) + 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, ) + + nextCandidate := repository.maxTime(notification.ScheduledAt, rateLimitedAt) + notification.ScheduledAt = repository.resolveScheduledAt(schedule, nextCandidate) } if err = tx.WithContext(ctx).Create(notification).Error; err != nil { - msg := fmt.Sprintf("cannot create new notification with id [%s] and schedule [%s]", notification.ID, notification.ScheduledAt.String()) - return stacktrace.Propagate(err, msg) + return stacktrace.Propagate( + err, + "cannot create new notification with id [%s] and schedule [%s]", + notification.ID, + notification.ScheduledAt.String(), + ) } + return nil }) if err != nil { - msg := fmt.Sprintf("cannot schedule phone notification with ID [%s]", notification.ID) - return stacktrace.Propagate(err, msg) + return repository.tracer.WrapErrorSpan( + span, + stacktrace.Propagate( + err, + "cannot schedule phone notification with ID [%s]", + notification.ID, + ), + ) } return nil } +// resolveScheduledAt returns the next time the notification is allowed to be sent. +// If no schedule is attached, the provided time is returned unchanged in UTC. +func (repository *gormPhoneNotificationRepository) resolveScheduledAt( + schedule *entities.MessageSendSchedule, + current time.Time, +) time.Time { + if schedule == nil { + return current.UTC() + } + + return schedule.ResolveScheduledAt(current) +} + +// maxTime returns the later of the two times. func (repository *gormPhoneNotificationRepository) maxTime(a, b time.Time) time.Time { - if a.Unix() > b.Unix() { + if a.After(b) { return a } return b } -func (repository *gormPhoneNotificationRepository) insert(ctx context.Context, notification *entities.PhoneNotification) error { +// insert stores a single phone notification. +func (repository *gormPhoneNotificationRepository) insert( + ctx context.Context, + notification *entities.PhoneNotification, +) error { ctx, span := repository.tracer.Start(ctx) defer span.End() - err := repository.db.WithContext(ctx).Create(notification).Error - if err != nil { - msg := fmt.Sprintf("cannot store notification with id [%s]", notification.ID) - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + if err := repository.db.WithContext(ctx).Create(notification).Error; err != nil { + return repository.tracer.WrapErrorSpan( + span, + stacktrace.Propagate( + err, + "cannot store notification with id [%s]", + notification.ID, + ), + ) + } + + 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/message_send_schedule_repository.go b/api/pkg/repositories/message_send_schedule_repository.go new file mode 100644 index 00000000..82ef4518 --- /dev/null +++ b/api/pkg/repositories/message_send_schedule_repository.go @@ -0,0 +1,32 @@ +package repositories + +import ( + "context" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" +) + +// MessageSendScheduleRepository loads and persists entities.MessageSendSchedule. +type MessageSendScheduleRepository interface { + // Store persists a new message send schedule. + Store(ctx context.Context, schedule *entities.MessageSendSchedule) error + + // Update persists changes to an existing message send schedule. + Update(ctx context.Context, schedule *entities.MessageSendSchedule) error + + // Load returns a message send schedule by user ID and schedule ID. + Load(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) (*entities.MessageSendSchedule, error) + + // Index returns all message send schedules owned by a user. + Index(ctx context.Context, userID entities.UserID) ([]entities.MessageSendSchedule, error) + + // Delete removes a message send schedule owned by a user. + Delete(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) error + + // 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/repositories/phone_notification_repository.go b/api/pkg/repositories/phone_notification_repository.go index 87f78490..e8bedfe4 100644 --- a/api/pkg/repositories/phone_notification_repository.go +++ b/api/pkg/repositories/phone_notification_repository.go @@ -11,7 +11,11 @@ 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.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 diff --git a/api/pkg/repositories/repository.go b/api/pkg/repositories/repository.go index 4a3e90dc..32ba4337 100644 --- a/api/pkg/repositories/repository.go +++ b/api/pkg/repositories/repository.go @@ -1,10 +1,8 @@ package repositories import ( - "strings" "time" - "github.com/avast/retry-go/v5" "github.com/palantir/stacktrace" ) @@ -23,21 +21,3 @@ const ( dbOperationDuration = 5 * time.Second ) - -// isRetryableError checks if the error is a retryable connection error -func isRetryableError(err error) bool { - msg := err.Error() - return strings.Contains(msg, "bad connection") || - strings.Contains(msg, "stream is closed") || - strings.Contains(msg, "driver: bad connection") -} - -// executeWithRetry executes a GORM query with retry logic for transient connection errors -func executeWithRetry(fn func() error) (err error) { - return retry.New( - retry.LastErrorOnly(true), - retry.Attempts(5), - retry.Delay(100*time.Millisecond), - retry.RetryIf(isRetryableError), - ).Do(fn) -} 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, } } 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, }) } 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/phone_update_request.go b/api/pkg/requests/phone_update_request.go index f920fad4..06876de8 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,omitempty" example:"32343a19-da5e-4b1b-a767-3298a73703cb" validate:"optional"` } // 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/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/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/api/pkg/services/entitlement_service.go b/api/pkg/services/entitlement_service.go new file mode 100644 index 00000000..cd4d740f --- /dev/null +++ b/api/pkg/services/entitlement_service.go @@ -0,0 +1,121 @@ +package services + +import ( + "context" + "fmt" + "strings" + "unicode" + + "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" +) + +// 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] %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 { + client := pluralize.NewClient() + words[len(words)-1] = client.Plural(words[len(words)-1]) + } + + return strings.Join(words, " ") +} diff --git a/api/pkg/services/message_send_schedule_service.go b/api/pkg/services/message_send_schedule_service.go new file mode 100644 index 00000000..e9140b5d --- /dev/null +++ b/api/pkg/services/message_send_schedule_service.go @@ -0,0 +1,189 @@ +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" +) + +// MessageSendScheduleService manages message send schedules for a user. +type MessageSendScheduleService struct { + service + logger telemetry.Logger + tracer telemetry.Tracer + repository repositories.MessageSendScheduleRepository +} + +// NewMessageSendScheduleService creates a new MessageSendScheduleService. +func NewMessageSendScheduleService( + logger telemetry.Logger, + tracer telemetry.Tracer, + repository repositories.MessageSendScheduleRepository, +) *MessageSendScheduleService { + return &MessageSendScheduleService{ + logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleService{})), + tracer: tracer, + repository: repository, + } +} + +// MessageSendScheduleUpsertParams contains the fields required to create or update a message send schedule. +type MessageSendScheduleUpsertParams struct { + UserID entities.UserID + Name string + Timezone string + IsActive bool + Windows []entities.MessageSendScheduleWindow +} + +// Index returns all message send schedules for a user. +func (service *MessageSendScheduleService) Index( + ctx context.Context, + userID entities.UserID, +) ([]entities.MessageSendSchedule, error) { + return service.repository.Index(ctx, userID) +} + +// CountByUser returns the number of schedules owned by a user. +func (service *MessageSendScheduleService) 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 *MessageSendScheduleService) Load( + ctx context.Context, + userID entities.UserID, + scheduleID uuid.UUID, +) (*entities.MessageSendSchedule, error) { + return service.repository.Load(ctx, userID, scheduleID) +} + +// Store creates a new message send schedule. +func (service *MessageSendScheduleService) Store( + ctx context.Context, + params *MessageSendScheduleUpsertParams, +) (*entities.MessageSendSchedule, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + schedule := &entities.MessageSendSchedule{ + 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 message send schedule [%s]", schedule.ID), + ), + ) + } + + return schedule, nil +} + +// Update updates an existing message send schedule. +func (service *MessageSendScheduleService) Update( + ctx context.Context, + userID entities.UserID, + scheduleID uuid.UUID, + params *MessageSendScheduleUpsertParams, +) (*entities.MessageSendSchedule, 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 message send schedule [%s]", schedule.ID), + ), + ) + } + + return schedule, nil +} + +// Delete removes a message send schedule for a user. +func (service *MessageSendScheduleService) Delete( + ctx context.Context, + userID entities.UserID, + scheduleID uuid.UUID, +) error { + return service.repository.Delete(ctx, userID, scheduleID) +} + +// sanitizeWindows normalizes and sorts schedule windows by day and start minute. +func sanitizeWindows( + windows []entities.MessageSendScheduleWindow, +) []entities.MessageSendScheduleWindow { + result := make([]entities.MessageSendScheduleWindow, 0, len(windows)) + + for _, item := range windows { + result = append(result, entities.MessageSendScheduleWindow{ + 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 +} + +// DeleteAllForUser removes all message send schedules owned by a user. +func (service *MessageSendScheduleService) DeleteAllForUser( + ctx context.Context, + userID entities.UserID, +) error { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { + return service.tracer.WrapErrorSpan( + span, + stacktrace.Propagate( + err, + fmt.Sprintf("cannot delete message send schedules for user [%s]", userID), + ), + ) + } + + return nil +} 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 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{}) {} diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 7907d6d6..9e6fb296 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -20,12 +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 - 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 @@ -35,15 +36,17 @@ func NewNotificationService( messagingClient *messaging.Client, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, + messageSendScheduleRepository repositories.MessageSendScheduleRepository, dispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ - logger: logger.WithService(fmt.Sprintf("%T", s)), - tracer: tracer, - messagingClient: messagingClient, - phoneNotificationRepository: phoneNotificationRepository, - phoneRepository: phoneRepository, - eventDispatcher: dispatcher, + logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), + tracer: tracer, + messagingClient: messagingClient, + phoneNotificationRepository: phoneNotificationRepository, + phoneRepository: phoneRepository, + messageSendScheduleRepository: messageSendScheduleRepository, + eventDispatcher: dispatcher, } } @@ -92,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 } @@ -134,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) } @@ -144,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 @@ -178,7 +197,49 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P UpdatedAt: time.Now().UTC(), } - if err = service.phoneNotificationRepository.Schedule(ctx, phone.MessagesPerMinute, notification); err != nil { + // 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.messageSendScheduleRepository.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)) } @@ -191,11 +252,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, @@ -213,7 +283,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, @@ -258,7 +332,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() @@ -279,15 +358,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) @@ -314,7 +404,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) @@ -339,7 +433,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() @@ -347,9 +445,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/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/validators/message_send_schedule_handler_validator.go b/api/pkg/validators/message_send_schedule_handler_validator.go new file mode 100644 index 00000000..00e405ca --- /dev/null +++ b/api/pkg/validators/message_send_schedule_handler_validator.go @@ -0,0 +1,160 @@ +package validators + +import ( + "context" + "fmt" + "net/url" + "sort" + "time" + + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/thedevsaddam/govalidator" +) + +const maxWindowsPerDay = 6 + +// MessageSendScheduleHandlerValidator validates send schedule HTTP requests. +type MessageSendScheduleHandlerValidator struct { + validator + logger telemetry.Logger + tracer telemetry.Tracer +} + +// NewMessageSendScheduleHandlerValidator creates a new MessageSendScheduleHandlerValidator. +func NewMessageSendScheduleHandlerValidator( + logger telemetry.Logger, + tracer telemetry.Tracer, +) *MessageSendScheduleHandlerValidator { + return &MessageSendScheduleHandlerValidator{ + logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleHandlerValidator{})), + tracer: tracer, + } +} + +// ValidateStore validates a send schedule create or update request. +func (validator *MessageSendScheduleHandlerValidator) ValidateStore( + _ context.Context, + request requests.MessageSendScheduleStore, +) 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 request.Timezone != "" { + if _, err := time.LoadLocation(request.Timezone); err != nil { + result.Add("timezone", "timezone must be a valid IANA timezone") + } + } + + return result +} + +func (validator *MessageSendScheduleHandlerValidator) validateWindows( + result url.Values, + windows []requests.MessageSendScheduleWindow, +) { + windowsPerDay := make(map[int]int) + + for index, item := range windows { + validator.validateDayOfWeek(result, index, item, windowsPerDay) + validator.validateStartMinute(result, index, item) + validator.validateEndMinute(result, index, item) + validator.validateWindowRange(result, index, item) + } + + validator.validateOverlappingWindows(result, windows) +} + +func (validator *MessageSendScheduleHandlerValidator) validateDayOfWeek( + result url.Values, + index int, + item requests.MessageSendScheduleWindow, + 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 *MessageSendScheduleHandlerValidator) validateStartMinute( + result url.Values, + index int, + 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 *MessageSendScheduleHandlerValidator) validateEndMinute( + result url.Values, + index int, + 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 *MessageSendScheduleHandlerValidator) validateWindowRange( + result url.Values, + index int, + 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 *MessageSendScheduleHandlerValidator) validateOverlappingWindows( + result url.Values, + windows []requests.MessageSendScheduleWindow, +) { + grouped := make(map[int][]requests.MessageSendScheduleWindow) + + for _, item := range windows { + if item.DayOfWeek < 0 || item.DayOfWeek > 6 { + continue + } + if item.EndMinute <= item.StartMinute { + continue + } + grouped[item.DayOfWeek] = append(grouped[item.DayOfWeek], item) + } + + for dayOfWeek, dayWindows := range grouped { + sort.Slice(dayWindows, func(i, j int) bool { + return dayWindows[i].StartMinute < dayWindows[j].StartMinute + }) + + for i := 1; i < len(dayWindows); i++ { + previous := dayWindows[i-1] + current := dayWindows[i] + + if current.StartMinute < previous.EndMinute { + result.Add( + "windows", + fmt.Sprintf("day_of_week %d contains overlapping windows", dayOfWeek), + ) + break + } + } + } +} 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/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." +``` 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..f92b60ed --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-scheduling-send-refactor.md @@ -0,0 +1,956 @@ +# 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. + +**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) + +**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). 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..8ec3893c --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-entitlement-service-design.md @@ -0,0 +1,188 @@ +# Entitlement Service Design + +## Problem + +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 +- **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 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) 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..1a02bc08 --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-scheduling-send-refactor-design.md @@ -0,0 +1,188 @@ +# 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` (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. + +## Proposed Solution + +### Core Principle + +- **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 + +#### 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 `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. diff --git a/outgoing-message-queue.md b/outgoing-message-queue.md new file mode 100644 index 00000000..0e8ee144 --- /dev/null +++ b/outgoing-message-queue.md @@ -0,0 +1,171 @@ +# 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 + +```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 + +- **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 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/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 6d3415d0..cbe4fe6e 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 @@ -210,6 +211,7 @@ {{ event }} @@ -244,6 +246,7 @@ >Documentation +

Discord Integration
@@ -310,6 +313,7 @@ > Add Discord +
Phones

List of mobile phones which are registered for sending and @@ -357,6 +361,7 @@ @@ -370,6 +375,71 @@ + +

+ 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 }} + Create Send Schedule + +
Email Notifications
@@ -415,6 +485,7 @@ {{ mdiContentSave }} Save Notification Settings +
Delete Account
@@ -485,6 +556,7 @@ + Edit Phone @@ -549,6 +621,18 @@ label="Max Send Attempts" > + - + {{ mdiDelete }} @@ -581,6 +670,7 @@ + @@ -696,6 +786,7 @@ + @@ -809,14 +900,213 @@ + + + + + Create Message Send Schedule + Edit Message Send Schedule + + + + + + + + + + + + +
+
+ +
+
+
+
+ +
+
+
+ +
+
+ + {{ mdiPlus }} + + + {{ mdiDelete }} + +
+
+
+ {{ scheduleWindowError(day.value) }} +
+
+
+
+
+
+ + + Save Schedule + + + + {{ mdiContentSave }} + + Update Schedule + + + + + {{ mdiDelete }} + + Delete + + + 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..bd3071e6 100644 --- a/web/store/index.ts +++ b/web/store/index.ts @@ -11,10 +11,12 @@ import { EntitiesMessage, EntitiesPhone, EntitiesPhoneAPIKey, + EntitiesSendSchedule, EntitiesUser, EntitiesWebhook, RequestsDiscordStore, RequestsDiscordUpdate, + RequestsSendScheduleStore, RequestsUserNotificationUpdate, RequestsUserPaymentInvoice, RequestsWebhookStore, @@ -26,6 +28,8 @@ import { ResponsesOkString, ResponsesPhoneAPIKeyResponse, ResponsesPhoneAPIKeysResponse, + ResponsesSendScheduleResponse, + ResponsesSendSchedulesResponse, ResponsesUnprocessableEntity, ResponsesUserResponse, ResponsesUserSubscriptionPaymentsResponse, @@ -366,8 +370,8 @@ export const actions = { context: ActionContext, phone: EntitiesPhone, ) { - await axios - .put(`/v1/phones`, { + try { + const response = await axios.put(`/v1/phones`, { fcm_token: phone.fcm_token, sim: phone.sim, phone_number: phone.phone_number, @@ -377,18 +381,18 @@ 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.schedule_id ?? null, }) - .catch((error: AxiosError) => { - context.dispatch('handleAxiosError', error) - }) - .then((response: any) => { - context.dispatch('addNotification', { - message: response.data.message, - type: 'success', - }) + + context.dispatch('addNotification', { + message: response.data.message, + type: 'success', }) - await context.dispatch('loadPhones', true) + await context.dispatch('loadPhones', true) + } catch (error) { + await context.dispatch('handleAxiosError', error as AxiosError) + } }, sendBulkMessages(context: ActionContext, document: File) { @@ -1104,6 +1108,91 @@ export const actions = { }) }, + getSendSchedules(context: ActionContext) { + return new Promise>((resolve, reject) => { + axios + .get(`/v1/send-schedules`) + .then((response: AxiosResponse) => { + resolve(response.data.data) + }) + .catch(async (error: AxiosError) => { + await context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while fetching send schedules', + type: 'error', + }) + reject(getErrorMessages(error)) + }) + }) + }, + + createSendSchedule( + context: ActionContext, + payload: RequestsSendScheduleStore, + ) { + return new Promise((resolve, reject) => { + axios + .post(`/v1/send-schedules`, payload) + .then((response: AxiosResponse) => { + resolve(response.data.data) + }) + .catch(async (error: AxiosError) => { + await context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while creating send schedule', + type: 'error', + }) + reject(getErrorMessages(error)) + }) + }) + }, + + updateSendSchedule( + context: ActionContext, + payload: RequestsSendScheduleStore & { id: string }, + ) { + return new Promise((resolve, reject) => { + axios + .put( + `/v1/send-schedules/${payload.id}`, + payload, + ) + .then((response: AxiosResponse) => { + resolve(response.data.data) + }) + .catch(async (error: AxiosError) => { + 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) { + return new Promise((resolve, reject) => { + axios + .delete(`/v1/send-schedules/${payload}`) + .then(() => { + resolve() + }) + .catch(async (error: AxiosError) => { + await context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while deleting send schedule', + type: 'error', + }) + reject(getErrorMessages(error)) + }) + }) + }, + createWebhook( context: ActionContext, payload: RequestsWebhookStore,