Skip to content

Commit 21d4122

Browse files
AchoArnoldCopilot
andcommitted
feat(api): limit contacts by plan
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 40d0ce5 commit 21d4122

9 files changed

Lines changed: 283 additions & 16 deletions

File tree

api/docs/docs.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,12 @@ const docTemplate = `{
363363
"$ref": "#/definitions/responses.Unauthorized"
364364
}
365365
},
366+
"402": {
367+
"description": "Payment Required",
368+
"schema": {
369+
"$ref": "#/definitions/responses.PaymentRequired"
370+
}
371+
},
366372
"422": {
367373
"description": "Unprocessable Entity",
368374
"schema": {
@@ -424,6 +430,12 @@ const docTemplate = `{
424430
"$ref": "#/definitions/responses.Unauthorized"
425431
}
426432
},
433+
"402": {
434+
"description": "Payment Required",
435+
"schema": {
436+
"$ref": "#/definitions/responses.PaymentRequired"
437+
}
438+
},
427439
"422": {
428440
"description": "Unprocessable Entity",
429441
"schema": {

api/docs/swagger.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,12 @@
360360
"$ref": "#/definitions/responses.Unauthorized"
361361
}
362362
},
363+
"402": {
364+
"description": "Payment Required",
365+
"schema": {
366+
"$ref": "#/definitions/responses.PaymentRequired"
367+
}
368+
},
363369
"422": {
364370
"description": "Unprocessable Entity",
365371
"schema": {
@@ -421,6 +427,12 @@
421427
"$ref": "#/definitions/responses.Unauthorized"
422428
}
423429
},
430+
"402": {
431+
"description": "Payment Required",
432+
"schema": {
433+
"$ref": "#/definitions/responses.PaymentRequired"
434+
}
435+
},
424436
"422": {
425437
"description": "Unprocessable Entity",
426438
"schema": {

api/docs/swagger.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,6 +1963,10 @@ paths:
19631963
description: Unauthorized
19641964
schema:
19651965
$ref: '#/definitions/responses.Unauthorized'
1966+
"402":
1967+
description: Payment Required
1968+
schema:
1969+
$ref: '#/definitions/responses.PaymentRequired'
19661970
"422":
19671971
description: Unprocessable Entity
19681972
schema:
@@ -2094,6 +2098,10 @@ paths:
20942098
description: Unauthorized
20952099
schema:
20962100
$ref: '#/definitions/responses.Unauthorized'
2101+
"402":
2102+
description: Payment Required
2103+
schema:
2104+
$ref: '#/definitions/responses.PaymentRequired'
20972105
"422":
20982106
description: Unprocessable Entity
20992107
schema:

api/pkg/di/container.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,7 @@ func (container *Container) ContactHandler() (h *handlers.ContactHandler) {
720720
container.Tracer(),
721721
container.ContactHandlerValidator(),
722722
container.ContactService(),
723+
container.EntitlementService(),
723724
)
724725
}
725726

api/pkg/entities/contact.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import (
1010
"github.com/lib/pq"
1111
)
1212

13+
// EntityNameContact is the entitlement entity name for contacts.
14+
const EntityNameContact = "Contact"
15+
1316
// ContactProperties is a free-form key/value map persisted as a jsonb column.
1417
type ContactProperties map[string]string
1518

api/pkg/handlers/contact_handler.go

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package handlers
22

33
import (
4+
"context"
45
"fmt"
56

7+
"github.com/NdoleStudio/httpsms/pkg/entities"
68
"github.com/NdoleStudio/httpsms/pkg/repositories"
79
"github.com/NdoleStudio/httpsms/pkg/requests"
810
"github.com/NdoleStudio/httpsms/pkg/services"
@@ -17,10 +19,11 @@ import (
1719
// ContactHandler handles contact http requests.
1820
type ContactHandler struct {
1921
handler
20-
logger telemetry.Logger
21-
tracer telemetry.Tracer
22-
validator *validators.ContactHandlerValidator
23-
service *services.ContactService
22+
logger telemetry.Logger
23+
tracer telemetry.Tracer
24+
validator *validators.ContactHandlerValidator
25+
service *services.ContactService
26+
entitlementService *services.EntitlementService
2427
}
2528

2629
// NewContactHandler creates a new ContactHandler.
@@ -29,12 +32,14 @@ func NewContactHandler(
2932
tracer telemetry.Tracer,
3033
validator *validators.ContactHandlerValidator,
3134
service *services.ContactService,
35+
entitlementService *services.EntitlementService,
3236
) (h *ContactHandler) {
3337
return &ContactHandler{
34-
logger: logger.WithService(fmt.Sprintf("%T", h)),
35-
tracer: tracer,
36-
validator: validator,
37-
service: service,
38+
logger: logger.WithService(fmt.Sprintf("%T", h)),
39+
tracer: tracer,
40+
validator: validator,
41+
service: service,
42+
entitlementService: entitlementService,
3843
}
3944
}
4045

@@ -107,6 +112,7 @@ func (h *ContactHandler) Index(c fiber.Ctx) error {
107112
// @Success 201 {object} responses.ContactsResponse
108113
// @Failure 400 {object} responses.BadRequest
109114
// @Failure 401 {object} responses.Unauthorized
115+
// @Failure 402 {object} responses.PaymentRequired
110116
// @Failure 422 {object} responses.UnprocessableEntity
111117
// @Failure 500 {object} responses.InternalServerError
112118
// @Router /contacts [post]
@@ -128,6 +134,15 @@ func (h *ContactHandler) Store(c fiber.Ctx) error {
128134

129135
userID := h.userIDFomContext(c)
130136
contacts := sanitized.ToContacts(userID)
137+
result, err := h.checkCreateEntitlement(ctx, userID, len(contacts))
138+
if err != nil {
139+
ctxLogger.Error(stacktrace.Propagatef(err, "cannot check contact entitlement for user [%s]", userID))
140+
return h.responseInternalServerError(c)
141+
}
142+
if !result.Allowed {
143+
return h.responsePaymentRequired(c, result.Message)
144+
}
145+
131146
if err := h.service.CreateMany(ctx, userID, contacts); err != nil {
132147
ctxLogger.Error(stacktrace.Propagatef(err, "cannot create [%d] contacts for user [%s]", len(contacts), userID))
133148
return h.responseInternalServerError(c)
@@ -147,6 +162,7 @@ func (h *ContactHandler) Store(c fiber.Ctx) error {
147162
// @Success 201 {object} responses.ContactsResponse
148163
// @Failure 400 {object} responses.BadRequest
149164
// @Failure 401 {object} responses.Unauthorized
165+
// @Failure 402 {object} responses.PaymentRequired
150166
// @Failure 422 {object} responses.UnprocessableEntity
151167
// @Failure 500 {object} responses.InternalServerError
152168
// @Router /contacts/upload [post]
@@ -171,6 +187,15 @@ func (h *ContactHandler) Upload(c fiber.Ctx) error {
171187
// build the persistable records directly without re-sanitizing.
172188
request := requests.ContactStoreRequest{Contacts: items}
173189
contacts := request.ToContacts(userID)
190+
result, err := h.checkCreateEntitlement(ctx, userID, len(contacts))
191+
if err != nil {
192+
ctxLogger.Error(stacktrace.Propagatef(err, "cannot check contact entitlement for user [%s]", userID))
193+
return h.responseInternalServerError(c)
194+
}
195+
if !result.Allowed {
196+
return h.responsePaymentRequired(c, result.Message)
197+
}
198+
174199
if err = h.service.CreateMany(ctx, userID, contacts); err != nil {
175200
ctxLogger.Error(stacktrace.Propagatef(err, "cannot import [%d] contacts for user [%s]", len(contacts), userID))
176201
return h.responseInternalServerError(c)
@@ -281,3 +306,14 @@ func (h *ContactHandler) Delete(c fiber.Ctx) error {
281306

282307
return h.responseNoContent(c, "contact deleted successfully")
283308
}
309+
310+
func (h *ContactHandler) checkCreateEntitlement(
311+
ctx context.Context,
312+
userID entities.UserID,
313+
additionalCount int,
314+
) (*services.EntitlementCheckResult, error) {
315+
return h.entitlementService.CheckAdditional(ctx, userID, entities.EntityNameContact, additionalCount, func() (int, error) {
316+
count, err := h.service.Count(ctx, userID, repositories.IndexParams{})
317+
return int(count), err
318+
})
319+
}

api/pkg/handlers/contact_handler_test.go

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,35 @@ func (r *contactHandlerFakeRepo) snapshot() ([][]*entities.Contact, []*entities.
150150

151151
const contactHandlerTestUserID = entities.UserID("user-id")
152152

153+
type contactHandlerEntitlementUserRepo struct {
154+
repositories.UserRepository
155+
subscriptionName entities.SubscriptionName
156+
}
157+
158+
func (repository *contactHandlerEntitlementUserRepo) Load(_ context.Context, userID entities.UserID) (*entities.User, error) {
159+
return &entities.User{ID: userID, SubscriptionName: repository.subscriptionName}, nil
160+
}
161+
153162
func newContactHandlerTestApp(repo repositories.ContactRepository) *fiber.App {
163+
return newContactHandlerTestAppWithEntitlements(repo, false, entities.SubscriptionNameFree)
164+
}
165+
166+
func newContactHandlerTestAppWithEntitlements(
167+
repo repositories.ContactRepository,
168+
entitlementsEnabled bool,
169+
subscriptionName entities.SubscriptionName,
170+
) *fiber.App {
154171
logger := &messageThreadHandlerNoopLogger{}
155172
tracer := telemetry.NewOtelLogger("test", logger)
156173
appCache := cache.NewMemoryCache(tracer, ttlCache.New(time.Minute, time.Minute))
157174
service := services.NewContactService(logger, tracer, repo, appCache)
158-
handler := NewContactHandler(logger, tracer, validators.NewContactHandlerValidator(logger, tracer), service)
175+
entitlementService := services.NewEntitlementService(
176+
logger,
177+
tracer,
178+
entitlementsEnabled,
179+
&contactHandlerEntitlementUserRepo{subscriptionName: subscriptionName},
180+
)
181+
handler := NewContactHandler(logger, tracer, validators.NewContactHandlerValidator(logger, tracer), service, entitlementService)
159182

160183
app := fiber.New()
161184
app.Use(func(c fiber.Ctx) error {
@@ -222,6 +245,41 @@ func TestContactHandler_Store_CreatesManyContactsFromObjectShape(t *testing.T) {
222245
assert.Equal(t, "Bob", stored[0][1].Name)
223246
}
224247

248+
func TestContactHandler_Store_RejectsBatchExceedingContactLimit(t *testing.T) {
249+
repo := &contactHandlerFakeRepo{countResult: 199}
250+
app := newContactHandlerTestAppWithEntitlements(repo, true, entities.SubscriptionNameFree)
251+
252+
body := `{"contacts":[
253+
{"name":"Alice","phone_numbers":["+18005550199"]},
254+
{"name":"Bob","phone_numbers":["+18005550100"]}
255+
]}`
256+
req := httptest.NewRequest(http.MethodPost, "/v1/contacts", bytes.NewBufferString(body))
257+
req.Header.Set("Content-Type", "application/json")
258+
259+
resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})
260+
require.NoError(t, err)
261+
require.Equal(t, http.StatusPaymentRequired, resp.StatusCode)
262+
263+
stored, _, _, _ := repo.snapshot()
264+
assert.Empty(t, stored)
265+
}
266+
267+
func TestContactHandler_Store_DisabledEntitlementsDoNotLimitContacts(t *testing.T) {
268+
repo := &contactHandlerFakeRepo{countResult: 200}
269+
app := newContactHandlerTestAppWithEntitlements(repo, false, entities.SubscriptionNameFree)
270+
271+
body := `[{"name":"Alice","phone_numbers":["+18005550199"]}]`
272+
req := httptest.NewRequest(http.MethodPost, "/v1/contacts", bytes.NewBufferString(body))
273+
req.Header.Set("Content-Type", "application/json")
274+
275+
resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})
276+
require.NoError(t, err)
277+
require.Equal(t, http.StatusCreated, resp.StatusCode)
278+
279+
stored, _, _, _ := repo.snapshot()
280+
require.Len(t, stored, 1)
281+
}
282+
225283
func TestContactHandler_Store_ValidationError_ReturnsUnprocessableEntity(t *testing.T) {
226284
repo := &contactHandlerFakeRepo{}
227285
app := newContactHandlerTestApp(repo)
@@ -289,6 +347,23 @@ func TestContactHandler_Upload_CSVSuccess(t *testing.T) {
289347
}
290348
}
291349

350+
func TestContactHandler_Upload_RejectsBatchExceedingContactLimit(t *testing.T) {
351+
repo := &contactHandlerFakeRepo{countResult: 199}
352+
app := newContactHandlerTestAppWithEntitlements(repo, true, entities.SubscriptionNameFree)
353+
354+
csv := "Name,Emails,PhoneNumbers\nAlice,alice@example.com,+18005550199\nBob,,+18005550100\n"
355+
body, contentType := buildContactCSVUpload(t, "contacts.csv", "text/csv", csv)
356+
req := httptest.NewRequest(http.MethodPost, "/v1/contacts/upload", body)
357+
req.Header.Set("Content-Type", contentType)
358+
359+
resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})
360+
require.NoError(t, err)
361+
require.Equal(t, http.StatusPaymentRequired, resp.StatusCode)
362+
363+
stored, _, _, _ := repo.snapshot()
364+
assert.Empty(t, stored)
365+
}
366+
292367
func TestContactHandler_Upload_NonCSVFile_ReturnsUnprocessableEntity(t *testing.T) {
293368
repo := &contactHandlerFakeRepo{}
294369
app := newContactHandlerTestApp(repo)

api/pkg/services/entitlement_service.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ func (service *EntitlementService) Check(
6262
userID entities.UserID,
6363
entityName string,
6464
countFunc func() (int, error),
65+
) (*EntitlementCheckResult, error) {
66+
return service.CheckAdditional(ctx, userID, entityName, 1, countFunc)
67+
}
68+
69+
// CheckAdditional verifies if the user can create additionalCount instances of
70+
// the given entity without exceeding their subscription plan limit.
71+
func (service *EntitlementService) CheckAdditional(
72+
ctx context.Context,
73+
userID entities.UserID,
74+
entityName string,
75+
additionalCount int,
76+
countFunc func() (int, error),
6577
) (*EntitlementCheckResult, error) {
6678
ctx, span := service.tracer.Start(ctx)
6779
defer span.End()
@@ -70,8 +82,8 @@ func (service *EntitlementService) Check(
7082
return &EntitlementCheckResult{Allowed: true}, nil
7183
}
7284

73-
limits, exists := entityLimits[entityName]
74-
if !exists {
85+
limits, hasConfiguredLimits := entityLimits[entityName]
86+
if !hasConfiguredLimits && entityName != entities.EntityNameContact {
7587
return &EntitlementCheckResult{Allowed: true}, nil
7688
}
7789

@@ -83,9 +95,13 @@ func (service *EntitlementService) Check(
8395
)
8496
}
8597

86-
limit, hasLimit := limits[user.SubscriptionName]
87-
if !hasLimit || limit == 0 {
88-
return &EntitlementCheckResult{Allowed: true}, nil
98+
limit := int(user.SubscriptionName.Limit())
99+
if entityName != entities.EntityNameContact {
100+
var hasLimit bool
101+
limit, hasLimit = limits[user.SubscriptionName]
102+
if !hasLimit || limit == 0 {
103+
return &EntitlementCheckResult{Allowed: true}, nil
104+
}
89105
}
90106

91107
currentCount, err := countFunc()
@@ -96,11 +112,11 @@ func (service *EntitlementService) Check(
96112
)
97113
}
98114

99-
if currentCount >= limit {
115+
if currentCount+additionalCount > limit {
100116
return &EntitlementCheckResult{
101117
Allowed: false,
102118
Message: fmt.Sprintf(
103-
"Upgrade to a paid plan to create more than [%d] %s. Visit https://httpsms.com/pricing for details.",
119+
"Upgrade your plan to create more than [%d] %s. Visit https://httpsms.com/pricing for details.",
104120
limit,
105121
formatEntityName(entityName, true),
106122
),

0 commit comments

Comments
 (0)