mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add GET /organizations/{org}/groups/ai/spend (#27123)
## Description
Adds `GET /api/v2/organizations/{org}/groups/ai/spend?group_ids=...` to return per-group AI spend and configured limits for a set of groups in an organization.
In the UI, this endpoint is used alongside the existing `/api/v2/organizations/{org}/groups` endpoint. AI spend data is kept separate from that endpoint so that:
- Different concepts stay on different endpoints: identity (groups) 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/organizations/{org}/groups` → returns the organization's groups.
2. Request `/api/v2/organizations/{org}/groups/ai/spend?group_ids=...` with the IDs from step 1.
The groups endpoint from 1) is currently not paginated, but if pagination is added later, this design keeps the two responses in sync. This spend endpoint intentionally takes `group_ids` rather than paginating on its own, since it depends on the group set from step 1. Pagination could be added in the future, especially for Cost Control-focused pages.
<img width="2880" height="1460" alt="image" src="https://github.com/user-attachments/assets/ea83b74d-6a4f-45a6-af2f-1024e019da07" />
## Changes
- Add `codersdk.OrganizationGroupsAISpend` and `OrganizationGroupAISpend` types, plus a shared `AISpendPeriodWindow` embedded in the spend response.
- Add `GetOrganizationGroupsAISpend` SQL query with a dbauthz per-row filter that mirrors `GET /organizations/{org}/groups`.
- Add handler and route under `/organizations/{organization}/groups/ai/spend` with a required `group_ids` query param (cap 100). Callers with more than 100 groups 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-466/backend-organization-groups-endpoint-with-groups-spend
> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
This commit is contained in:
@@ -1458,6 +1458,17 @@ func UserAIBudgetOverride(o database.UserAIBudgetOverride) codersdk.UserAIBudget
|
||||
}
|
||||
}
|
||||
|
||||
func OrganizationGroupAISpend(row database.GetOrganizationGroupsAISpendRow) codersdk.OrganizationGroupAISpend {
|
||||
group := codersdk.OrganizationGroupAISpend{
|
||||
GroupID: row.GroupID,
|
||||
CurrentSpendMicros: row.CurrentSpendMicros,
|
||||
}
|
||||
if row.SpendLimitMicros.Valid {
|
||||
group.SpendLimitMicros = &row.SpendLimitMicros.Int64
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset {
|
||||
var presets []codersdk.InvalidatedPreset
|
||||
for _, p := range invalidatedPresets {
|
||||
|
||||
@@ -4217,6 +4217,10 @@ func (q *querier) GetOrganizationByName(ctx context.Context, name database.GetOr
|
||||
return fetch(q.log, q.auth, q.db.GetOrganizationByName)(ctx, name)
|
||||
}
|
||||
|
||||
func (q *querier) GetOrganizationGroupsAISpend(ctx context.Context, arg database.GetOrganizationGroupsAISpendParams) ([]database.GetOrganizationGroupsAISpendRow, error) {
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOrganizationGroupsAISpend)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]database.GetOrganizationIDsByMemberIDsRow, error) {
|
||||
// TODO: This should be rewritten to return a list of database.OrganizationMember for consistent RBAC objects.
|
||||
// Currently this row returns a list of org ids per user, which is challenging to check against the RBAC system.
|
||||
|
||||
@@ -6938,6 +6938,22 @@ func (s *MethodTestSuite) TestAIBridge() {
|
||||
check.Args(database.GetAIModelPriceByProviderModelParams{}).Asserts(rbac.ResourceAiModelPrice, policy.ActionRead)
|
||||
}))
|
||||
|
||||
s.Run("GetOrganizationGroupsAISpend", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
org := testutil.Fake(s.T(), faker, database.Organization{})
|
||||
row1 := testutil.Fake(s.T(), faker, database.GetOrganizationGroupsAISpendRow{OrganizationID: org.ID})
|
||||
row2 := testutil.Fake(s.T(), faker, database.GetOrganizationGroupsAISpendRow{OrganizationID: org.ID})
|
||||
arg := database.GetOrganizationGroupsAISpendParams{
|
||||
OrganizationID: org.ID,
|
||||
GroupIds: []uuid.UUID{row1.GroupID, row2.GroupID},
|
||||
PeriodStart: time.Now().UTC().Truncate(24 * time.Hour),
|
||||
}
|
||||
dbm.EXPECT().GetOrganizationGroupsAISpend(gomock.Any(), arg).
|
||||
Return([]database.GetOrganizationGroupsAISpendRow{row1, row2}, nil).AnyTimes()
|
||||
check.Args(arg).
|
||||
Asserts(row1, policy.ActionRead, row2, policy.ActionRead).
|
||||
Returns([]database.GetOrganizationGroupsAISpendRow{row1, row2})
|
||||
}))
|
||||
|
||||
s.Run("GetGroupAIBudget", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
g := testutil.Fake(s.T(), faker, database.Group{})
|
||||
b := testutil.Fake(s.T(), faker, database.GroupAIBudget{GroupID: g.ID})
|
||||
|
||||
+8
@@ -2537,6 +2537,14 @@ func (m queryMetricsStore) GetOrganizationByName(ctx context.Context, arg databa
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetOrganizationGroupsAISpend(ctx context.Context, arg database.GetOrganizationGroupsAISpendParams) ([]database.GetOrganizationGroupsAISpendRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetOrganizationGroupsAISpend(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetOrganizationGroupsAISpend").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetOrganizationGroupsAISpend").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]database.GetOrganizationIDsByMemberIDsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetOrganizationIDsByMemberIDs(ctx, ids)
|
||||
|
||||
Generated
+15
@@ -4708,6 +4708,21 @@ func (mr *MockStoreMockRecorder) GetOrganizationByName(ctx, arg any) *gomock.Cal
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationByName", reflect.TypeOf((*MockStore)(nil).GetOrganizationByName), ctx, arg)
|
||||
}
|
||||
|
||||
// GetOrganizationGroupsAISpend mocks base method.
|
||||
func (m *MockStore) GetOrganizationGroupsAISpend(ctx context.Context, arg database.GetOrganizationGroupsAISpendParams) ([]database.GetOrganizationGroupsAISpendRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetOrganizationGroupsAISpend", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetOrganizationGroupsAISpendRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetOrganizationGroupsAISpend indicates an expected call of GetOrganizationGroupsAISpend.
|
||||
func (mr *MockStoreMockRecorder) GetOrganizationGroupsAISpend(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationGroupsAISpend", reflect.TypeOf((*MockStore)(nil).GetOrganizationGroupsAISpend), ctx, arg)
|
||||
}
|
||||
|
||||
// GetOrganizationIDsByMemberIDs mocks base method.
|
||||
func (m *MockStore) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]database.GetOrganizationIDsByMemberIDsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -458,6 +458,10 @@ func (g GetGroupsRow) RBACObject() rbac.Object {
|
||||
return g.Group.RBACObject()
|
||||
}
|
||||
|
||||
func (g GetOrganizationGroupsAISpendRow) RBACObject() rbac.Object {
|
||||
return Group{ID: g.GroupID, OrganizationID: g.OrganizationID}.RBACObject()
|
||||
}
|
||||
|
||||
func (gm GroupMember) RBACObject() rbac.Object {
|
||||
return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String())
|
||||
}
|
||||
|
||||
Generated
+5
@@ -651,6 +651,11 @@ type sqlcQuerier interface {
|
||||
GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error)
|
||||
GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error)
|
||||
GetOrganizationByName(ctx context.Context, arg GetOrganizationByNameParams) (Organization, error)
|
||||
// Returns AI spend limits and aggregate spend for groups in @group_ids that
|
||||
// belong to @organization_id, on or after period_start until NOW. The spend
|
||||
// limit is null when the group has no configured budget.
|
||||
// The period_start parameter is normalized to its UTC calendar day.
|
||||
GetOrganizationGroupsAISpend(ctx context.Context, arg GetOrganizationGroupsAISpendParams) ([]GetOrganizationGroupsAISpendRow, error)
|
||||
GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error)
|
||||
GetOrganizationResourceCountByID(ctx context.Context, organizationID uuid.UUID) (GetOrganizationResourceCountByIDRow, error)
|
||||
GetOrganizations(ctx context.Context, arg GetOrganizationsParams) ([]Organization, error)
|
||||
|
||||
@@ -12800,6 +12800,312 @@ func TestGetUserAISpendSince(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetOrganizationGroupsAISpend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Use fixed dates to keep the test deterministic.
|
||||
monthStart := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
now := monthStart.AddDate(0, 0, 14) // 2024-06-15
|
||||
prevMonthLastDay := monthStart.AddDate(0, 0, -1) // 2024-05-31
|
||||
|
||||
type seedRow struct {
|
||||
day time.Time
|
||||
spend int64
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setBudget bool
|
||||
spendLimit int64
|
||||
rows []seedRow
|
||||
wantCurrentSpend int64
|
||||
}{
|
||||
{
|
||||
name: "NoBudgetNoSpend",
|
||||
wantCurrentSpend: 0,
|
||||
},
|
||||
{
|
||||
name: "ZeroLimitBudget",
|
||||
setBudget: true,
|
||||
spendLimit: 0,
|
||||
wantCurrentSpend: 0,
|
||||
},
|
||||
{
|
||||
name: "BudgetZeroSpend",
|
||||
setBudget: true,
|
||||
spendLimit: 1_000_000,
|
||||
wantCurrentSpend: 0,
|
||||
},
|
||||
{
|
||||
name: "BudgetWithSpend",
|
||||
setBudget: true,
|
||||
spendLimit: 1_000_000,
|
||||
rows: []seedRow{{now, 250}},
|
||||
wantCurrentSpend: 250,
|
||||
},
|
||||
{
|
||||
name: "NoBudgetWithSpend",
|
||||
rows: []seedRow{{now, 100}},
|
||||
wantCurrentSpend: 100,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Given: an org with a single group, optionally with a budget and seeded spend.
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
if tt.setBudget {
|
||||
_, err := db.UpsertGroupAIBudget(ctx, database.UpsertGroupAIBudgetParams{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: tt.spendLimit,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
for _, r := range tt.rows {
|
||||
_, err := db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: user.ID,
|
||||
EffectiveGroupID: group.ID,
|
||||
Day: r.day,
|
||||
CostMicros: r.spend,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// When: querying spend for the group since monthStart.
|
||||
got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{
|
||||
OrganizationID: org.ID,
|
||||
GroupIds: []uuid.UUID{group.ID},
|
||||
PeriodStart: monthStart,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: one row is returned with the group's limit and spend.
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, group.ID, got[0].GroupID)
|
||||
require.Equal(t, org.ID, got[0].OrganizationID)
|
||||
if tt.setBudget {
|
||||
require.True(t, got[0].SpendLimitMicros.Valid, "expected configured budget")
|
||||
require.Equal(t, tt.spendLimit, got[0].SpendLimitMicros.Int64, "spend_limit_micros")
|
||||
} else {
|
||||
require.False(t, got[0].SpendLimitMicros.Valid, "expected no configured budget")
|
||||
}
|
||||
require.Equal(t, tt.wantCurrentSpend, got[0].CurrentSpendMicros)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("MultipleGroupsInSameOrg", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Given: two groups in the same org with different budget and spend.
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
groupA := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
groupB := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
_, err := db.UpsertGroupAIBudget(ctx, database.UpsertGroupAIBudgetParams{
|
||||
GroupID: groupA.ID,
|
||||
SpendLimitMicros: 1_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: user.ID, EffectiveGroupID: groupA.ID, Day: now, CostMicros: 250,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: user.ID, EffectiveGroupID: groupB.ID, Day: now, CostMicros: 500,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: querying spend for both groups in one call.
|
||||
got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{
|
||||
OrganizationID: org.ID,
|
||||
GroupIds: []uuid.UUID{groupA.ID, groupB.ID},
|
||||
PeriodStart: monthStart,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: both are returned with their own budget and spend aggregates.
|
||||
require.Len(t, got, 2)
|
||||
byID := make(map[uuid.UUID]database.GetOrganizationGroupsAISpendRow, len(got))
|
||||
for _, r := range got {
|
||||
byID[r.GroupID] = r
|
||||
}
|
||||
rowA, ok := byID[groupA.ID]
|
||||
require.True(t, ok, "groupA missing from response")
|
||||
require.Equal(t, sql.NullInt64{Int64: 1_000_000, Valid: true}, rowA.SpendLimitMicros)
|
||||
require.Equal(t, int64(250), rowA.CurrentSpendMicros)
|
||||
rowB, ok := byID[groupB.ID]
|
||||
require.True(t, ok, "groupB missing from response")
|
||||
require.Equal(t, sql.NullInt64{}, rowB.SpendLimitMicros)
|
||||
require.Equal(t, int64(500), rowB.CurrentSpendMicros)
|
||||
})
|
||||
|
||||
t.Run("ExcludesGroupsInOtherOrgs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Given: a group in a different org with its own budget and spend.
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
otherOrg := dbgen.Organization(t, db, database.Organization{})
|
||||
group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
otherOrgGroup := dbgen.Group(t, db, database.Group{OrganizationID: otherOrg.ID})
|
||||
_, err := db.UpsertGroupAIBudget(ctx, database.UpsertGroupAIBudgetParams{
|
||||
GroupID: otherOrgGroup.ID,
|
||||
SpendLimitMicros: 9_999_999,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: user.ID, EffectiveGroupID: otherOrgGroup.ID, Day: now, CostMicros: 999,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: querying the primary org with both group IDs.
|
||||
got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{
|
||||
OrganizationID: org.ID,
|
||||
GroupIds: []uuid.UUID{group.ID, otherOrgGroup.ID},
|
||||
PeriodStart: monthStart,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: only the primary-org group is returned, and the cross-org group's budget and spend are absent.
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, group.ID, got[0].GroupID)
|
||||
require.Equal(t, sql.NullInt64{}, got[0].SpendLimitMicros,
|
||||
"cross-org group's budget must not leak")
|
||||
require.Equal(t, int64(0), got[0].CurrentSpendMicros,
|
||||
"cross-org group's spend must not leak")
|
||||
})
|
||||
|
||||
t.Run("ExcludesGroupIDsNotInList", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Given: two groups in the same org.
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
groupA := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
_ = dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
|
||||
// When: querying with only one of the group IDs.
|
||||
got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{
|
||||
OrganizationID: org.ID,
|
||||
GroupIds: []uuid.UUID{groupA.ID},
|
||||
PeriodStart: monthStart,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: only the requested group is returned.
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, groupA.ID, got[0].GroupID)
|
||||
})
|
||||
|
||||
t.Run("ExcludesSpendBeforePeriodStart", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Given: spend both in the prior period and in the current period.
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
_, err := db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: user.ID, EffectiveGroupID: group.ID, Day: prevMonthLastDay, CostMicros: 999,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: user.ID, EffectiveGroupID: group.ID, Day: monthStart, CostMicros: 25,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: querying since monthStart.
|
||||
got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{
|
||||
OrganizationID: org.ID,
|
||||
GroupIds: []uuid.UUID{group.ID},
|
||||
PeriodStart: monthStart,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: only the current-period spend is aggregated.
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, int64(25), got[0].CurrentSpendMicros)
|
||||
})
|
||||
|
||||
t.Run("AggregatesSpendAcrossUsers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Given: spend from two users attributed to the same group.
|
||||
userA := dbgen.User(t, db, database.User{})
|
||||
userB := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
_, err := db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: userA.ID, EffectiveGroupID: group.ID, Day: now, CostMicros: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: userB.ID, EffectiveGroupID: group.ID, Day: now, CostMicros: 25,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: querying the group's spend.
|
||||
got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{
|
||||
OrganizationID: org.ID,
|
||||
GroupIds: []uuid.UUID{group.ID},
|
||||
PeriodStart: monthStart,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: the group's aggregate sums both users' spend.
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, int64(125), got[0].CurrentSpendMicros)
|
||||
})
|
||||
|
||||
t.Run("NormalizesNonUTCPeriodStart", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Given: spend both in the prior UTC day and the first day of the current UTC month.
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
_, err := db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: user.ID, EffectiveGroupID: group.ID, Day: prevMonthLastDay, CostMicros: 999,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{
|
||||
UserID: user.ID, EffectiveGroupID: group.ID, Day: monthStart, CostMicros: 25,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: querying with a non-UTC period_start that normalizes to June 1 UTC.
|
||||
// 2024-05-31 23:00 in UTC-5 is 2024-06-01 04:00 UTC.
|
||||
localLate := time.Date(2024, 5, 31, 23, 0, 0, 0, time.FixedZone("UTC-5", -5*3600))
|
||||
got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{
|
||||
OrganizationID: org.ID,
|
||||
GroupIds: []uuid.UUID{group.ID},
|
||||
PeriodStart: localLate,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: the prior UTC day's spend is excluded from the aggregate.
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, int64(25), got[0].CurrentSpendMicros,
|
||||
"sum must exclude prevMonthLastDay row after normalization")
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatPinOrderQueries(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
|
||||
Generated
+62
@@ -2568,6 +2568,68 @@ func (q *sqlQuerier) GetHighestGroupAIBudgetByUser(ctx context.Context, userID u
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationGroupsAISpend = `-- name: GetOrganizationGroupsAISpend :many
|
||||
SELECT
|
||||
groups.id AS group_id,
|
||||
groups.organization_id AS organization_id,
|
||||
budget.spend_limit_micros AS spend_limit_micros,
|
||||
COALESCE(SUM(spend.spend_micros), 0)::BIGINT AS current_spend_micros
|
||||
FROM groups
|
||||
LEFT JOIN group_ai_budgets budget ON budget.group_id = groups.id
|
||||
LEFT JOIN ai_user_daily_spend spend
|
||||
ON spend.effective_group_id = groups.id
|
||||
AND spend.day >= (($1::timestamptz) AT TIME ZONE 'UTC')::date
|
||||
WHERE groups.organization_id = $2
|
||||
AND groups.id = ANY($3::uuid[])
|
||||
GROUP BY groups.id, budget.spend_limit_micros
|
||||
ORDER BY groups.id
|
||||
`
|
||||
|
||||
type GetOrganizationGroupsAISpendParams struct {
|
||||
PeriodStart time.Time `db:"period_start" json:"period_start"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
GroupIds []uuid.UUID `db:"group_ids" json:"group_ids"`
|
||||
}
|
||||
|
||||
type GetOrganizationGroupsAISpendRow struct {
|
||||
GroupID uuid.UUID `db:"group_id" json:"group_id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"`
|
||||
CurrentSpendMicros int64 `db:"current_spend_micros" json:"current_spend_micros"`
|
||||
}
|
||||
|
||||
// Returns AI spend limits and aggregate spend for groups in @group_ids that
|
||||
// belong to @organization_id, on or after period_start until NOW. The spend
|
||||
// limit is null when the group has no configured budget.
|
||||
// The period_start parameter is normalized to its UTC calendar day.
|
||||
func (q *sqlQuerier) GetOrganizationGroupsAISpend(ctx context.Context, arg GetOrganizationGroupsAISpendParams) ([]GetOrganizationGroupsAISpendRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getOrganizationGroupsAISpend, arg.PeriodStart, arg.OrganizationID, pq.Array(arg.GroupIds))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetOrganizationGroupsAISpendRow
|
||||
for rows.Next() {
|
||||
var i GetOrganizationGroupsAISpendRow
|
||||
if err := rows.Scan(
|
||||
&i.GroupID,
|
||||
&i.OrganizationID,
|
||||
&i.SpendLimitMicros,
|
||||
&i.CurrentSpendMicros,
|
||||
); 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 getUserAIBudgetOverride = `-- name: GetUserAIBudgetOverride :one
|
||||
SELECT user_id, group_id, spend_limit_micros, created_at, updated_at
|
||||
FROM user_ai_budget_overrides
|
||||
|
||||
@@ -101,3 +101,23 @@ FROM ai_user_daily_spend
|
||||
WHERE user_id = @user_id
|
||||
AND effective_group_id = @effective_group_id
|
||||
AND day >= ((@period_start::timestamptz) AT TIME ZONE 'UTC')::date;
|
||||
|
||||
-- name: GetOrganizationGroupsAISpend :many
|
||||
-- Returns AI spend limits and aggregate spend for groups in @group_ids that
|
||||
-- belong to @organization_id, on or after period_start until NOW. The spend
|
||||
-- limit is null when the group has no configured budget.
|
||||
-- The period_start parameter is normalized to its UTC calendar day.
|
||||
SELECT
|
||||
groups.id AS group_id,
|
||||
groups.organization_id AS organization_id,
|
||||
budget.spend_limit_micros AS spend_limit_micros,
|
||||
COALESCE(SUM(spend.spend_micros), 0)::BIGINT AS current_spend_micros
|
||||
FROM groups
|
||||
LEFT JOIN group_ai_budgets budget ON budget.group_id = groups.id
|
||||
LEFT JOIN ai_user_daily_spend spend
|
||||
ON spend.effective_group_id = groups.id
|
||||
AND spend.day >= ((@period_start::timestamptz) AT TIME ZONE 'UTC')::date
|
||||
WHERE groups.organization_id = @organization_id
|
||||
AND groups.id = ANY(@group_ids::uuid[])
|
||||
GROUP BY groups.id, budget.spend_limit_micros
|
||||
ORDER BY groups.id;
|
||||
|
||||
Reference in New Issue
Block a user