mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 12:33:00 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/auth"
|
||||
"bookra/apps/backend/internal/billing"
|
||||
"bookra/apps/backend/internal/bookings"
|
||||
"bookra/apps/backend/internal/catalog"
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/httpx"
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
"bookra/apps/backend/internal/tenancy"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
router *gin.Engine
|
||||
cfg config.Config
|
||||
pools *db.Pools
|
||||
verifier *auth.Verifier
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
verifier, err := auth.NewVerifier(cfg.NeonAuthURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repository := db.NewRepository(pools)
|
||||
bookingService := bookings.NewService(repository)
|
||||
tenantService := tenancy.NewService(repository)
|
||||
billingService := billing.NewService(cfg, repository)
|
||||
notificationService := notifications.NewService(cfg, repository)
|
||||
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
||||
|
||||
server := &Server{
|
||||
router: gin.New(),
|
||||
cfg: cfg,
|
||||
pools: pools,
|
||||
verifier: verifier,
|
||||
}
|
||||
|
||||
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
|
||||
AllowOrigins: []string{cfg.FrontendURL},
|
||||
AllowHeaders: []string{"Authorization", "Content-Type"},
|
||||
AllowMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowCredentials: true,
|
||||
}), httpx.SecurityHeaders())
|
||||
|
||||
server.router.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"environment": cfg.Environment,
|
||||
"databaseConfigured": pools.DatabaseConfigured(),
|
||||
})
|
||||
})
|
||||
|
||||
server.router.GET("/v1/meta/config", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"environment": cfg.Environment,
|
||||
"neonAuthEnabled": verifier.Enabled(),
|
||||
"apiUrl": cfg.APIURL,
|
||||
})
|
||||
})
|
||||
|
||||
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
|
||||
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, bookings.ErrTenantNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
server.router.POST("/v1/public/bookings", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||
var request struct {
|
||||
TenantSlug string `json:"tenantSlug" binding:"required"`
|
||||
BookingMode string `json:"bookingMode" binding:"required"`
|
||||
ServiceID *string `json:"serviceId"`
|
||||
ClassSessionID *string `json:"classSessionId"`
|
||||
StaffID *string `json:"staffId"`
|
||||
LocationID *string `json:"locationId"`
|
||||
CustomerName string `json:"customerName" binding:"required"`
|
||||
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
||||
Notes string `json:"notes"`
|
||||
StartsAt string `json:"startsAt" binding:"required"`
|
||||
EndsAt string `json:"endsAt" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
|
||||
response, err := bookingService.Create(c.Request.Context(), domain.CreateBookingRequest(request))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, bookings.ErrInvalidBooking):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, bookings.ErrTenantNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, bookings.ErrBookingConflict):
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
|
||||
protected := server.router.Group("/v1")
|
||||
protected.Use(auth.RequireAuth(verifier, repository))
|
||||
|
||||
protected.GET("/dashboard/summary", func(c *gin.Context) {
|
||||
response, err := bookingService.DashboardSummary(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, bookings.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.GET("/tenants/bootstrap", func(c *gin.Context) {
|
||||
response, err := tenantService.Bootstrap(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/tenants/onboard", func(c *gin.Context) {
|
||||
var request domain.OnboardTenantRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := tenantService.Onboard(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, tenancy.ErrInvalidOnboarding):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, tenancy.ErrTenantAlreadyProvisioned), errors.Is(err, tenancy.ErrTenantSlugTaken):
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
|
||||
_ = catalog.NewService()
|
||||
|
||||
protected.GET("/billing/subscription", func(c *gin.Context) {
|
||||
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, billing.ErrBillingMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/billing/checkout", func(c *gin.Context) {
|
||||
var request domain.CheckoutSessionRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, billing.ErrBillingMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if errors.Is(err, billing.ErrBillingPlanUnsupported) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/billing/refresh", func(c *gin.Context) {
|
||||
response, err := billingService.Refresh(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, billing.ErrBillingMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
|
||||
payload, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_payload"})
|
||||
return
|
||||
}
|
||||
if err := billingService.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
||||
})
|
||||
|
||||
server.router.POST("/v1/internal/jobs/reminders/dispatch", func(c *gin.Context) {
|
||||
if !authorizeJobRunner(c, cfg) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var request domain.DispatchReminderJobsRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
|
||||
response, err := notificationService.DispatchDue(c.Request.Context(), request.Limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.router
|
||||
}
|
||||
|
||||
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
|
||||
if cfg.JobRunnerKey == "" {
|
||||
return cfg.Environment == "development"
|
||||
}
|
||||
return c.GetHeader("X-Bookra-Job-Key") == cfg.JobRunnerKey
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const principalContextKey = "principal"
|
||||
|
||||
func RequireAuth(verifier *Verifier, repo db.Repository) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if verifier == nil || !verifier.Enabled() {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"})
|
||||
return
|
||||
}
|
||||
|
||||
header := c.GetHeader("Authorization")
|
||||
tokenString, ok := strings.CutPrefix(header, "Bearer ")
|
||||
if !ok || strings.TrimSpace(tokenString) == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing_bearer_token"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := verifier.Verify(strings.TrimSpace(tokenString))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
|
||||
subject, _ := claims["sub"].(string)
|
||||
email, _ := claims["email"].(string)
|
||||
name, _ := claims["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = claims["display_name"].(string)
|
||||
}
|
||||
role, _ := claims["role"].(string)
|
||||
if role == "" {
|
||||
role = "authenticated"
|
||||
}
|
||||
if strings.TrimSpace(subject) == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token_subject"})
|
||||
return
|
||||
}
|
||||
if repo != nil {
|
||||
if err := repo.EnsureUserIdentity(c.Request.Context(), subject, email, name); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "identity_sync_failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Set(principalContextKey, domain.Principal{
|
||||
Subject: subject,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Role: role,
|
||||
})
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func PrincipalFromContext(c *gin.Context) domain.Principal {
|
||||
value, ok := c.Get(principalContextKey)
|
||||
if !ok {
|
||||
return domain.Principal{}
|
||||
}
|
||||
principal, _ := value.(domain.Principal)
|
||||
return principal
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/MicahParks/keyfunc/v3"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Verifier struct {
|
||||
jwks keyfunc.Keyfunc
|
||||
expectedIssuer string
|
||||
enabled bool
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewVerifier(neonAuthURL string) (*Verifier, error) {
|
||||
trimmed := strings.TrimSpace(neonAuthURL)
|
||||
if trimmed == "" {
|
||||
return &Verifier{enabled: false}, nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse neon auth url: %w", err)
|
||||
}
|
||||
|
||||
expectedIssuer := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
jwksURL := fmt.Sprintf("%s/.well-known/jwks.json", trimmed)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("create jwks: %w", err)
|
||||
}
|
||||
|
||||
return &Verifier{
|
||||
jwks: jwks,
|
||||
expectedIssuer: expectedIssuer,
|
||||
enabled: true,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) Enabled() bool {
|
||||
return v.enabled
|
||||
}
|
||||
|
||||
func (v *Verifier) Close() {
|
||||
if v.cancel != nil {
|
||||
v.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Verifier) Verify(tokenString string) (jwt.MapClaims, error) {
|
||||
if !v.enabled {
|
||||
return nil, errors.New("neon auth verifier is disabled")
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
|
||||
jwt.WithIssuer(v.expectedIssuer),
|
||||
jwt.WithValidMethods([]string{"EdDSA"}),
|
||||
jwt.WithAudience(v.expectedIssuer),
|
||||
jwt.WithLeeway(15*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token claims")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stripe/stripe-go/v83"
|
||||
"github.com/stripe/stripe-go/v83/checkout/session"
|
||||
"github.com/stripe/stripe-go/v83/customer"
|
||||
"github.com/stripe/stripe-go/v83/subscription"
|
||||
"github.com/stripe/stripe-go/v83/webhook"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBillingMembership = errors.New("billing membership not found")
|
||||
ErrBillingPlanUnsupported = errors.New("billing plan is not configured")
|
||||
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
|
||||
)
|
||||
|
||||
var allowedWebhookEvents = []string{
|
||||
"checkout.session.completed",
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"customer.subscription.paused",
|
||||
"customer.subscription.resumed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"payment_intent.succeeded",
|
||||
"payment_intent.payment_failed",
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
return &Service{cfg: cfg, repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.SubscriptionSnapshot{}, ErrBillingMembership
|
||||
}
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
record, err := s.repo.GetSubscriptionSnapshot(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||
TenantID: membership.Tenant.ID,
|
||||
StripeCustomerID: derefString(membership.Tenant.StripeCustomerID),
|
||||
Status: membership.Tenant.SubscriptionStatus,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
}, s.cfg), nil
|
||||
}
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string) (domain.CheckoutSessionResponse, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.CheckoutSessionResponse{}, ErrBillingMembership
|
||||
}
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
|
||||
priceID := s.cfg.StripePriceIDs[planCode]
|
||||
if priceID == "" {
|
||||
return domain.CheckoutSessionResponse{}, ErrBillingPlanUnsupported
|
||||
}
|
||||
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
mockURL := fmt.Sprintf("%s/dashboard?billing=mock-checkout&plan=%s", s.cfg.FrontendURL, planCode)
|
||||
return domain.CheckoutSessionResponse{URL: mockURL}, nil
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
customerID := derefString(membership.Tenant.StripeCustomerID)
|
||||
if customerID == "" {
|
||||
params := &stripe.CustomerParams{
|
||||
Name: stripe.String(membership.Tenant.Name),
|
||||
Email: stripe.String(principal.Email),
|
||||
Metadata: map[string]string{"tenant_id": membership.Tenant.ID, "tenant_slug": membership.Tenant.Slug},
|
||||
}
|
||||
createdCustomer, err := customer.New(params)
|
||||
if err != nil {
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
customerID = createdCustomer.ID
|
||||
if err := s.repo.UpdateTenantStripeCustomerID(ctx, membership.Tenant.ID, customerID); err != nil {
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
Customer: stripe.String(customerID),
|
||||
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", s.cfg.FrontendURL)),
|
||||
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", s.cfg.FrontendURL)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
Metadata: map[string]string{
|
||||
"tenant_id": membership.Tenant.ID,
|
||||
"plan_code": planCode,
|
||||
},
|
||||
}
|
||||
|
||||
checkoutSession, err := session.New(params)
|
||||
if err != nil {
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
return domain.CheckoutSessionResponse{URL: checkoutSession.URL}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.SubscriptionSnapshot{}, ErrBillingMembership
|
||||
}
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
customerID := derefString(membership.Tenant.StripeCustomerID)
|
||||
if customerID == "" {
|
||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||
TenantID: membership.Tenant.ID,
|
||||
StripeCustomerID: "",
|
||||
Status: "none",
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
}, s.cfg), nil
|
||||
}
|
||||
record, err := s.syncStripeData(ctx, membership.Tenant, customerID)
|
||||
if err != nil {
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||
}
|
||||
|
||||
func (s *Service) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return nil
|
||||
}
|
||||
if signature == "" {
|
||||
return ErrStripeSignatureMissing
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
customerID := extractCustomerID(event)
|
||||
if customerID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tenant, err := s.repo.GetTenantByStripeCustomerID(ctx, customerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
inserted, err := s.repo.RecordStripeEvent(ctx, tenant.ID, event.ID, string(event.Type), payload)
|
||||
if err != nil || !inserted {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.syncStripeData(ctx, tenant, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) syncStripeData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
now := time.Now().UTC()
|
||||
record := db.BillingSnapshotRecord{
|
||||
TenantID: tenant.ID,
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: "",
|
||||
Status: tenant.SubscriptionStatus,
|
||||
PlanCode: tenant.PlanCode,
|
||||
PriceID: s.cfg.StripePriceIDs[tenant.PlanCode],
|
||||
LastSyncedAt: &now,
|
||||
}
|
||||
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
|
||||
params.Status = stripe.String("all")
|
||||
params.AddExpand("data.default_payment_method")
|
||||
params.AddExpand("data.items.data.price")
|
||||
|
||||
iter := subscription.List(params)
|
||||
if iter.Err() != nil {
|
||||
return db.BillingSnapshotRecord{}, iter.Err()
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
record := db.BillingSnapshotRecord{
|
||||
TenantID: tenant.ID,
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: "",
|
||||
Status: "none",
|
||||
PlanCode: tenant.PlanCode,
|
||||
PriceID: "",
|
||||
LastSyncedAt: &now,
|
||||
}
|
||||
|
||||
if iter.Next() {
|
||||
subscriptionRecord := iter.Subscription()
|
||||
record.StripeSubscriptionID = subscriptionRecord.ID
|
||||
record.Status = string(subscriptionRecord.Status)
|
||||
record.CancelAtPeriodEnd = subscriptionRecord.CancelAtPeriodEnd
|
||||
if len(subscriptionRecord.Items.Data) > 0 {
|
||||
record.PriceID = subscriptionRecord.Items.Data[0].Price.ID
|
||||
record.PlanCode = s.planCodeForPrice(record.PriceID, tenant.PlanCode)
|
||||
record.CurrentPeriodStart = toTimePtr(subscriptionRecord.Items.Data[0].CurrentPeriodStart)
|
||||
record.CurrentPeriodEnd = toTimePtr(subscriptionRecord.Items.Data[0].CurrentPeriodEnd)
|
||||
}
|
||||
if subscriptionRecord.DefaultPaymentMethod != nil && subscriptionRecord.DefaultPaymentMethod.Card != nil {
|
||||
record.PaymentMethodBrand = string(subscriptionRecord.DefaultPaymentMethod.Card.Brand)
|
||||
record.PaymentMethodLast4 = subscriptionRecord.DefaultPaymentMethod.Card.Last4
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.StripeSubscriptionID); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
|
||||
if record.PlanCode == "" {
|
||||
record.PlanCode = tenant.PlanCode
|
||||
}
|
||||
if record.Status == "" {
|
||||
record.Status = tenant.SubscriptionStatus
|
||||
}
|
||||
return domain.SubscriptionSnapshot{
|
||||
TenantID: tenant.ID,
|
||||
CustomerID: record.StripeCustomerID,
|
||||
SubscriptionID: record.StripeSubscriptionID,
|
||||
Status: record.Status,
|
||||
PlanCode: record.PlanCode,
|
||||
PriceID: record.PriceID,
|
||||
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
|
||||
CurrentPeriodStart: record.CurrentPeriodStart,
|
||||
CurrentPeriodEnd: record.CurrentPeriodEnd,
|
||||
PaymentMethodBrand: record.PaymentMethodBrand,
|
||||
PaymentMethodLast4: record.PaymentMethodLast4,
|
||||
Entitlements: entitlementsForPlan(record.PlanCode),
|
||||
LastSyncedAt: record.LastSyncedAt,
|
||||
CheckoutURLAvailable: cfg.StripePriceIDs[record.PlanCode] != "",
|
||||
}
|
||||
}
|
||||
|
||||
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
||||
switch planCode {
|
||||
case "starter":
|
||||
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, SMSAddonAvailable: false, AdvancedReporting: false}
|
||||
case "multi-location":
|
||||
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, SMSAddonAvailable: true, AdvancedReporting: true}
|
||||
default:
|
||||
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, SMSAddonAvailable: true, AdvancedReporting: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) planCodeForPrice(priceID string, fallback string) string {
|
||||
for code, configuredPriceID := range s.cfg.StripePriceIDs {
|
||||
if configuredPriceID != "" && configuredPriceID == priceID {
|
||||
return code
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func toTimePtr(value int64) *time.Time {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
timestamp := time.Unix(value, 0).UTC()
|
||||
return ×tamp
|
||||
}
|
||||
|
||||
func extractCustomerID(event stripe.Event) string {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := payload["customer"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
customerID, _ := value.(string)
|
||||
return customerID
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
},
|
||||
}, db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get subscription: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.PlanCode != "growth" {
|
||||
t.Fatalf("expected growth, got %s", snapshot.PlanCode)
|
||||
}
|
||||
if snapshot.Entitlements.MaxLocations != 3 {
|
||||
t.Fatalf("expected 3 locations, got %d", snapshot.Entitlements.MaxLocations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCheckoutUsesMockURLWithoutStripeKey(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
},
|
||||
}, db.NewMemoryRepository())
|
||||
|
||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
}, "growth")
|
||||
if err != nil {
|
||||
t.Fatalf("create checkout: %v", err)
|
||||
}
|
||||
if response.URL == "" {
|
||||
t.Fatal("expected checkout url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshReturnsSnapshotWithoutStripeKey(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
},
|
||||
}, db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.Refresh(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.Status != "active" {
|
||||
t.Fatalf("expected active status, got %s", snapshot.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTenantNotFound = errors.New("tenant not found")
|
||||
ErrInvalidBooking = errors.New("invalid booking request")
|
||||
ErrBookingConflict = errors.New("booking conflict")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.PublicAvailability, error) {
|
||||
tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.PublicAvailability{}, ErrTenantNotFound
|
||||
}
|
||||
return domain.PublicAvailability{}, err
|
||||
}
|
||||
|
||||
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
|
||||
if err != nil {
|
||||
return domain.PublicAvailability{}, err
|
||||
}
|
||||
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID)
|
||||
if err != nil {
|
||||
return domain.PublicAvailability{}, err
|
||||
}
|
||||
windowStart := time.Now().UTC()
|
||||
windowEnd := windowStart.AddDate(0, 0, 7)
|
||||
existingBookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, windowStart, windowEnd)
|
||||
if err != nil {
|
||||
return domain.PublicAvailability{}, err
|
||||
}
|
||||
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8)
|
||||
if err != nil {
|
||||
return domain.PublicAvailability{}, err
|
||||
}
|
||||
|
||||
slots := make([]domain.TimeSlot, 0, 8)
|
||||
slots = append(slots, generateAppointmentSlots(tenant, services, rules, existingBookings)...)
|
||||
slots = append(slots, generateClassSlots(classSessions, existingBookings)...)
|
||||
sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt })
|
||||
if len(slots) > 8 {
|
||||
slots = slots[:8]
|
||||
}
|
||||
|
||||
return domain.PublicAvailability{
|
||||
TenantSlug: tenant.Slug,
|
||||
Timezone: tenant.Timezone,
|
||||
Locale: tenant.Locale,
|
||||
Slots: slots,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, request domain.CreateBookingRequest) (domain.CreateBookingResponse, error) {
|
||||
tenant, err := s.repo.GetTenantBySlug(ctx, request.TenantSlug)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.CreateBookingResponse{}, ErrTenantNotFound
|
||||
}
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
|
||||
startsAt, err := time.Parse(time.RFC3339, request.StartsAt)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidBooking)
|
||||
}
|
||||
endsAt, err := time.Parse(time.RFC3339, request.EndsAt)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: endsAt must be RFC3339", ErrInvalidBooking)
|
||||
}
|
||||
if !endsAt.After(startsAt) {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: endsAt must be after startsAt", ErrInvalidBooking)
|
||||
}
|
||||
|
||||
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
|
||||
status := "confirmed"
|
||||
if request.BookingMode == "class" && request.ClassSessionID != nil {
|
||||
classBookings := countClassBookings(existing, *request.ClassSessionID)
|
||||
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, startsAt.Add(-1*time.Minute), 16)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
sessionCapacity := int32(0)
|
||||
for _, session := range classSessions {
|
||||
if session.ID == *request.ClassSessionID {
|
||||
sessionCapacity = session.Capacity
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCapacity > 0 && classBookings >= sessionCapacity {
|
||||
status = "waitlisted"
|
||||
}
|
||||
} else {
|
||||
for _, booking := range existing {
|
||||
if booking.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
if sameResource(booking.StaffID, request.StaffID) || sameResource(booking.LocationID, request.LocationID) {
|
||||
return domain.CreateBookingResponse{}, ErrBookingConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||
TenantID: tenant.ID,
|
||||
ServiceID: request.ServiceID,
|
||||
ClassSessionID: request.ClassSessionID,
|
||||
StaffID: request.StaffID,
|
||||
LocationID: request.LocationID,
|
||||
BookingMode: request.BookingMode,
|
||||
CustomerName: request.CustomerName,
|
||||
CustomerEmail: request.CustomerEmail,
|
||||
StartsAt: startsAt.UTC(),
|
||||
EndsAt: endsAt.UTC(),
|
||||
Status: status,
|
||||
Reference: db.Reference("BK", time.Now()),
|
||||
Notes: request.Notes,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
|
||||
if status == "waitlisted" && request.ClassSessionID != nil {
|
||||
waitlistPosition := int(countClassBookings(existing, *request.ClassSessionID)) + 1
|
||||
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
|
||||
TenantID: tenant.ID,
|
||||
ClassSessionID: *request.ClassSessionID,
|
||||
CustomerName: request.CustomerName,
|
||||
CustomerEmail: request.CustomerEmail,
|
||||
Position: waitlistPosition,
|
||||
}); err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if scheduledFor, ok := reminderSchedule(startsAt); ok {
|
||||
if err := s.repo.CreateReminderJob(ctx, db.ReminderJobParams{
|
||||
TenantID: tenant.ID,
|
||||
BookingID: created.ID,
|
||||
Channel: "email",
|
||||
ScheduledFor: scheduledFor,
|
||||
}); err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return domain.CreateBookingResponse{
|
||||
BookingID: created.ID,
|
||||
Reference: created.Reference,
|
||||
Status: created.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.DashboardSummary{}, ErrTenantMembership
|
||||
}
|
||||
return domain.DashboardSummary{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
weekEnd := now.AddDate(0, 0, 7)
|
||||
metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd)
|
||||
if err != nil {
|
||||
return domain.DashboardSummary{}, err
|
||||
}
|
||||
|
||||
return domain.DashboardSummary{
|
||||
TenantName: membership.Tenant.Name,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
KPIs: []domain.DashboardKPI{
|
||||
{Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)},
|
||||
{Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)},
|
||||
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateAppointmentSlots(
|
||||
tenant db.TenantRecord,
|
||||
services []db.ServiceRecord,
|
||||
rules []db.AvailabilityRuleRecord,
|
||||
existing []db.BookingRecord,
|
||||
) []domain.TimeSlot {
|
||||
if len(services) == 0 || len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation(tenant.Timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
|
||||
service := services[0]
|
||||
now := time.Now().In(location)
|
||||
var slots []domain.TimeSlot
|
||||
|
||||
for dayOffset := 0; dayOffset < 7 && len(slots) < 6; dayOffset++ {
|
||||
day := now.AddDate(0, 0, dayOffset)
|
||||
for _, rule := range rules {
|
||||
if int(day.Weekday()) != rule.DayOfWeek {
|
||||
continue
|
||||
}
|
||||
|
||||
startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location)
|
||||
windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location)
|
||||
|
||||
step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
|
||||
duration := time.Duration(service.DurationMinutes) * time.Minute
|
||||
for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) {
|
||||
if slotStart.Before(now.Add(2 * time.Hour)) {
|
||||
continue
|
||||
}
|
||||
|
||||
slotEnd := slotStart.Add(duration)
|
||||
if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) {
|
||||
continue
|
||||
}
|
||||
|
||||
serviceID := service.ID
|
||||
slots = append(slots, domain.TimeSlot{
|
||||
ServiceID: &serviceID,
|
||||
StaffID: rule.StaffID,
|
||||
StartsAt: slotStart.UTC().Format(time.RFC3339),
|
||||
EndsAt: slotEnd.UTC().Format(time.RFC3339),
|
||||
Mode: "appointment",
|
||||
Label: service.Name,
|
||||
})
|
||||
if len(slots) >= 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slots
|
||||
}
|
||||
|
||||
func generateClassSlots(classSessions []db.ClassSessionRecord, existing []db.BookingRecord) []domain.TimeSlot {
|
||||
slots := make([]domain.TimeSlot, 0, len(classSessions))
|
||||
for _, session := range classSessions {
|
||||
remaining := session.Capacity - countClassBookings(existing, session.ID)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
classSessionID := session.ID
|
||||
locationID := session.LocationID
|
||||
slots = append(slots, domain.TimeSlot{
|
||||
ClassSessionID: &classSessionID,
|
||||
LocationID: locationID,
|
||||
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
|
||||
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
|
||||
Mode: "class",
|
||||
Label: session.Title,
|
||||
RemainingCapacity: &remaining,
|
||||
})
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
func collides(bookings []db.BookingRecord, staffID *string, locationID *string, startsAt time.Time, endsAt time.Time) bool {
|
||||
for _, booking := range bookings {
|
||||
if booking.Status == "cancelled" || booking.Status == "waitlisted" {
|
||||
continue
|
||||
}
|
||||
if !(booking.StartsAt.Before(endsAt) && booking.EndsAt.After(startsAt)) {
|
||||
continue
|
||||
}
|
||||
if sameResource(booking.StaffID, staffID) || sameResource(booking.LocationID, locationID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sameResource(left *string, right *string) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func countClassBookings(bookings []db.BookingRecord, classSessionID string) int32 {
|
||||
var total int32
|
||||
for _, booking := range bookings {
|
||||
if booking.ClassSessionID == nil {
|
||||
continue
|
||||
}
|
||||
if *booking.ClassSessionID == classSessionID && booking.Status == "confirmed" {
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func reminderSchedule(startsAt time.Time) (time.Time, bool) {
|
||||
now := time.Now().UTC()
|
||||
switch {
|
||||
case startsAt.After(now.Add(25 * time.Hour)):
|
||||
return startsAt.Add(-24 * time.Hour), true
|
||||
case startsAt.After(now.Add(3 * time.Hour)):
|
||||
return startsAt.Add(-2 * time.Hour), true
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestCreateAppointmentRejectsConflict(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
|
||||
var appointment domain.TimeSlot
|
||||
for _, slot := range availability.Slots {
|
||||
if slot.Mode == "appointment" {
|
||||
appointment = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
if appointment.StartsAt == "" {
|
||||
t.Fatal("expected appointment slot")
|
||||
}
|
||||
|
||||
first, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
ServiceID: appointment.ServiceID,
|
||||
StaffID: appointment.StaffID,
|
||||
LocationID: appointment.LocationID,
|
||||
CustomerName: "First",
|
||||
CustomerEmail: "first@example.com",
|
||||
StartsAt: appointment.StartsAt,
|
||||
EndsAt: appointment.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
if first.Status != "confirmed" {
|
||||
t.Fatalf("expected confirmed, got %s", first.Status)
|
||||
}
|
||||
|
||||
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
ServiceID: appointment.ServiceID,
|
||||
StaffID: appointment.StaffID,
|
||||
LocationID: appointment.LocationID,
|
||||
CustomerName: "Second",
|
||||
CustomerEmail: "second@example.com",
|
||||
StartsAt: appointment.StartsAt,
|
||||
EndsAt: appointment.EndsAt,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if err != ErrBookingConflict {
|
||||
t.Fatalf("expected ErrBookingConflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
|
||||
var classSlot domain.TimeSlot
|
||||
for _, slot := range availability.Slots {
|
||||
if slot.Mode == "class" {
|
||||
classSlot = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
if classSlot.ClassSessionID == nil {
|
||||
t.Fatal("expected class slot")
|
||||
}
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "class",
|
||||
ClassSessionID: classSlot.ClassSessionID,
|
||||
LocationID: classSlot.LocationID,
|
||||
CustomerName: "Capacity",
|
||||
CustomerEmail: "capacity@example.com",
|
||||
StartsAt: classSlot.StartsAt,
|
||||
EndsAt: classSlot.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create within capacity: %v", err)
|
||||
}
|
||||
if response.Status != "confirmed" {
|
||||
t.Fatalf("expected confirmed within capacity, got %s", response.Status)
|
||||
}
|
||||
}
|
||||
|
||||
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "class",
|
||||
ClassSessionID: classSlot.ClassSessionID,
|
||||
LocationID: classSlot.LocationID,
|
||||
CustomerName: "Waitlist",
|
||||
CustomerEmail: "waitlist@example.com",
|
||||
StartsAt: classSlot.StartsAt,
|
||||
EndsAt: classSlot.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create waitlist: %v", err)
|
||||
}
|
||||
if response.Status != "waitlisted" {
|
||||
t.Fatalf("expected waitlisted, got %s", response.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
if len(availability.Slots) == 0 {
|
||||
t.Fatal("expected slots")
|
||||
}
|
||||
|
||||
for _, slot := range availability.Slots {
|
||||
startsAt, err := time.Parse(time.RFC3339, slot.StartsAt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse startsAt: %v", err)
|
||||
}
|
||||
if startsAt.Before(time.Now().UTC().Add(90 * time.Minute)) {
|
||||
t.Fatalf("expected upcoming slot, got %s", slot.StartsAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
|
||||
var appointment domain.TimeSlot
|
||||
for _, slot := range availability.Slots {
|
||||
if slot.Mode == "appointment" {
|
||||
appointment = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
if appointment.StartsAt == "" {
|
||||
t.Fatal("expected appointment slot")
|
||||
}
|
||||
|
||||
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
ServiceID: appointment.ServiceID,
|
||||
StaffID: appointment.StaffID,
|
||||
LocationID: appointment.LocationID,
|
||||
CustomerName: "Reminder",
|
||||
CustomerEmail: "reminder@example.com",
|
||||
StartsAt: time.Now().UTC().Add(30 * time.Hour).Format(time.RFC3339),
|
||||
EndsAt: time.Now().UTC().Add(31 * time.Hour).Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
reminders, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().Add(365*24*time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminder jobs: %v", err)
|
||||
}
|
||||
if len(reminders) == 0 {
|
||||
t.Fatal("expected reminder job to be scheduled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package catalog
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string
|
||||
Port string
|
||||
APIURL string
|
||||
FrontendURL string
|
||||
DatabaseURL string
|
||||
DatabaseDirectURL string
|
||||
NeonAuthURL string
|
||||
JobRunnerKey string
|
||||
EmailFrom string
|
||||
SMSFrom string
|
||||
StripeSecretKey string
|
||||
StripeWebhookKey string
|
||||
StripePriceIDs map[string]string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
|
||||
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
|
||||
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
|
||||
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
|
||||
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
|
||||
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
|
||||
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
|
||||
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
|
||||
SMSFrom: valueOrDefault("BOOKRA_SMS_FROM", "Bookra"),
|
||||
StripeSecretKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SECRET_KEY")),
|
||||
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
|
||||
StripePriceIDs: map[string]string{
|
||||
"starter": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_STARTER_PRICE_ID")),
|
||||
"growth": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_GROWTH_PRICE_ID")),
|
||||
"multi-location": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_MULTI_LOCATION_PRICE_ID")),
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.FrontendURL == "" {
|
||||
return Config{}, errors.New("BOOKRA_FRONTEND_URL is required")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func valueOrDefault(key string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Pools struct {
|
||||
App *pgxpool.Pool
|
||||
Direct *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPools(cfg config.Config) (*Pools, error) {
|
||||
pools := &Pools{}
|
||||
|
||||
if cfg.DatabaseURL != "" {
|
||||
pool, err := connect(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pools.App = pool
|
||||
}
|
||||
|
||||
if cfg.DatabaseDirectURL != "" {
|
||||
pool, err := connect(cfg.DatabaseDirectURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pools.Direct = pool
|
||||
}
|
||||
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
func (p *Pools) Close() {
|
||||
if p.App != nil {
|
||||
p.App.Close()
|
||||
}
|
||||
if p.Direct != nil {
|
||||
p.Direct.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pools) DatabaseConfigured() bool {
|
||||
return p.App != nil || p.Direct != nil
|
||||
}
|
||||
|
||||
func connect(databaseURL string) (*pgxpool.Pool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return pgxpool.New(ctx, databaseURL)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Principal struct {
|
||||
Subject string `json:"subject"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type DashboardKPI struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type DashboardSummary struct {
|
||||
TenantName string `json:"tenantName"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode"`
|
||||
KPIs []DashboardKPI `json:"kpis"`
|
||||
}
|
||||
|
||||
type TenantBootstrap struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantName"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode,omitempty"`
|
||||
CurrentUser Principal `json:"currentUser"`
|
||||
}
|
||||
|
||||
type OnboardTenantRequest struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type TimeSlot struct {
|
||||
ServiceID *string `json:"serviceId,omitempty"`
|
||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
LocationID *string `json:"locationId,omitempty"`
|
||||
StartsAt string `json:"startsAt"`
|
||||
EndsAt string `json:"endsAt"`
|
||||
Mode string `json:"mode"`
|
||||
Label string `json:"label"`
|
||||
RemainingCapacity *int32 `json:"remainingCapacity,omitempty"`
|
||||
}
|
||||
|
||||
type PublicAvailability struct {
|
||||
TenantSlug string `json:"tenantSlug"`
|
||||
Timezone string `json:"timezone"`
|
||||
Locale string `json:"locale"`
|
||||
Slots []TimeSlot `json:"slots"`
|
||||
}
|
||||
|
||||
type CreateBookingRequest struct {
|
||||
TenantSlug string `json:"tenantSlug"`
|
||||
BookingMode string `json:"bookingMode"`
|
||||
ServiceID *string `json:"serviceId,omitempty"`
|
||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
LocationID *string `json:"locationId,omitempty"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
Notes string `json:"notes"`
|
||||
StartsAt string `json:"startsAt"`
|
||||
EndsAt string `json:"endsAt"`
|
||||
}
|
||||
|
||||
type CreateBookingResponse struct {
|
||||
BookingID string `json:"bookingId"`
|
||||
Reference string `json:"reference"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type PlanEntitlements struct {
|
||||
MaxLocations int `json:"maxLocations"`
|
||||
MaxStaff int `json:"maxStaff"`
|
||||
SMSAddonAvailable bool `json:"smsAddonAvailable"`
|
||||
AdvancedReporting bool `json:"advancedReporting"`
|
||||
}
|
||||
|
||||
type SubscriptionSnapshot struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
CustomerID string `json:"customerId"`
|
||||
SubscriptionID string `json:"subscriptionId"`
|
||||
Status string `json:"status"`
|
||||
PlanCode string `json:"planCode"`
|
||||
PriceID string `json:"priceId"`
|
||||
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
|
||||
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
|
||||
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
|
||||
PaymentMethodBrand string `json:"paymentMethodBrand,omitempty"`
|
||||
PaymentMethodLast4 string `json:"paymentMethodLast4,omitempty"`
|
||||
Entitlements PlanEntitlements `json:"entitlements"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable,omitempty"`
|
||||
}
|
||||
|
||||
type CheckoutSessionRequest struct {
|
||||
PlanCode string `json:"planCode"`
|
||||
}
|
||||
|
||||
type CheckoutSessionResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type DispatchReminderJobsRequest struct {
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type DispatchReminderJobsResponse struct {
|
||||
ProcessedCount int `json:"processedCount"`
|
||||
SentCount int `json:"sentCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Content-Security-Policy", "default-src 'self'; connect-src 'self' https:; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; base-uri 'self'; form-action 'self'")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*visitor
|
||||
limit rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
limit: limit,
|
||||
burst: burst,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RateLimiter) Middleware() gin.HandlerFunc {
|
||||
go r.cleanupLoop()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
if ip == "" {
|
||||
ip = "unknown"
|
||||
}
|
||||
limiter := r.getVisitor(ip)
|
||||
if !limiter.Allow() {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate_limited"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RateLimiter) getVisitor(key string) *rate.Limiter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
entry, ok := r.visitors[key]
|
||||
if !ok {
|
||||
entry = &visitor{
|
||||
limiter: rate.NewLimiter(r.limit, r.burst),
|
||||
lastSeen: time.Now(),
|
||||
}
|
||||
r.visitors[key] = entry
|
||||
return entry.limiter
|
||||
}
|
||||
|
||||
entry.lastSeen = time.Now()
|
||||
return entry.limiter
|
||||
}
|
||||
|
||||
func (r *RateLimiter) cleanupLoop() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
r.mu.Lock()
|
||||
cutoff := time.Now().Add(-15 * time.Minute)
|
||||
for key, entry := range r.visitors {
|
||||
if entry.lastSeen.Before(cutoff) {
|
||||
delete(r.visitors, key)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
var ErrUnsupportedChannel = errors.New("unsupported notification channel")
|
||||
|
||||
type DeliveryReceipt struct {
|
||||
Provider string
|
||||
ExternalID string
|
||||
}
|
||||
|
||||
type EmailMessage struct {
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Text string
|
||||
}
|
||||
|
||||
type SMSMessage struct {
|
||||
From string
|
||||
To string
|
||||
Text string
|
||||
}
|
||||
|
||||
type EmailProvider interface {
|
||||
Send(context.Context, EmailMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type SMSProvider interface {
|
||||
Send(context.Context, SMSMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
emailProvider EmailProvider
|
||||
smsProvider SMSProvider
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
repo: repo,
|
||||
emailProvider: noopEmailProvider{},
|
||||
smsProvider: noopSMSProvider{},
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchReminderJobsResponse, error) {
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
jobs, err := s.repo.ListDueReminderJobs(ctx, s.now(), limit)
|
||||
if err != nil {
|
||||
return domain.DispatchReminderJobsResponse{}, err
|
||||
}
|
||||
|
||||
response := domain.DispatchReminderJobsResponse{}
|
||||
for _, job := range jobs {
|
||||
response.ProcessedCount++
|
||||
|
||||
status := "sent"
|
||||
provider := "unknown"
|
||||
externalID := ""
|
||||
errorMessage := ""
|
||||
|
||||
switch job.Channel {
|
||||
case "email":
|
||||
receipt, sendErr := s.emailProvider.Send(ctx, renderEmailMessage(s.cfg.EmailFrom, job))
|
||||
if sendErr != nil {
|
||||
status = "failed"
|
||||
errorMessage = sendErr.Error()
|
||||
} else {
|
||||
provider = receipt.Provider
|
||||
externalID = receipt.ExternalID
|
||||
}
|
||||
case "sms":
|
||||
receipt, sendErr := s.smsProvider.Send(ctx, renderSMSMessage(s.cfg.SMSFrom, job))
|
||||
if sendErr != nil {
|
||||
status = "failed"
|
||||
errorMessage = sendErr.Error()
|
||||
} else {
|
||||
provider = receipt.Provider
|
||||
externalID = receipt.ExternalID
|
||||
}
|
||||
default:
|
||||
status = "failed"
|
||||
errorMessage = ErrUnsupportedChannel.Error()
|
||||
}
|
||||
|
||||
if provider == "unknown" {
|
||||
if job.Channel == "email" {
|
||||
provider = "noop-email"
|
||||
} else if job.Channel == "sms" {
|
||||
provider = "noop-sms"
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.MarkReminderJobDispatched(ctx, job.ID, status, s.now()); err != nil {
|
||||
return domain.DispatchReminderJobsResponse{}, err
|
||||
}
|
||||
if err := s.repo.CreateNotificationDeliveryLog(ctx, db.NotificationDeliveryLogParams{
|
||||
TenantID: job.TenantID,
|
||||
ReminderJobID: job.ID,
|
||||
Channel: job.Channel,
|
||||
Provider: provider,
|
||||
Recipient: reminderRecipient(job),
|
||||
Status: status,
|
||||
ExternalID: externalID,
|
||||
ErrorMessage: errorMessage,
|
||||
}); err != nil {
|
||||
return domain.DispatchReminderJobsResponse{}, err
|
||||
}
|
||||
|
||||
if status == "sent" {
|
||||
response.SentCount++
|
||||
} else {
|
||||
response.FailedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return EmailMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Subject: subject,
|
||||
Text: body,
|
||||
}
|
||||
}
|
||||
|
||||
func renderSMSMessage(from string, job db.ReminderJobRecord) SMSMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return SMSMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Text: fmt.Sprintf("%s: %s", subject, body),
|
||||
}
|
||||
}
|
||||
|
||||
func renderReminderCopy(job db.ReminderJobRecord) (string, string) {
|
||||
startLabel := localizedStartsAt(job)
|
||||
|
||||
if job.Locale == "cs" {
|
||||
return "Pripominka rezervace Bookra", fmt.Sprintf(
|
||||
"Dobry den %s,\n\npripominame rezervaci %s u %s na %s.\n\nReference: %s\n",
|
||||
job.CustomerName,
|
||||
job.Reference,
|
||||
job.TenantName,
|
||||
startLabel,
|
||||
job.Reference,
|
||||
)
|
||||
}
|
||||
|
||||
return "Bookra booking reminder", fmt.Sprintf(
|
||||
"Hello %s,\n\nthis is a reminder for booking %s with %s at %s.\n\nReference: %s\n",
|
||||
job.CustomerName,
|
||||
job.Reference,
|
||||
job.TenantName,
|
||||
startLabel,
|
||||
job.Reference,
|
||||
)
|
||||
}
|
||||
|
||||
func localizedStartsAt(job db.ReminderJobRecord) string {
|
||||
location, err := time.LoadLocation(job.Timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
localStartsAt := job.StartsAt.In(location)
|
||||
if job.Locale == "cs" {
|
||||
return localStartsAt.Format("02.01.2006 15:04")
|
||||
}
|
||||
return localStartsAt.Format("Jan 02, 2006 15:04")
|
||||
}
|
||||
|
||||
func reminderRecipient(job db.ReminderJobRecord) string {
|
||||
if job.Channel == "email" {
|
||||
return job.CustomerEmail
|
||||
}
|
||||
return job.CustomerEmail
|
||||
}
|
||||
|
||||
type noopEmailProvider struct{}
|
||||
|
||||
func (noopEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
|
||||
if message.To == "" {
|
||||
return DeliveryReceipt{Provider: "noop-email"}, errors.New("missing email recipient")
|
||||
}
|
||||
return DeliveryReceipt{
|
||||
Provider: "noop-email",
|
||||
ExternalID: fmt.Sprintf("noop-email-%d", time.Now().UnixNano()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type noopSMSProvider struct{}
|
||||
|
||||
func (noopSMSProvider) Send(_ context.Context, message SMSMessage) (DeliveryReceipt, error) {
|
||||
if message.To == "" {
|
||||
return DeliveryReceipt{Provider: "noop-sms"}, errors.New("missing sms recipient")
|
||||
}
|
||||
return DeliveryReceipt{
|
||||
Provider: "noop-sms",
|
||||
ExternalID: fmt.Sprintf("noop-sms-%d", time.Now().UnixNano()),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
startsAt := time.Now().UTC().Add(26 * time.Hour)
|
||||
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
|
||||
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||
BookingMode: "appointment",
|
||||
CustomerName: "Reminder Customer",
|
||||
CustomerEmail: "reminder@example.com",
|
||||
StartsAt: startsAt,
|
||||
EndsAt: startsAt.Add(time.Hour),
|
||||
Status: "confirmed",
|
||||
Reference: "BK-TEST-REMINDER",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create booking: %v", err)
|
||||
}
|
||||
if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{
|
||||
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||
BookingID: created.ID,
|
||||
Channel: "email",
|
||||
ScheduledFor: time.Now().UTC().Add(-time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("create reminder job: %v", err)
|
||||
}
|
||||
|
||||
service := NewService(config.Config{
|
||||
Environment: "development",
|
||||
EmailFrom: "noreply@bookra.dev",
|
||||
SMSFrom: "Bookra",
|
||||
}, repo)
|
||||
|
||||
response, err := service.DispatchDue(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch due: %v", err)
|
||||
}
|
||||
if response.ProcessedCount != 1 {
|
||||
t.Fatalf("expected processed count 1, got %d", response.ProcessedCount)
|
||||
}
|
||||
if response.SentCount != 1 {
|
||||
t.Fatalf("expected sent count 1, got %d", response.SentCount)
|
||||
}
|
||||
if response.FailedCount != 0 {
|
||||
t.Fatalf("expected failed count 0, got %d", response.FailedCount)
|
||||
}
|
||||
|
||||
pending, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().Add(time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list due reminder jobs: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("expected no pending jobs after dispatch, got %d", len(pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchDueFailsUnknownChannel(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{
|
||||
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||
BookingID: "booking-unknown-channel",
|
||||
Channel: "push",
|
||||
ScheduledFor: time.Now().UTC().Add(-time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("create reminder job: %v", err)
|
||||
}
|
||||
|
||||
service := NewService(config.Config{Environment: "development"}, repo)
|
||||
response, err := service.DispatchDue(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch due: %v", err)
|
||||
}
|
||||
if response.ProcessedCount != 1 || response.FailedCount != 1 {
|
||||
t.Fatalf("expected one failed job, got %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRequestContractShape(t *testing.T) {
|
||||
request := domain.DispatchReminderJobsRequest{Limit: 20}
|
||||
if request.Limit != 20 {
|
||||
t.Fatalf("expected limit 20, got %d", request.Limit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package tenancy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTenantAlreadyProvisioned = errors.New("tenant already provisioned for user")
|
||||
ErrInvalidOnboarding = errors.New("invalid onboarding request")
|
||||
ErrTenantSlugTaken = errors.New("tenant slug is already in use")
|
||||
)
|
||||
|
||||
var tenantSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: "",
|
||||
TenantName: "",
|
||||
Preset: "",
|
||||
Locale: "cs",
|
||||
Timezone: "Europe/Prague",
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
Name: principal.Name,
|
||||
Role: principal.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return domain.TenantBootstrap{}, err
|
||||
}
|
||||
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
Name: principal.Name,
|
||||
Role: membership.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Onboard(ctx context.Context, principal domain.Principal, request domain.OnboardTenantRequest) (domain.TenantBootstrap, error) {
|
||||
if _, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject); err == nil {
|
||||
return domain.TenantBootstrap{}, ErrTenantAlreadyProvisioned
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.TenantBootstrap{}, err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(request.Name)
|
||||
slug := strings.TrimSpace(request.Slug)
|
||||
preset := strings.TrimSpace(request.Preset)
|
||||
locale := strings.TrimSpace(request.Locale)
|
||||
timezone := strings.TrimSpace(request.Timezone)
|
||||
|
||||
switch {
|
||||
case len(name) < 2 || len(name) > 80:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case len(slug) < 3 || len(slug) > 48 || !tenantSlugPattern.MatchString(slug):
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case preset != "salon" && preset != "clinic" && preset != "massage" && preset != "repair" && preset != "studio":
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case locale != "cs" && locale != "en":
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case timezone == "":
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
}
|
||||
if _, err := time.LoadLocation(timezone); err != nil {
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
}
|
||||
|
||||
membership, err := s.repo.CreateTenantForUser(ctx, db.CreateTenantForUserParams{
|
||||
Subject: principal.Subject,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Preset: preset,
|
||||
Locale: locale,
|
||||
Timezone: timezone,
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return domain.TenantBootstrap{}, ErrTenantSlugTaken
|
||||
}
|
||||
return domain.TenantBootstrap{}, err
|
||||
}
|
||||
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
Name: principal.Name,
|
||||
Role: membership.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package tenancy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestBootstrapResolvesMembershipAfterIdentitySync(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
if err := repo.EnsureUserIdentity(context.Background(), "neon-user-123", "owner@bookra.dev", "Neon Owner"); err != nil {
|
||||
t.Fatalf("ensure user identity: %v", err)
|
||||
}
|
||||
|
||||
bootstrap, err := service.Bootstrap(context.Background(), domain.Principal{
|
||||
Subject: "neon-user-123",
|
||||
Email: "owner@bookra.dev",
|
||||
Name: "Neon Owner",
|
||||
Role: "authenticated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
if bootstrap.TenantID == "" {
|
||||
t.Fatal("expected tenant id")
|
||||
}
|
||||
if bootstrap.CurrentUser.Role != "owner" {
|
||||
t.Fatalf("expected owner role, got %s", bootstrap.CurrentUser.Role)
|
||||
}
|
||||
if bootstrap.CurrentUser.Name != "Neon Owner" {
|
||||
t.Fatalf("expected synced name, got %s", bootstrap.CurrentUser.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapReturnsShellWhenMembershipMissing(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
bootstrap, err := service.Bootstrap(context.Background(), domain.Principal{
|
||||
Subject: "unassigned-user",
|
||||
Email: "new@bookra.dev",
|
||||
Name: "New User",
|
||||
Role: "authenticated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap without membership: %v", err)
|
||||
}
|
||||
|
||||
if bootstrap.TenantID != "" {
|
||||
t.Fatalf("expected empty tenant id, got %s", bootstrap.TenantID)
|
||||
}
|
||||
if bootstrap.CurrentUser.Subject != "unassigned-user" {
|
||||
t.Fatalf("expected subject passthrough, got %s", bootstrap.CurrentUser.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardCreatesTenantForAuthenticatedUser(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
bootstrap, err := service.Onboard(context.Background(), domain.Principal{
|
||||
Subject: "fresh-user",
|
||||
Email: "fresh@bookra.dev",
|
||||
Name: "Fresh User",
|
||||
Role: "authenticated",
|
||||
}, domain.OnboardTenantRequest{
|
||||
Name: "Fresh Studio",
|
||||
Slug: "fresh-studio",
|
||||
Preset: "studio",
|
||||
Locale: "cs",
|
||||
Timezone: "Europe/Prague",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("onboard: %v", err)
|
||||
}
|
||||
if bootstrap.TenantName != "Fresh Studio" {
|
||||
t.Fatalf("expected tenant name, got %s", bootstrap.TenantName)
|
||||
}
|
||||
if bootstrap.CurrentUser.Role != "owner" {
|
||||
t.Fatalf("expected owner role, got %s", bootstrap.CurrentUser.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardRejectsInvalidSlug(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
_, err := service.Onboard(context.Background(), domain.Principal{
|
||||
Subject: "fresh-user",
|
||||
Email: "fresh@bookra.dev",
|
||||
Name: "Fresh User",
|
||||
Role: "authenticated",
|
||||
}, domain.OnboardTenantRequest{
|
||||
Name: "Fresh Studio",
|
||||
Slug: "bad slug",
|
||||
Preset: "studio",
|
||||
Locale: "cs",
|
||||
Timezone: "Europe/Prague",
|
||||
})
|
||||
if err != ErrInvalidOnboarding {
|
||||
t.Fatalf("expected invalid onboarding, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user