From 062b1f6151264a5df84235597441b4f317e3a08b Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Mon, 18 May 2026 00:37:11 -0700 Subject: [PATCH] Add country, region, and subscription_status filters to GET /users. Filtering matches user profile country/region (case-insensitive trim) and derived subscription state in SQL so pagination totals stay correct. Co-authored-by: Cursor --- db/query/user.sql | 40 +++++++++++++++++ docs/docs.go | 20 ++++++++- docs/swagger.json | 20 ++++++++- docs/swagger.yaml | 15 ++++++- gen/db/user.sql.go | 64 ++++++++++++++++++++++++---- internal/domain/user.go | 7 ++- internal/ports/user.go | 3 ++ internal/repository/user.go | 31 +++++++++++--- internal/services/user/direct.go | 18 ++++++++ internal/web_server/handlers/user.go | 37 ++++++++++++---- 10 files changed, 228 insertions(+), 27 deletions(-) diff --git a/db/query/user.sql b/db/query/user.sql index 906aa8e..3b46a88 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -196,6 +196,46 @@ WHERE )) AND (sqlc.narg('created_after')::TIMESTAMPTZ IS NULL OR created_at >= sqlc.narg('created_after')::TIMESTAMPTZ) AND (sqlc.narg('created_before')::TIMESTAMPTZ IS NULL OR created_at <= sqlc.narg('created_before')::TIMESTAMPTZ) + AND (sqlc.narg('country')::TEXT IS NULL OR LOWER(TRIM(COALESCE(country, ''))) = LOWER(TRIM(sqlc.narg('country')::TEXT))) + AND (sqlc.narg('region')::TEXT IS NULL OR LOWER(TRIM(COALESCE(region, ''))) = LOWER(TRIM(sqlc.narg('region')::TEXT))) + AND ( + sqlc.narg('subscription_status')::TEXT IS NULL + OR ( + sqlc.narg('subscription_status')::TEXT = 'ACTIVE' + AND EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id + AND us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP + ) + ) + OR ( + sqlc.narg('subscription_status')::TEXT = 'PENDING' + AND NOT EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id + AND us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP + ) + AND EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id AND us.status = 'PENDING' + ) + ) + OR ( + sqlc.narg('subscription_status')::TEXT = 'Unsubscribed' + AND NOT EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id + AND us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP + ) + AND NOT EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id AND us.status = 'PENDING' + ) + ) + ) ORDER BY created_at DESC LIMIT sqlc.narg('limit')::INT OFFSET sqlc.narg('offset')::INT; diff --git a/docs/docs.go b/docs/docs.go index e0f7cd0..f120869 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -8486,9 +8486,27 @@ const docTemplate = `{ }, { "type": "string", - "description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", + "description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", "name": "status", "in": "query" + }, + { + "type": "string", + "description": "Country filter (case-insensitive match on stored value)", + "name": "country", + "in": "query" + }, + { + "type": "string", + "description": "Region filter (case-insensitive match on stored value)", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "Derived subscription filter: ACTIVE, PENDING, or Unsubscribed (matches response subscription_status semantics)", + "name": "subscription_status", + "in": "query" } ], "responses": { diff --git a/docs/swagger.json b/docs/swagger.json index 7c0943c..2e888f6 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -8478,9 +8478,27 @@ }, { "type": "string", - "description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", + "description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", "name": "status", "in": "query" + }, + { + "type": "string", + "description": "Country filter (case-insensitive match on stored value)", + "name": "country", + "in": "query" + }, + { + "type": "string", + "description": "Region filter (case-insensitive match on stored value)", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "Derived subscription filter: ACTIVE, PENDING, or Unsubscribed (matches response subscription_status semantics)", + "name": "subscription_status", + "in": "query" } ], "responses": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5e718b2..be8ac0a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -8017,10 +8017,23 @@ paths: in: query name: created_after type: string - - description: Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED) + - description: User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED) in: query name: status type: string + - description: Country filter (case-insensitive match on stored value) + in: query + name: country + type: string + - description: Region filter (case-insensitive match on stored value) + in: query + name: region + type: string + - description: 'Derived subscription filter: ACTIVE, PENDING, or Unsubscribed + (matches response subscription_status semantics)' + in: query + name: subscription_status + type: string produces: - application/json responses: diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 68696d5..487c3b6 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -386,19 +386,62 @@ WHERE )) AND ($4::TIMESTAMPTZ IS NULL OR created_at >= $4::TIMESTAMPTZ) AND ($5::TIMESTAMPTZ IS NULL OR created_at <= $5::TIMESTAMPTZ) + AND ($6::TEXT IS NULL OR LOWER(TRIM(COALESCE(country, ''))) = LOWER(TRIM($6::TEXT))) + AND ($7::TEXT IS NULL OR LOWER(TRIM(COALESCE(region, ''))) = LOWER(TRIM($7::TEXT))) + AND ( + $8::TEXT IS NULL + OR ( + $8::TEXT = 'ACTIVE' + AND EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id + AND us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP + ) + ) + OR ( + $8::TEXT = 'PENDING' + AND NOT EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id + AND us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP + ) + AND EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id AND us.status = 'PENDING' + ) + ) + OR ( + $8::TEXT = 'Unsubscribed' + AND NOT EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id + AND us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP + ) + AND NOT EXISTS ( + SELECT 1 FROM user_subscriptions us + WHERE us.user_id = users.id AND us.status = 'PENDING' + ) + ) + ) ORDER BY created_at DESC -LIMIT $7::INT -OFFSET $6::INT +LIMIT $10::INT +OFFSET $9::INT ` type GetAllUsersParams struct { - Role pgtype.Text `json:"role"` - Status pgtype.Text `json:"status"` - Query pgtype.Text `json:"query"` - CreatedAfter pgtype.Timestamptz `json:"created_after"` - CreatedBefore pgtype.Timestamptz `json:"created_before"` - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` + Role pgtype.Text `json:"role"` + Status pgtype.Text `json:"status"` + Query pgtype.Text `json:"query"` + CreatedAfter pgtype.Timestamptz `json:"created_after"` + CreatedBefore pgtype.Timestamptz `json:"created_before"` + Country pgtype.Text `json:"country"` + Region pgtype.Text `json:"region"` + SubscriptionStatus pgtype.Text `json:"subscription_status"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` } type GetAllUsersRow struct { @@ -441,6 +484,9 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get arg.Query, arg.CreatedAfter, arg.CreatedBefore, + arg.Country, + arg.Region, + arg.SubscriptionStatus, arg.Offset, arg.Limit, ) diff --git a/internal/domain/user.go b/internal/domain/user.go index b5f3616..aac4d1b 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -125,8 +125,11 @@ type UserProfileResponse struct { } type UserFilter struct { - Role string - Status string + Role string + Status string + Country string + Region string + SubscriptionStatus string // display filter: ACTIVE, PENDING, Unsubscribed (same as API subscription_status values) Page int64 PageSize int64 diff --git a/internal/ports/user.go b/internal/ports/user.go index 8ad82d7..4f2d1ef 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -55,6 +55,9 @@ type UserStore interface { status *string, query *string, createdBefore, createdAfter *time.Time, + country *string, + region *string, + subscriptionStatus *string, limit, offset int32, ) ([]domain.User, int64, error) ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error) diff --git a/internal/repository/user.go b/internal/repository/user.go index 9336d97..9419f97 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -414,6 +414,9 @@ func (s *Store) GetAllUsers( status *string, query *string, createdBefore, createdAfter *time.Time, + country *string, + region *string, + subscriptionStatus *string, limit, offset int32, ) ([]domain.User, int64, error) { @@ -442,12 +445,30 @@ func (s *Store) GetAllUsers( createdBeforeParam = pgtype.Timestamptz{Time: *createdBefore, Valid: true} } + var countryParam pgtype.Text + if country != nil && *country != "" { + countryParam = pgtype.Text{String: *country, Valid: true} + } + + var regionParam pgtype.Text + if region != nil && *region != "" { + regionParam = pgtype.Text{String: *region, Valid: true} + } + + var subscriptionStatusParam pgtype.Text + if subscriptionStatus != nil && *subscriptionStatus != "" { + subscriptionStatusParam = pgtype.Text{String: *subscriptionStatus, Valid: true} + } + params := dbgen.GetAllUsersParams{ - Role: roleParam, - Status: statusParam, - Query: queryParam, - CreatedAfter: createdAfterParam, - CreatedBefore: createdBeforeParam, + Role: roleParam, + Status: statusParam, + Query: queryParam, + CreatedAfter: createdAfterParam, + CreatedBefore: createdBeforeParam, + Country: countryParam, + Region: regionParam, + SubscriptionStatus: subscriptionStatusParam, Limit: pgtype.Int4{ Int32: limit, Valid: true, diff --git a/internal/services/user/direct.go b/internal/services/user/direct.go index 898fb57..603d9de 100644 --- a/internal/services/user/direct.go +++ b/internal/services/user/direct.go @@ -89,6 +89,21 @@ func (s *Service) GetAllUsers( query = &filter.Query } + var country *string + if filter.Country != "" { + country = &filter.Country + } + + var region *string + if filter.Region != "" { + region = &filter.Region + } + + var subscriptionStatus *string + if filter.SubscriptionStatus != "" { + subscriptionStatus = &filter.SubscriptionStatus + } + offset := int32(filter.Page * filter.PageSize) return s.userStore.GetAllUsers( @@ -98,6 +113,9 @@ func (s *Service) GetAllUsers( query, before, after, + country, + region, + subscriptionStatus, int32(filter.PageSize), offset, ) diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 03bcd99..7daa3bc 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -433,7 +433,10 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error { // @Param page_size query int false "Page size" // @Param created_before query string false "Created before (RFC3339)" // @Param created_after query string false "Created after (RFC3339)" -// @Param status query string false "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)" +// @Param status query string false "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)" +// @Param country query string false "Country filter (case-insensitive match on stored value)" +// @Param region query string false "Region filter (case-insensitive match on stored value)" +// @Param subscription_status query string false "Derived subscription filter: ACTIVE, PENDING, or Unsubscribed (matches response subscription_status semantics)" // @Success 200 {object} response.APIResponse // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse @@ -467,14 +470,32 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error { createdAfter = domain.ValidTime{Value: parsed, Valid: true} } + subscriptionStatusQuery := strings.TrimSpace(c.Query("subscription_status")) + var subscriptionStatusFilter string + if subscriptionStatusQuery != "" { + switch strings.ToUpper(subscriptionStatusQuery) { + case "ACTIVE": + subscriptionStatusFilter = "ACTIVE" + case "PENDING": + subscriptionStatusFilter = "PENDING" + case "UNSUBSCRIBED": + subscriptionStatusFilter = "Unsubscribed" + default: + return fiber.NewError(fiber.StatusBadRequest, `Invalid subscription_status; use ACTIVE, PENDING, or Unsubscribed`) + } + } + filter := domain.UserFilter{ - Role: c.Query("role"), - Status: c.Query("status"), - Page: int64(c.QueryInt("page", 1) - 1), - PageSize: int64(c.QueryInt("page_size", 10)), - Query: searchString.Value, - CreatedBefore: createdBefore, - CreatedAfter: createdAfter, + Role: c.Query("role"), + Status: c.Query("status"), + Country: strings.TrimSpace(c.Query("country")), + Region: strings.TrimSpace(c.Query("region")), + SubscriptionStatus: subscriptionStatusFilter, + Page: int64(c.QueryInt("page", 1) - 1), + PageSize: int64(c.QueryInt("page_size", 10)), + Query: searchString.Value, + CreatedBefore: createdBefore, + CreatedAfter: createdAfter, } if valErrs, ok := h.validator.Validate(c, filter); !ok {