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_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('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 ORDER BY created_at DESC
LIMIT sqlc.narg('limit')::INT LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT; OFFSET sqlc.narg('offset')::INT;

View File

@ -8486,9 +8486,27 @@ const docTemplate = `{
}, },
{ {
"type": "string", "type": "string",
"description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", "description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
"name": "status", "name": "status",
"in": "query" "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": { "responses": {

View File

@ -8478,9 +8478,27 @@
}, },
{ {
"type": "string", "type": "string",
"description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", "description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
"name": "status", "name": "status",
"in": "query" "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": { "responses": {

View File

@ -8017,10 +8017,23 @@ paths:
in: query in: query
name: created_after name: created_after
type: string type: string
- description: Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED) - description: User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)
in: query in: query
name: status name: status
type: string 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: produces:
- application/json - application/json
responses: responses:

View File

@ -386,9 +386,49 @@ WHERE
)) ))
AND ($4::TIMESTAMPTZ IS NULL OR created_at >= $4::TIMESTAMPTZ) AND ($4::TIMESTAMPTZ IS NULL OR created_at >= $4::TIMESTAMPTZ)
AND ($5::TIMESTAMPTZ IS NULL OR created_at <= $5::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 ORDER BY created_at DESC
LIMIT $7::INT LIMIT $10::INT
OFFSET $6::INT OFFSET $9::INT
` `
type GetAllUsersParams struct { type GetAllUsersParams struct {
@ -397,6 +437,9 @@ type GetAllUsersParams struct {
Query pgtype.Text `json:"query"` Query pgtype.Text `json:"query"`
CreatedAfter pgtype.Timestamptz `json:"created_after"` CreatedAfter pgtype.Timestamptz `json:"created_after"`
CreatedBefore pgtype.Timestamptz `json:"created_before"` 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"` Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"` Limit pgtype.Int4 `json:"limit"`
} }
@ -441,6 +484,9 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get
arg.Query, arg.Query,
arg.CreatedAfter, arg.CreatedAfter,
arg.CreatedBefore, arg.CreatedBefore,
arg.Country,
arg.Region,
arg.SubscriptionStatus,
arg.Offset, arg.Offset,
arg.Limit, arg.Limit,
) )

View File

@ -127,6 +127,9 @@ type UserProfileResponse struct {
type UserFilter struct { type UserFilter struct {
Role string Role string
Status string Status string
Country string
Region string
SubscriptionStatus string // display filter: ACTIVE, PENDING, Unsubscribed (same as API subscription_status values)
Page int64 Page int64
PageSize int64 PageSize int64

View File

@ -55,6 +55,9 @@ type UserStore interface {
status *string, status *string,
query *string, query *string,
createdBefore, createdAfter *time.Time, createdBefore, createdAfter *time.Time,
country *string,
region *string,
subscriptionStatus *string,
limit, offset int32, limit, offset int32,
) ([]domain.User, int64, error) ) ([]domain.User, int64, error)
ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, 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, status *string,
query *string, query *string,
createdBefore, createdAfter *time.Time, createdBefore, createdAfter *time.Time,
country *string,
region *string,
subscriptionStatus *string,
limit, offset int32, limit, offset int32,
) ([]domain.User, int64, error) { ) ([]domain.User, int64, error) {
@ -442,12 +445,30 @@ func (s *Store) GetAllUsers(
createdBeforeParam = pgtype.Timestamptz{Time: *createdBefore, Valid: true} 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{ params := dbgen.GetAllUsersParams{
Role: roleParam, Role: roleParam,
Status: statusParam, Status: statusParam,
Query: queryParam, Query: queryParam,
CreatedAfter: createdAfterParam, CreatedAfter: createdAfterParam,
CreatedBefore: createdBeforeParam, CreatedBefore: createdBeforeParam,
Country: countryParam,
Region: regionParam,
SubscriptionStatus: subscriptionStatusParam,
Limit: pgtype.Int4{ Limit: pgtype.Int4{
Int32: limit, Int32: limit,
Valid: true, Valid: true,

View File

@ -89,6 +89,21 @@ func (s *Service) GetAllUsers(
query = &filter.Query 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) offset := int32(filter.Page * filter.PageSize)
return s.userStore.GetAllUsers( return s.userStore.GetAllUsers(
@ -98,6 +113,9 @@ func (s *Service) GetAllUsers(
query, query,
before, before,
after, after,
country,
region,
subscriptionStatus,
int32(filter.PageSize), int32(filter.PageSize),
offset, offset,
) )

View File

@ -433,7 +433,10 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
// @Param page_size query int false "Page size" // @Param page_size query int false "Page size"
// @Param created_before query string false "Created before (RFC3339)" // @Param created_before query string false "Created before (RFC3339)"
// @Param created_after query string false "Created after (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 // @Success 200 {object} response.APIResponse
// @Failure 400 {object} response.APIResponse // @Failure 400 {object} response.APIResponse
// @Failure 500 {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} 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{ filter := domain.UserFilter{
Role: c.Query("role"), Role: c.Query("role"),
Status: c.Query("status"), 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), Page: int64(c.QueryInt("page", 1) - 1),
PageSize: int64(c.QueryInt("page_size", 10)), PageSize: int64(c.QueryInt("page_size", 10)),
Query: searchString.Value, Query: searchString.Value,