mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 12:33:00 +00:00
feat(sms): implement SMS messaging and metered billing
Implement a complete SMS messaging system including: - Integration with SMS Manager.cz API for sending messages. - Metered billing via Stripe using monthly aggregate invoice items. - Backend services for managing SMS settings, usage logging, and monthly reporting. - Database migrations for tenant settings, usage logs, and monthly reports. - Frontend dashboard components for SMS configuration, usage tracking, and history. - Support for customer phone numbers in the booking flow. Includes new migrations, backend services, and frontend UI components.
This commit is contained in:
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/httpx"
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
"bookra/apps/backend/internal/sms"
|
||||
"bookra/apps/backend/internal/tenancy"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
@@ -36,6 +38,7 @@ type Server struct {
|
||||
adminService *admin.Service
|
||||
billingService *billing.Service
|
||||
notificationService *notifications.Service
|
||||
smsService *sms.Service
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
@@ -53,6 +56,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
catalogService := catalog.NewService(repository, billingService, notificationService)
|
||||
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
||||
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
||||
smsService := sms.NewService(cfg, repository)
|
||||
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
||||
|
||||
server := &Server{
|
||||
@@ -64,6 +68,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
adminService: adminService,
|
||||
billingService: billingService,
|
||||
notificationService: notificationService,
|
||||
smsService: smsService,
|
||||
}
|
||||
|
||||
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
|
||||
@@ -339,6 +344,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
LocationID *string `json:"locationId"`
|
||||
CustomerName string `json:"customerName" binding:"required"`
|
||||
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
||||
CustomerPhone string `json:"customerPhone"`
|
||||
Notes string `json:"notes"`
|
||||
StartsAt string `json:"startsAt" binding:"required"`
|
||||
EndsAt string `json:"endsAt" binding:"required"`
|
||||
@@ -801,6 +807,141 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// SMS API
|
||||
// ============================================
|
||||
protected.GET("/sms/settings", func(c *gin.Context) {
|
||||
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
settings, err := smsService.GetSettings(c.Request.Context(), membership.Tenant.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
})
|
||||
protected.POST("/sms/settings", func(c *gin.Context) {
|
||||
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var request domain.UpdateSMSSettingsRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
settings, err := smsService.UpdateSettings(c.Request.Context(), membership.Tenant.ID, request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, sms.ErrSMSPlanNotAllowed) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
})
|
||||
protected.POST("/sms/send", func(c *gin.Context) {
|
||||
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var request domain.SendSMSRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := smsService.SendMessage(c.Request.Context(), membership.Tenant.ID, request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, sms.ErrSMSNotConfigured), errors.Is(err, sms.ErrSMSNotEnabled):
|
||||
status = http.StatusServiceUnavailable
|
||||
case errors.Is(err, sms.ErrSMSPlanNotAllowed):
|
||||
status = http.StatusForbidden
|
||||
case errors.Is(err, sms.ErrSMSLimitReached):
|
||||
status = http.StatusTooManyRequests
|
||||
case errors.Is(err, sms.ErrSMSInvalidPhone):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.GET("/sms/usage", func(c *gin.Context) {
|
||||
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
yearMonth := c.Query("month")
|
||||
if yearMonth == "" {
|
||||
now := time.Now().UTC()
|
||||
yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month())
|
||||
}
|
||||
report, err := smsService.GetUsage(c.Request.Context(), membership.Tenant.ID, yearMonth)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, report)
|
||||
})
|
||||
protected.GET("/sms/history", func(c *gin.Context) {
|
||||
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
logs, err := smsService.GetUsageHistory(c.Request.Context(), membership.Tenant.ID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
||||
})
|
||||
protected.GET("/sms/invoices", func(c *gin.Context) {
|
||||
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
limit := 12
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 24 {
|
||||
limit = l
|
||||
}
|
||||
reports, err := smsService.GetMonthlyReports(c.Request.Context(), membership.Tenant.ID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"reports": reports})
|
||||
})
|
||||
|
||||
// Internal job endpoint for SMS monthly invoicing
|
||||
server.router.POST("/v1/internal/jobs/sms/invoices", func(c *gin.Context) {
|
||||
if !authorizeJobRunner(c, cfg) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
yearMonth := c.Query("month")
|
||||
response, err := smsService.GenerateMonthlyInvoices(c.Request.Context(), yearMonth, notificationService)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) {
|
||||
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
Reference in New Issue
Block a user