diff --git a/db/query/subscriptions.sql b/db/query/subscriptions.sql index 2159937..349a6d9 100644 --- a/db/query/subscriptions.sql +++ b/db/query/subscriptions.sql @@ -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.*, diff --git a/docs/docs.go b/docs/docs.go index 17ec959..b644d68 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index 9185f8e..d0094b4 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index da4d9a6..a1538d8 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/gen/db/subscriptions.sql.go b/gen/db/subscriptions.sql.go index c0aadd4..c482b17 100644 --- a/gen/db/subscriptions.sql.go +++ b/gen/db/subscriptions.sql.go @@ -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) diff --git a/internal/domain/subscriptions.go b/internal/domain/subscriptions.go index d5e7228..7bc9fe8 100644 --- a/internal/domain/subscriptions.go +++ b/internal/domain/subscriptions.go @@ -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 diff --git a/internal/domain/user.go b/internal/domain/user.go index a62512f..79f251d 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -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 { diff --git a/internal/ports/subscriptions.go b/internal/ports/subscriptions.go index e70c726..6a83c71 100644 --- a/internal/ports/subscriptions.go +++ b/internal/ports/subscriptions.go @@ -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 diff --git a/internal/repository/subscriptions.go b/internal/repository/subscriptions.go index 149e11f..007a30b 100644 --- a/internal/repository/subscriptions.go +++ b/internal/repository/subscriptions.go @@ -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, diff --git a/internal/services/subscriptions/service.go b/internal/services/subscriptions/service.go index 4112828..f0e5500 100644 --- a/internal/services/subscriptions/service.go +++ b/internal/services/subscriptions/service.go @@ -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) } diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index d30910d..a91bbbe 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -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, }) }