feat: add GET /groups/{group}/members/ai/spend (#27130)

## Description

Adds `GET /api/v2/groups/{group}/members/ai/spend?user_ids=...` (also available org-scoped at `/api/v2/organizations/{org}/groups/{groupName}/members/ai/spend`) to return per-member AI spend attributed to a group, along with each member's effective budget group and the applied spend limit when the queried group is their effective budget source.

In the UI, this endpoint is used alongside the existing `/api/v2/groups/{group}/members` endpoint. AI spend data is kept separate from that endpoint so that:

- Different concepts stay on different endpoints: identity (group members) vs. cost control (spend). Cost control is an additional feature layered on top of groups/orgs.
- Callers that don't need spend information don't pay for its computation.

UI flow:

1. Request `/api/v2/groups/{group}/members` → returns the group's members.
2. Request `/api/v2/groups/{group}/members/ai/spend?user_ids=...` with the IDs from step 1.

**Note:** Only current members of the queried group are returned. `spend_limit_micros` and `limit_source` are populated only when the queried group is the member's effective budget source (its own limit or a user override). `effective_group_id` is null when the member's budget resolves to a group in another organization, since an organization is treated as a tenant boundary.

<img width="2880" height="1904" alt="image" src="https://github.com/user-attachments/assets/33ed395d-d1a3-4b46-bb04-c8d3f41c8886" />

## Changes

- Add `codersdk.GroupMembersAISpend` and `GroupMemberAISpend` types, reusing the shared `AISpendPeriodWindow`.
- Add `GetGroupMembersAISpend` SQL query with a dbauthz per-row filter that mirrors `GET /api/v2/groups/{group}/members`.
- Add handler and routes under `/groups/{group}/members/ai/spend` (and the org-scoped alias) with a required `user_ids` query param (cap 100). Callers with more than 100 members are expected to batch across multiple requests.
- Add codersdk client method.
- Tests: dbauthz, raw SQL, endpoint, and role-access.

Closes https://linear.app/codercom/issue/AIGOV-471/backend-group-members-endpoint-with-members-spend

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
This commit is contained in:
Susana Ferreira
2026-07-20 13:04:37 +01:00
committed by GitHub
parent 2adc8f5272
commit a9fdf87a2f
20 changed files with 2009 additions and 0 deletions
+131
View File
@@ -2533,6 +2533,137 @@ func (q *sqlQuerier) GetGroupAIBudget(ctx context.Context, groupID uuid.UUID) (G
return i, err
}
const getGroupMembersAISpend = `-- name: GetGroupMembersAISpend :many
WITH queried_group AS (
-- The queried group's org, used to detect cross-org effective groups.
SELECT organization_id
FROM groups
WHERE id = $1
),
filtered_users AS (
-- Users from @user_ids that are members of the queried group. Uses
-- group_members_expanded so the implicit Everyone group counts.
SELECT DISTINCT user_id
FROM group_members_expanded
WHERE group_id = $1
AND user_id = ANY($3::uuid[])
),
user_highest_group AS (
-- Per user, the highest-limit group they belong to. Uses
-- group_members_expanded so the implicit Everyone group counts.
SELECT DISTINCT ON (member.user_id)
member.user_id,
budget.group_id,
budget.spend_limit_micros
FROM group_ai_budgets budget
JOIN group_members_expanded member ON member.group_id = budget.group_id
WHERE member.user_id IN (SELECT user_id FROM filtered_users)
ORDER BY member.user_id, budget.spend_limit_micros DESC, member.group_name ASC, budget.group_id ASC
),
effective AS (
-- Effective budget per user: a per-user override wins over the
-- highest-limit group.
SELECT
filtered_users.user_id,
COALESCE(override.group_id, user_highest_group.group_id) AS raw_effective_group_id,
COALESCE(override.spend_limit_micros, user_highest_group.spend_limit_micros) AS spend_limit_micros,
(CASE
WHEN override.group_id IS NOT NULL THEN 'user_override'
WHEN user_highest_group.group_id IS NOT NULL THEN 'group'
END)::text AS limit_source
FROM filtered_users
LEFT JOIN user_ai_budget_overrides override ON override.user_id = filtered_users.user_id
LEFT JOIN user_highest_group ON user_highest_group.user_id = filtered_users.user_id
),
applied_budget AS (
-- The limit and source only for users whose effective budget source is the
-- queried group.
SELECT user_id, spend_limit_micros, limit_source
FROM effective
WHERE raw_effective_group_id = $1
)
SELECT
effective.user_id,
queried_group.organization_id,
effective_group.id AS effective_group_id,
applied_budget.spend_limit_micros,
applied_budget.limit_source,
COALESCE(SUM(spend.spend_micros), 0)::BIGINT AS group_spend_micros
FROM effective
CROSS JOIN queried_group
LEFT JOIN groups effective_group
ON effective_group.id = effective.raw_effective_group_id
AND effective_group.organization_id = queried_group.organization_id
LEFT JOIN applied_budget ON applied_budget.user_id = effective.user_id
LEFT JOIN ai_user_daily_spend spend
ON spend.user_id = effective.user_id
AND spend.effective_group_id = $1
AND spend.day >= (($2::timestamptz) AT TIME ZONE 'UTC')::date
GROUP BY
effective.user_id,
queried_group.organization_id,
effective_group.id,
applied_budget.spend_limit_micros,
applied_budget.limit_source
ORDER BY effective.user_id
`
type GetGroupMembersAISpendParams struct {
GroupID uuid.UUID `db:"group_id" json:"group_id"`
PeriodStart time.Time `db:"period_start" json:"period_start"`
UserIds []uuid.UUID `db:"user_ids" json:"user_ids"`
}
type GetGroupMembersAISpendRow struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
EffectiveGroupID uuid.NullUUID `db:"effective_group_id" json:"effective_group_id"`
SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"`
LimitSource sql.NullString `db:"limit_source" json:"limit_source"`
GroupSpendMicros int64 `db:"group_spend_micros" json:"group_spend_micros"`
}
// Returns each user's AI spend attributed to the queried group, on or after
// period_start until NOW. Only current members of the queried group are
// returned. spend_limit_micros and limit_source are populated only when the
// queried group is the user's effective budget source. The effective_group_id
// is null when the user has no configured budget or when the effective group
// belongs to a different organization than the queried group.
// The period_start parameter is normalized to its UTC calendar day.
// TODO(AIGOV-527): unify effective group resolution in a single place.
// Spend is aggregated for the queried group, not the user's effective group.
// A LEFT JOIN leaves spend_limit_micros and limit_source null for users
// whose effective budget source is not the queried group.
func (q *sqlQuerier) GetGroupMembersAISpend(ctx context.Context, arg GetGroupMembersAISpendParams) ([]GetGroupMembersAISpendRow, error) {
rows, err := q.db.QueryContext(ctx, getGroupMembersAISpend, arg.GroupID, arg.PeriodStart, pq.Array(arg.UserIds))
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetGroupMembersAISpendRow
for rows.Next() {
var i GetGroupMembersAISpendRow
if err := rows.Scan(
&i.UserID,
&i.OrganizationID,
&i.EffectiveGroupID,
&i.SpendLimitMicros,
&i.LimitSource,
&i.GroupSpendMicros,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getHighestGroupAIBudgetByUser = `-- name: GetHighestGroupAIBudgetByUser :one
SELECT
gaib.group_id,