Skip to content

Commit 16d7d46

Browse files
AchoArnoldCopilot
andauthored
feat: limit free users to 1 phone API key (#894)
Add entitlement check to the phone API key creation endpoint so free users can only create 1 phone API key. Paid users remain unlimited. Self-hosted setups are unaffected (controlled by ENTITLEMENT_ENABLED). Reuses the existing EntitlementService pattern from send schedules: - Add PhoneAPIKey to entityLimits map (free: 1) - Add CountByUser to PhoneAPIKeyRepository and service - Inject EntitlementService into PhoneAPIKeyHandler - Check entitlement before creating, return 402 if exceeded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 73395a4 commit 16d7d46

8 files changed

Lines changed: 57 additions & 22 deletions

File tree

api/go.mod

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ require (
4141
github.com/redis/go-redis/extra/redisotel/v9 v9.19.0
4242
github.com/redis/go-redis/v9 v9.19.0
4343
github.com/rs/zerolog v1.35.1
44-
github.com/schollz/progressbar/v3 v3.19.0
4544
github.com/stretchr/testify v1.11.1
4645
github.com/swaggo/swag v1.16.6
4746
github.com/thedevsaddam/govalidator v1.9.10
@@ -142,7 +141,6 @@ require (
142141
github.com/mattn/go-isatty v0.0.22 // indirect
143142
github.com/mattn/go-runewidth v0.0.23 // indirect
144143
github.com/mattn/go-sqlite3 v1.14.44 // indirect
145-
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
146144
github.com/mitchellh/copystructure v1.2.0 // indirect
147145
github.com/mitchellh/reflectwalk v1.0.2 // indirect
148146
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -155,7 +153,6 @@ require (
155153
github.com/redis/go-redis/extra/rediscmd/v9 v9.19.0 // indirect
156154
github.com/richardlehane/mscfb v1.0.6 // indirect
157155
github.com/richardlehane/msoleps v1.0.6 // indirect
158-
github.com/rivo/uniseg v0.4.7 // indirect
159156
github.com/segmentio/asm v1.2.1 // indirect
160157
github.com/shopspring/decimal v1.4.0 // indirect
161158
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
@@ -196,7 +193,6 @@ require (
196193
golang.org/x/net v0.53.0 // indirect
197194
golang.org/x/oauth2 v0.36.0 // indirect
198195
golang.org/x/sys v0.44.0 // indirect
199-
golang.org/x/term v0.43.0 // indirect
200196
golang.org/x/text v0.37.0 // indirect
201197
golang.org/x/time v0.15.0 // indirect
202198
golang.org/x/tools v0.44.0 // indirect

api/go.sum

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,6 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
7878
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
7979
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
8080
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
81-
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
82-
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
8381
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
8482
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
8583
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
@@ -247,8 +245,6 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry
247245
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
248246
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
249247
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
250-
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
251-
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
252248
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
253249
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
254250
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -295,14 +291,10 @@ github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq
295291
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
296292
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
297293
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
298-
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
299-
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
300294
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
301295
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
302296
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
303297
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
304-
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
305-
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
306298
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
307299
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
308300
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -485,8 +477,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
485477
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
486478
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
487479
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
488-
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
489-
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
490480
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
491481
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
492482
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=

api/pkg/di/container.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,6 +1264,7 @@ func (container *Container) PhoneAPIKeyHandler() (handler *handlers.PhoneAPIKeyH
12641264
container.Tracer(),
12651265
container.PhoneAPIKeyHandlerValidator(),
12661266
container.PhoneAPIKeyService(),
1267+
container.EntitlementService(),
12671268
)
12681269
}
12691270

api/pkg/handlers/phone_api_key_handler.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import (
1717
// PhoneAPIKeyHandler handles phone API key http requests
1818
type PhoneAPIKeyHandler struct {
1919
handler
20-
logger telemetry.Logger
21-
tracer telemetry.Tracer
22-
validator *validators.PhoneAPIKeyHandlerValidator
23-
service *services.PhoneAPIKeyService
20+
logger telemetry.Logger
21+
tracer telemetry.Tracer
22+
validator *validators.PhoneAPIKeyHandlerValidator
23+
service *services.PhoneAPIKeyService
24+
entitlementService *services.EntitlementService
2425
}
2526

2627
// NewPhoneAPIKeyHandler creates a new PhoneAPIKeyHandler
@@ -29,12 +30,14 @@ func NewPhoneAPIKeyHandler(
2930
tracer telemetry.Tracer,
3031
validator *validators.PhoneAPIKeyHandlerValidator,
3132
service *services.PhoneAPIKeyService,
33+
entitlementService *services.EntitlementService,
3234
) *PhoneAPIKeyHandler {
3335
return &PhoneAPIKeyHandler{
34-
logger: logger.WithService(fmt.Sprintf("%T", &PhoneAPIKeyHandler{})),
35-
tracer: tracer,
36-
validator: validator,
37-
service: service,
36+
logger: logger.WithService(fmt.Sprintf("%T", &PhoneAPIKeyHandler{})),
37+
tracer: tracer,
38+
validator: validator,
39+
service: service,
40+
entitlementService: entitlementService,
3841
}
3942
}
4043

@@ -99,13 +102,27 @@ func (h *PhoneAPIKeyHandler) index(c *fiber.Ctx) error {
99102
// @Success 200 {object} responses.PhoneAPIKeyResponse
100103
// @Failure 400 {object} responses.BadRequest
101104
// @Failure 401 {object} responses.Unauthorized
105+
// @Failure 402 {object} responses.PaymentRequired
102106
// @Failure 422 {object} responses.UnprocessableEntity
103107
// @Failure 500 {object} responses.InternalServerError
104108
// @Router /phone-api-keys [post]
105109
func (h *PhoneAPIKeyHandler) store(c *fiber.Ctx) error {
106110
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
107111
defer span.End()
108112

113+
userID := h.userIDFomContext(c)
114+
115+
result, err := h.entitlementService.Check(ctx, userID, "PhoneAPIKey", func() (int, error) {
116+
return h.service.CountByUser(ctx, userID)
117+
})
118+
if err != nil {
119+
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot check entitlement for phone API keys for user [%s]", userID)))
120+
return h.responseInternalServerError(c)
121+
}
122+
if !result.Allowed {
123+
return h.responsePaymentRequired(c, result.Message)
124+
}
125+
109126
var request requests.PhoneAPIKeyStoreRequest
110127
if err := c.BodyParser(&request); err != nil {
111128
msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request)

api/pkg/repositories/gorm_phone_api_key_repository.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ WHERE user_id = ? AND array_position(phone_ids, ?) IS NOT NULL;
6161
return nil
6262
}
6363

64+
// CountByUser returns the number of phone API keys owned by a user.
65+
func (repository *gormPhoneAPIKeyRepository) CountByUser(ctx context.Context, userID entities.UserID) (int, error) {
66+
ctx, span := repository.tracer.Start(ctx)
67+
defer span.End()
68+
69+
var count int64
70+
err := repository.db.WithContext(ctx).
71+
Model(&entities.PhoneAPIKey{}).
72+
Where("user_id = ?", userID).
73+
Count(&count).Error
74+
if err != nil {
75+
return 0, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot count phone API keys for user [%s]", userID))
76+
}
77+
78+
return int(count), nil
79+
}
80+
6481
// Load an entities.PhoneAPIKey based on the entities.UserID
6582
func (repository *gormPhoneAPIKeyRepository) Load(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) (*entities.PhoneAPIKey, error) {
6683
ctx, span := repository.tracer.Start(ctx)

api/pkg/repositories/phone_api_key_repository.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ type PhoneAPIKeyRepository interface {
3131
// RemovePhone removes an entities.Phone to an entities.PhoneAPIKey
3232
RemovePhone(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey, phone *entities.Phone) error
3333

34+
// CountByUser returns the number of phone API keys owned by a user
35+
CountByUser(ctx context.Context, userID entities.UserID) (int, error)
36+
3437
// DeleteAllForUser deletes all entities.PhoneAPIKey for a user
3538
DeleteAllForUser(ctx context.Context, userID entities.UserID) error
3639

api/pkg/services/entitlement_service.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ var entityLimits = map[string]map[entities.SubscriptionName]int{
1919
"MessageSendSchedule": {
2020
entities.SubscriptionNameFree: 1,
2121
},
22+
"PhoneAPIKey": {
23+
entities.SubscriptionNameFree: 1,
24+
},
2225
}
2326

2427
// EntitlementCheckResult holds the outcome of an entitlement check.

api/pkg/services/phone_api_key_service.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ func NewPhoneAPIKeyService(
4040
}
4141
}
4242

43+
// CountByUser returns the number of phone API keys owned by a user.
44+
func (service *PhoneAPIKeyService) CountByUser(ctx context.Context, userID entities.UserID) (int, error) {
45+
ctx, span := service.tracer.Start(ctx)
46+
defer span.End()
47+
48+
return service.repository.CountByUser(ctx, userID)
49+
}
50+
4351
// Index fetches the entities.Webhook for an entities.UserID
4452
func (service *PhoneAPIKeyService) Index(ctx context.Context, userID entities.UserID, params repositories.IndexParams) ([]*entities.PhoneAPIKey, error) {
4553
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)

0 commit comments

Comments
 (0)