Skip to content

Commit e16b2c6

Browse files
AchoArnoldCopilot
andcommitted
feat(api): return accurate contacts total for server pagination
Extend the contact repository/service/handler so GET /v1/contacts returns a top-level total count for the same user/query filter, independent of skip/limit, enabling true server-side pagination. - add ContactRepository.Count reusing a shared scopedContactQuery helper so Index and Count filters can never drift; Count ignores limit/offset - add ContactService.Count and handler responseOKWithTotal; Index returns total - add Total to responses.ContactsResponse and regenerate Swagger - sanitize parsed CSV rows before validation so CSV and JSON accept the same phone/email formats; drop the now-redundant re-sanitize on upload - assert Scan error behaviour instead of the unexported stacktrace type - tests for count filter parity, total propagation, and CSV sanitization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 743da66 commit e16b2c6

16 files changed

Lines changed: 269 additions & 24 deletions

‎api/docs/docs.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ const docTemplate = `{
249249
"ApiKeyAuth": []
250250
}
251251
],
252-
"description": "Returns the paginated list of contacts for the authenticated user.",
252+
"description": "Returns the paginated list of contacts for the authenticated user. The top-level \"total\" field is the number of contacts matching the query filter, independent of skip/limit, so clients can drive server-side pagination.",
253253
"consumes": [
254254
"application/json"
255255
],
@@ -5257,7 +5257,8 @@ const docTemplate = `{
52575257
"required": [
52585258
"data",
52595259
"message",
5260-
"status"
5260+
"status",
5261+
"total"
52615262
],
52625263
"properties": {
52635264
"data": {
@@ -5273,6 +5274,11 @@ const docTemplate = `{
52735274
"status": {
52745275
"type": "string",
52755276
"example": "success"
5277+
},
5278+
"total": {
5279+
"description": "Total is the number of contacts matching the request filter for the\nuser, independent of the pagination skip/limit applied to Data.",
5280+
"type": "integer",
5281+
"example": 57
52765282
}
52775283
}
52785284
},

‎api/docs/swagger.json‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@
246246
"ApiKeyAuth": []
247247
}
248248
],
249-
"description": "Returns the paginated list of contacts for the authenticated user.",
249+
"description": "Returns the paginated list of contacts for the authenticated user. The top-level \"total\" field is the number of contacts matching the query filter, independent of skip/limit, so clients can drive server-side pagination.",
250250
"consumes": [
251251
"application/json"
252252
],
@@ -5254,7 +5254,8 @@
52545254
"required": [
52555255
"data",
52565256
"message",
5257-
"status"
5257+
"status",
5258+
"total"
52585259
],
52595260
"properties": {
52605261
"data": {
@@ -5270,6 +5271,11 @@
52705271
"status": {
52715272
"type": "string",
52725273
"example": "success"
5274+
},
5275+
"total": {
5276+
"description": "Total is the number of contacts matching the request filter for the\nuser, independent of the pagination skip/limit applied to Data.",
5277+
"type": "integer",
5278+
"example": 57
52735279
}
52745280
}
52755281
},

‎api/docs/swagger.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,10 +1235,17 @@ definitions:
12351235
status:
12361236
example: success
12371237
type: string
1238+
total:
1239+
description: |-
1240+
Total is the number of contacts matching the request filter for the
1241+
user, independent of the pagination skip/limit applied to Data.
1242+
example: 57
1243+
type: integer
12381244
required:
12391245
- data
12401246
- message
12411247
- status
1248+
- total
12421249
type: object
12431250
responses.DiscordResponse:
12441251
properties:
@@ -1883,6 +1890,8 @@ paths:
18831890
consumes:
18841891
- application/json
18851892
description: Returns the paginated list of contacts for the authenticated user.
1893+
The top-level "total" field is the number of contacts matching the query filter,
1894+
independent of skip/limit, so clients can drive server-side pagination.
18861895
parameters:
18871896
- description: number of contacts to skip
18881897
in: query

‎api/pkg/entities/contact_test.go‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package entities
22

33
import (
4-
"reflect"
54
"testing"
65

76
"github.com/stretchr/testify/assert"
@@ -47,6 +46,5 @@ func TestContactProperties_ScanUnsupportedType(t *testing.T) {
4746
err := scanned.Scan(123)
4847

4948
assert.Error(t, err)
50-
assert.Equal(t, "*stacktrace.stacktrace", reflect.TypeOf(err).String())
5149
assert.Contains(t, err.Error(), "unsupported type [int] for ContactProperties")
5250
}

‎api/pkg/handlers/contact_handler.go‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func (h *ContactHandler) RegisterRoutes(router fiber.Router, middlewares ...fibe
4949

5050
// Index lists contacts for the authenticated user.
5151
// @Summary List contacts
52-
// @Description Returns the paginated list of contacts for the authenticated user.
52+
// @Description Returns the paginated list of contacts for the authenticated user. The top-level "total" field is the number of contacts matching the query filter, independent of skip/limit, so clients can drive server-side pagination.
5353
// @Security ApiKeyAuth
5454
// @Tags Contacts
5555
// @Accept json
@@ -80,13 +80,20 @@ func (h *ContactHandler) Index(c fiber.Ctx) error {
8080
}
8181

8282
userID := h.userIDFomContext(c)
83-
contacts, err := h.service.Index(ctx, userID, sanitized.ToIndexParams())
83+
params := sanitized.ToIndexParams()
84+
contacts, err := h.service.Index(ctx, userID, params)
8485
if err != nil {
8586
ctxLogger.Error(stacktrace.Propagatef(err, "cannot list contacts for user [%s]", userID))
8687
return h.responseInternalServerError(c)
8788
}
8889

89-
return h.responseOK(c, fmt.Sprintf("fetched %d %s", len(*contacts), h.pluralize("contact", len(*contacts))), contacts)
90+
total, err := h.service.Count(ctx, userID, params)
91+
if err != nil {
92+
ctxLogger.Error(stacktrace.Propagatef(err, "cannot count contacts for user [%s]", userID))
93+
return h.responseInternalServerError(c)
94+
}
95+
96+
return h.responseOKWithTotal(c, fmt.Sprintf("fetched %d %s", len(*contacts), h.pluralize("contact", len(*contacts))), contacts, total)
9097
}
9198

9299
// Store creates one or many contacts.
@@ -160,7 +167,9 @@ func (h *ContactHandler) Upload(c fiber.Ctx) error {
160167
return h.responseUnprocessableEntity(c, errors, "validation errors while importing contacts")
161168
}
162169

163-
request := (requests.ContactStoreRequest{Contacts: items}).Sanitize()
170+
// items are already sanitized by ValidateUpload (SanitizeContactItem), so
171+
// build the persistable records directly without re-sanitizing.
172+
request := requests.ContactStoreRequest{Contacts: items}
164173
contacts := request.ToContacts(userID)
165174
if err = h.service.CreateMany(ctx, userID, contacts); err != nil {
166175
ctxLogger.Error(stacktrace.Propagatef(err, "cannot import [%d] contacts for user [%s]", len(contacts), userID))

‎api/pkg/handlers/contact_handler_test.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,15 @@ type contactHandlerFakeRepo struct {
4141
updated []*entities.Contact
4242
deleted []deletedContact
4343
indexParams []repositories.IndexParams
44+
countParams []repositories.IndexParams
4445
loadCalls []loadedContact
4546

4647
loadResult *entities.Contact
4748
loadErr error
4849
indexResult []entities.Contact
4950
indexErr error
51+
countResult int64
52+
countErr error
5053
storeErr error
5154
updateErr error
5255
deleteErr error
@@ -109,6 +112,16 @@ func (r *contactHandlerFakeRepo) Index(_ context.Context, _ entities.UserID, par
109112
return &out, nil
110113
}
111114

115+
func (r *contactHandlerFakeRepo) Count(_ context.Context, _ entities.UserID, params repositories.IndexParams) (int64, error) {
116+
r.mu.Lock()
117+
defer r.mu.Unlock()
118+
r.countParams = append(r.countParams, params)
119+
if r.countErr != nil {
120+
return 0, r.countErr
121+
}
122+
return r.countResult, nil
123+
}
124+
112125
func (r *contactHandlerFakeRepo) FetchAll(context.Context, entities.UserID) (*[]entities.Contact, error) {
113126
out := []entities.Contact{}
114127
return &out, nil
@@ -157,6 +170,7 @@ type contactHandlerPayload struct {
157170
Status string `json:"status"`
158171
Message string `json:"message"`
159172
Data json.RawMessage `json:"data"`
173+
Total int64 `json:"total"`
160174
}
161175

162176
func decodeContactHandlerPayload(t *testing.T, resp *http.Response) contactHandlerPayload {
@@ -467,6 +481,54 @@ func TestContactHandler_Index_ConvertsQueryAndScopesToUser(t *testing.T) {
467481
assert.Contains(t, payload.Message, "1")
468482
}
469483

484+
func TestContactHandler_Index_ReturnsServerTotalIndependentOfPageLength(t *testing.T) {
485+
repo := &contactHandlerFakeRepo{
486+
indexResult: []entities.Contact{
487+
{ID: uuid.New(), UserID: contactHandlerTestUserID, Name: "Alice", PhoneNumbers: pq.StringArray{"+18005550199"}},
488+
{ID: uuid.New(), UserID: contactHandlerTestUserID, Name: "Bob", PhoneNumbers: pq.StringArray{"+18005550100"}},
489+
},
490+
countResult: 57,
491+
}
492+
app := newContactHandlerTestApp(repo)
493+
494+
values := url.Values{}
495+
values.Set("skip", "5")
496+
values.Set("limit", "25")
497+
values.Set("query", "ali")
498+
req := httptest.NewRequest(http.MethodGet, "/v1/contacts?"+values.Encode(), nil)
499+
500+
resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})
501+
require.NoError(t, err)
502+
require.Equal(t, http.StatusOK, resp.StatusCode)
503+
504+
payload := decodeContactHandlerPayload(t, resp)
505+
assert.Equal(t, "success", payload.Status)
506+
// total must be the server count, not the length of the returned page.
507+
assert.Equal(t, int64(57), payload.Total)
508+
509+
var data []entities.Contact
510+
require.NoError(t, json.Unmarshal(payload.Data, &data))
511+
assert.Len(t, data, 2)
512+
513+
// Count must run with the exact same filter/pagination params as Index.
514+
require.Len(t, repo.indexParams, 1)
515+
require.Len(t, repo.countParams, 1)
516+
assert.Equal(t, repo.indexParams[0], repo.countParams[0])
517+
assert.Equal(t, 5, repo.countParams[0].Skip)
518+
assert.Equal(t, 25, repo.countParams[0].Limit)
519+
assert.Equal(t, "ali", repo.countParams[0].Query)
520+
}
521+
522+
func TestContactHandler_Index_CountErrorReturnsInternalServerError(t *testing.T) {
523+
repo := &contactHandlerFakeRepo{countErr: assert.AnError}
524+
app := newContactHandlerTestApp(repo)
525+
526+
req := httptest.NewRequest(http.MethodGet, "/v1/contacts", nil)
527+
resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})
528+
require.NoError(t, err)
529+
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
530+
}
531+
470532
func TestContactHandler_Index_DefaultsAppliedWhenParamsMissing(t *testing.T) {
471533
repo := &contactHandlerFakeRepo{}
472534
app := newContactHandlerTestApp(repo)

‎api/pkg/handlers/handler.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ func (h *handler) responseOK(c fiber.Ctx, message string, data interface{}) erro
9797
})
9898
}
9999

100+
func (h *handler) responseOKWithTotal(c fiber.Ctx, message string, data interface{}, total int64) error {
101+
return c.Status(fiber.StatusOK).JSON(fiber.Map{
102+
"status": "success",
103+
"message": message,
104+
"data": data,
105+
"total": total,
106+
})
107+
}
108+
100109
func (h *handler) responseCreated(c fiber.Ctx, message string, data interface{}) error {
101110
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
102111
"status": "success",

‎api/pkg/repositories/contact_repository.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ type ContactRepository interface {
2121
// Index contacts for a user with optional search.
2222
Index(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.Contact, error)
2323

24+
// Count returns the number of contacts for a user matching the same
25+
// name/emails/phone_numbers filter as Index, ignoring pagination.
26+
Count(ctx context.Context, userID entities.UserID, params IndexParams) (int64, error)
27+
2428
// FetchAll returns every contact for a user ordered by updated_at ascending.
2529
FetchAll(ctx context.Context, userID entities.UserID) (*[]entities.Contact, error)
2630

‎api/pkg/repositories/gorm_contact_repository.go‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,28 +78,51 @@ func (repository *gormContactRepository) Load(ctx context.Context, userID entiti
7878
return contact, nil
7979
}
8080

81-
func (repository *gormContactRepository) Index(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.Contact, error) {
82-
ctx, span := repository.tracer.Start(ctx)
83-
defer span.End()
84-
85-
query := repository.db.WithContext(ctx).Where("user_id = ?", userID)
86-
if len(params.Query) > 0 {
87-
queryPattern := "%" + params.Query + "%"
88-
query = query.Where(
81+
// scopedContactQuery builds the shared query that scopes contacts to a user and
82+
// applies the optional name/emails/phone_numbers search filter. Index and Count
83+
// both build on it so their filters can never drift apart. It sets the model so
84+
// callers can chain Find or Count without repeating the table.
85+
func (repository *gormContactRepository) scopedContactQuery(ctx context.Context, userID entities.UserID, query string) *gorm.DB {
86+
scoped := repository.db.WithContext(ctx).Model(&entities.Contact{}).Where("user_id = ?", userID)
87+
if len(query) > 0 {
88+
queryPattern := "%" + query + "%"
89+
scoped = scoped.Where(
8990
repository.db.WithContext(ctx).Where("name ILIKE ?", queryPattern).
9091
Or("array_to_string(emails, ',') ILIKE ?", queryPattern).
9192
Or("array_to_string(phone_numbers, ',') ILIKE ?", queryPattern),
9293
)
9394
}
95+
return scoped
96+
}
97+
98+
func (repository *gormContactRepository) Index(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.Contact, error) {
99+
ctx, span := repository.tracer.Start(ctx)
100+
defer span.End()
94101

95102
contacts := new([]entities.Contact)
96-
if err := query.Order("updated_at DESC").Limit(params.Limit).Offset(params.Skip).Find(contacts).Error; err != nil {
103+
if err := repository.scopedContactQuery(ctx, userID, params.Query).
104+
Order("updated_at DESC").
105+
Limit(params.Limit).
106+
Offset(params.Skip).
107+
Find(contacts).Error; err != nil {
97108
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot index contacts for user [%s] with params [%+#v]", userID, params))
98109
}
99110

100111
return contacts, nil
101112
}
102113

114+
func (repository *gormContactRepository) Count(ctx context.Context, userID entities.UserID, params IndexParams) (int64, error) {
115+
ctx, span := repository.tracer.Start(ctx)
116+
defer span.End()
117+
118+
var count int64
119+
if err := repository.scopedContactQuery(ctx, userID, params.Query).Count(&count).Error; err != nil {
120+
return 0, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot count contacts for user [%s] with query [%s]", userID, params.Query))
121+
}
122+
123+
return count, nil
124+
}
125+
103126
func (repository *gormContactRepository) FetchAll(ctx context.Context, userID entities.UserID) (*[]entities.Contact, error) {
104127
ctx, span := repository.tracer.Start(ctx)
105128
defer span.End()

‎api/pkg/repositories/gorm_contact_repository_test.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,45 @@ func TestGormContactRepository_Load_ScopesByUserAndContactID(t *testing.T) {
218218
assert.Equal(t, 1, statement.args[2])
219219
}
220220

221+
func TestGormContactRepository_Count_ReusesIndexFilterWithoutPagination(t *testing.T) {
222+
repository, recorder := newContactTestRepo(t)
223+
224+
total, err := repository.Count(context.Background(), entities.UserID("user-1"), IndexParams{
225+
Query: "alice",
226+
// Limit/Skip must be ignored by Count.
227+
Limit: 20,
228+
Skip: 40,
229+
})
230+
231+
require.NoError(t, err)
232+
assert.Equal(t, int64(0), total)
233+
234+
statement := lastContactStatement(t, recorder)
235+
assert.True(t, strings.HasPrefix(statement.query, `SELECT count(*) FROM "contacts"`))
236+
// Count must apply the exact same user + name/emails/phone_numbers filter as Index.
237+
assert.Contains(t, statement.query, `WHERE user_id = $1 AND (name ILIKE $2 OR array_to_string(emails, ',') ILIKE $3 OR array_to_string(phone_numbers, ',') ILIKE $4)`)
238+
// Count must ignore pagination.
239+
assert.NotContains(t, statement.query, "LIMIT")
240+
assert.NotContains(t, statement.query, "OFFSET")
241+
require.Len(t, statement.args, 4)
242+
assert.Equal(t, entities.UserID("user-1"), statement.args[0])
243+
assert.Equal(t, "%alice%", statement.args[1])
244+
assert.Equal(t, "%alice%", statement.args[2])
245+
assert.Equal(t, "%alice%", statement.args[3])
246+
}
247+
248+
func TestGormContactRepository_Count_ScopesByUserWithoutQuery(t *testing.T) {
249+
repository, recorder := newContactTestRepo(t)
250+
251+
_, err := repository.Count(context.Background(), entities.UserID("user-1"), IndexParams{})
252+
253+
require.NoError(t, err)
254+
statement := lastContactStatement(t, recorder)
255+
assert.Equal(t, `SELECT count(*) FROM "contacts" WHERE user_id = $1`, statement.query)
256+
require.Len(t, statement.args, 1)
257+
assert.Equal(t, entities.UserID("user-1"), statement.args[0])
258+
}
259+
221260
func TestGormContactRepository_Index_FiltersByUserAndQueryAcrossContactFields(t *testing.T) {
222261
repository, recorder := newContactTestRepo(t)
223262

0 commit comments

Comments
 (0)