user list response fix

This commit is contained in:
Yared Yemane 2026-05-18 00:09:26 -07:00
parent 2883561525
commit f824c16c64
11 changed files with 327 additions and 4 deletions

View File

@ -61,6 +61,29 @@ FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.id = $1;
-- name: ListActiveSubscriptionsByUserIDs :many
-- One ACTIVE, non-expired row per user (latest expires_at wins), same rules as GetActiveSubscriptionByUserID.
SELECT DISTINCT ON (us.user_id)
us.user_id,
us.id,
us.plan_id,
us.starts_at,
us.expires_at,
us.status,
us.auto_renew,
us.payment_method,
sp.name AS plan_name,
sp.duration_value,
sp.duration_unit,
sp.price,
sp.currency
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.user_id = ANY($1::bigint[])
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
ORDER BY us.user_id, us.expires_at DESC;
-- name: GetActiveSubscriptionByUserID :one
SELECT
us.*,

View File

@ -8436,7 +8436,7 @@ const docTemplate = `{
},
"/api/v1/users": {
"get": {
"description": "Get users with optional filters",
"description": "Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.",
"consumes": [
"application/json"
],
@ -11294,6 +11294,9 @@ const docTemplate = `{
"domain.UserProfileResponse": {
"type": "object",
"properties": {
"active_subscription": {
"$ref": "#/definitions/domain.UserSubscriptionSummary"
},
"age_group": {
"type": "string"
},
@ -11401,6 +11404,47 @@ const docTemplate = `{
"UserStatusDeactivated"
]
},
"domain.UserSubscriptionSummary": {
"type": "object",
"properties": {
"auto_renew": {
"type": "boolean"
},
"currency": {
"type": "string"
},
"duration_unit": {
"type": "string"
},
"duration_value": {
"type": "integer"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "integer"
},
"payment_method": {
"type": "string"
},
"plan_id": {
"type": "integer"
},
"plan_name": {
"type": "string"
},
"price": {
"type": "number"
},
"starts_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"domain.UserSummary": {
"type": "object",
"properties": {

View File

@ -8428,7 +8428,7 @@
},
"/api/v1/users": {
"get": {
"description": "Get users with optional filters",
"description": "Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.",
"consumes": [
"application/json"
],
@ -11286,6 +11286,9 @@
"domain.UserProfileResponse": {
"type": "object",
"properties": {
"active_subscription": {
"$ref": "#/definitions/domain.UserSubscriptionSummary"
},
"age_group": {
"type": "string"
},
@ -11393,6 +11396,47 @@
"UserStatusDeactivated"
]
},
"domain.UserSubscriptionSummary": {
"type": "object",
"properties": {
"auto_renew": {
"type": "boolean"
},
"currency": {
"type": "string"
},
"duration_unit": {
"type": "string"
},
"duration_value": {
"type": "integer"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "integer"
},
"payment_method": {
"type": "string"
},
"plan_id": {
"type": "integer"
},
"plan_name": {
"type": "string"
},
"price": {
"type": "number"
},
"starts_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"domain.UserSummary": {
"type": "object",
"properties": {

View File

@ -1145,6 +1145,8 @@ definitions:
type: object
domain.UserProfileResponse:
properties:
active_subscription:
$ref: '#/definitions/domain.UserSubscriptionSummary'
age_group:
type: string
birth_day:
@ -1219,6 +1221,33 @@ definitions:
- UserStatusActive
- UserStatusSuspended
- UserStatusDeactivated
domain.UserSubscriptionSummary:
properties:
auto_renew:
type: boolean
currency:
type: string
duration_unit:
type: string
duration_value:
type: integer
expires_at:
type: string
id:
type: integer
payment_method:
type: string
plan_id:
type: integer
plan_name:
type: string
price:
type: number
starts_at:
type: string
status:
type: string
type: object
domain.UserSummary:
properties:
active_users:
@ -7988,7 +8017,8 @@ paths:
get:
consumes:
- application/json
description: Get users with optional filters
description: Get users with optional filters. Each user may include active_subscription
when they have a current ACTIVE, non-expired plan.
parameters:
- description: Role filter
in: query

View File

@ -578,6 +578,80 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
return items, nil
}
const ListActiveSubscriptionsByUserIDs = `-- name: ListActiveSubscriptionsByUserIDs :many
SELECT DISTINCT ON (us.user_id)
us.user_id,
us.id,
us.plan_id,
us.starts_at,
us.expires_at,
us.status,
us.auto_renew,
us.payment_method,
sp.name AS plan_name,
sp.duration_value,
sp.duration_unit,
sp.price,
sp.currency
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.user_id = ANY($1::bigint[])
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
ORDER BY us.user_id, us.expires_at DESC
`
type ListActiveSubscriptionsByUserIDsRow struct {
UserID int64 `json:"user_id"`
ID int64 `json:"id"`
PlanID int64 `json:"plan_id"`
StartsAt pgtype.Timestamptz `json:"starts_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
Status string `json:"status"`
AutoRenew bool `json:"auto_renew"`
PaymentMethod pgtype.Text `json:"payment_method"`
PlanName string `json:"plan_name"`
DurationValue int32 `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Price pgtype.Numeric `json:"price"`
Currency string `json:"currency"`
}
// One ACTIVE, non-expired row per user (latest expires_at wins), same rules as GetActiveSubscriptionByUserID.
func (q *Queries) ListActiveSubscriptionsByUserIDs(ctx context.Context, dollar_1 []int64) ([]ListActiveSubscriptionsByUserIDsRow, error) {
rows, err := q.db.Query(ctx, ListActiveSubscriptionsByUserIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActiveSubscriptionsByUserIDsRow
for rows.Next() {
var i ListActiveSubscriptionsByUserIDsRow
if err := rows.Scan(
&i.UserID,
&i.ID,
&i.PlanID,
&i.StartsAt,
&i.ExpiresAt,
&i.Status,
&i.AutoRenew,
&i.PaymentMethod,
&i.PlanName,
&i.DurationValue,
&i.DurationUnit,
&i.Price,
&i.Currency,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListSubscriptionPlans = `-- name: ListSubscriptionPlans :many
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans
WHERE ($1::BOOLEAN IS NULL OR $1 = true AND is_active = true OR $1 = false)

View File

@ -56,6 +56,54 @@ type UserSubscription struct {
Currency *string
}
// UserSubscriptionSummary is the active subscription attached to admin user list responses (GET /users).
type UserSubscriptionSummary struct {
ID int64 `json:"id"`
PlanID int64 `json:"plan_id"`
PlanName string `json:"plan_name"`
Status string `json:"status"`
StartsAt time.Time `json:"starts_at"`
ExpiresAt time.Time `json:"expires_at"`
AutoRenew bool `json:"auto_renew"`
PaymentMethod *string `json:"payment_method,omitempty"`
DurationValue int32 `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Price float64 `json:"price"`
Currency string `json:"currency"`
}
// Summary returns a copy safe for JSON embedding; nil if receiver is nil.
func (us *UserSubscription) Summary() *UserSubscriptionSummary {
if us == nil {
return nil
}
s := &UserSubscriptionSummary{
ID: us.ID,
PlanID: us.PlanID,
Status: us.Status,
StartsAt: us.StartsAt,
ExpiresAt: us.ExpiresAt,
AutoRenew: us.AutoRenew,
PaymentMethod: us.PaymentMethod,
}
if us.PlanName != nil {
s.PlanName = *us.PlanName
}
if us.DurationValue != nil {
s.DurationValue = *us.DurationValue
}
if us.DurationUnit != nil {
s.DurationUnit = *us.DurationUnit
}
if us.Price != nil {
s.Price = *us.Price
}
if us.Currency != nil {
s.Currency = *us.Currency
}
return s
}
type CreateSubscriptionPlanInput struct {
Name string
Description *string

View File

@ -120,6 +120,8 @@ type UserProfileResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
ActiveSubscription *UserSubscriptionSummary `json:"active_subscription,omitempty"`
}
type UserFilter struct {

View File

@ -18,6 +18,7 @@ type SubscriptionStore interface {
CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error)
GetUserSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error)
GetActiveSubscriptionByUserID(ctx context.Context, userID int64) (*domain.UserSubscription, error)
ListActiveSubscriptionsByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error)
GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error)
HasActiveSubscription(ctx context.Context, userID int64) (bool, error)
CancelUserSubscription(ctx context.Context, id int64) error

View File

@ -157,6 +157,39 @@ func (s *Store) GetActiveSubscriptionByUserID(ctx context.Context, userID int64)
}, nil
}
func (s *Store) ListActiveSubscriptionsByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error) {
if len(userIDs) == 0 {
return map[int64]*domain.UserSubscription{}, nil
}
rows, err := s.queries.ListActiveSubscriptionsByUserIDs(ctx, userIDs)
if err != nil {
return nil, err
}
out := make(map[int64]*domain.UserSubscription, len(rows))
for _, r := range rows {
dv := r.DurationValue
du := r.DurationUnit
pn := r.PlanName
cur := r.Currency
out[r.UserID] = &domain.UserSubscription{
ID: r.ID,
UserID: r.UserID,
PlanID: r.PlanID,
StartsAt: r.StartsAt.Time,
ExpiresAt: r.ExpiresAt.Time,
Status: r.Status,
AutoRenew: r.AutoRenew,
PaymentMethod: fromPgText(r.PaymentMethod),
PlanName: &pn,
DurationValue: &dv,
DurationUnit: &du,
Price: float64Ptr(fromPgNumeric(r.Price)),
Currency: &cur,
}
}
return out, nil
}
func (s *Store) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {
subs, err := s.queries.GetUserSubscriptionHistory(ctx, dbgen.GetUserSubscriptionHistoryParams{
UserID: userID,

View File

@ -107,6 +107,11 @@ func (s *Service) GetActiveSubscription(ctx context.Context, userID int64) (*dom
return s.store.GetActiveSubscriptionByUserID(ctx, userID)
}
// ListActiveSubscriptionsForUserIDs returns the current ACTIVE, non-expired subscription per user (latest expiry).
func (s *Service) ListActiveSubscriptionsForUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error) {
return s.store.ListActiveSubscriptionsByUserIDs(ctx, userIDs)
}
func (s *Service) GetSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {
return s.store.GetUserSubscriptionHistory(ctx, userID, limit, offset)
}

View File

@ -423,7 +423,7 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
// GetAllUsers godoc
// @Summary Get all users
// @Description Get users with optional filters
// @Description Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.
// @Tags user
// @Accept json
// @Produce json
@ -499,6 +499,19 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get users: "+err.Error())
}
userIDs := make([]int64, len(users))
for i, u := range users {
userIDs[i] = u.ID
}
activeSubs, err := h.subscriptionsSvc.ListActiveSubscriptionsForUserIDs(c.Context(), userIDs)
if err != nil {
h.mongoLoggerSvc.Error("failed to batch-load active subscriptions for user list",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()))
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get subscription info: "+err.Error())
}
// Map to profile response to avoid leaking sensitive fields
// result := make([]domain.UserProfileResponse, len(users))
// for i, u := range users {
@ -538,6 +551,11 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
if !u.BirthDay.IsZero() {
bd = u.BirthDay.Format("2006-01-02")
}
var activeSub *domain.UserSubscriptionSummary
if sub, ok := activeSubs[u.ID]; ok {
activeSub = sub.Summary()
}
mapped = append(mapped, domain.UserProfileResponse{
ID: u.ID,
FirstName: u.FirstName,
@ -567,6 +585,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
PreferredLanguage: u.PreferredLanguage,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
ActiveSubscription: activeSub,
})
}