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 <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-18 00:37:11 -07:00
parent 49bcc22d0d
commit 062b1f6151
10 changed files with 228 additions and 27 deletions

View File

@ -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;

View File

@ -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": {

View File

@ -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": {

View File

@ -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:

View File

@ -386,9 +386,49 @@ 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 {
@ -397,6 +437,9 @@ type GetAllUsersParams struct {
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"`
}
@ -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,
)

View File

@ -127,6 +127,9 @@ type UserProfileResponse struct {
type UserFilter struct {
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

View File

@ -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)

View File

@ -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,
Country: countryParam,
Region: regionParam,
SubscriptionStatus: subscriptionStatusParam,
Limit: pgtype.Int4{
Int32: limit,
Valid: true,

View File

@ -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,
)

View File

@ -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,9 +470,27 @@ 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"),
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,