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": "
Use your API Key in the x-api-key HTTP Header when
@@ -210,6 +211,7 @@
List of mobile phones which are registered for sending and
@@ -357,6 +361,7 @@
+ 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.
+
+ Send Schedules
+
+
+
+
+
+ Name
+ Timezone
+ Schedule
+ Action
+
+
+
+
+
+ {{ schedule.name }}
+
+
+ {{ schedule.timezone }}
+
+
+
+
+
+
Email Notifications
@@ -415,6 +485,7 @@