add admin user last used support
This commit is contained in:
@@ -299,51 +299,6 @@ func normalizeEmailAuthIdentitySubject(email string) string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (r *userRepository) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
|
||||
result := make(map[int64]*time.Time, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if r.sql == nil {
|
||||
return nil, fmt.Errorf("sql executor is not configured")
|
||||
}
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, `
|
||||
SELECT user_id, MAX(created_at) AS last_used_at
|
||||
FROM usage_logs
|
||||
WHERE user_id = ANY($1)
|
||||
GROUP BY user_id
|
||||
`, pq.Array(userIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
userID int64
|
||||
lastUsedAt time.Time
|
||||
)
|
||||
if err := rows.Scan(&userID, &lastUsedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts := lastUsedAt.UTC()
|
||||
result[userID] = &ts
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
|
||||
latestByUserID, err := r.GetLatestUsedAtByUserIDs(ctx, []int64{userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return latestByUserID[userID], nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(ctx context.Context, id int64) error {
|
||||
affected, err := r.client.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx)
|
||||
if err != nil {
|
||||
@@ -469,6 +424,10 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
|
||||
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
|
||||
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
|
||||
|
||||
if sortBy == "last_used_at" {
|
||||
return userLastUsedAtOrder(sortOrder)
|
||||
}
|
||||
|
||||
var field string
|
||||
defaultField := true
|
||||
nullsLastField := false
|
||||
@@ -530,6 +489,72 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
|
||||
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(dbuser.FieldID)}
|
||||
}
|
||||
|
||||
func (r *userRepository) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
|
||||
result := make(map[int64]*time.Time, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if r.sql == nil {
|
||||
return nil, fmt.Errorf("sql executor is not configured")
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT user_id, MAX(created_at) AS last_used_at
|
||||
FROM usage_logs
|
||||
WHERE user_id = ANY($1)
|
||||
GROUP BY user_id
|
||||
`
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, pq.Array(userIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
userID int64
|
||||
lastUsedAt time.Time
|
||||
)
|
||||
if scanErr := rows.Scan(&userID, &lastUsedAt); scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
ts := lastUsedAt.UTC()
|
||||
result[userID] = &ts
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
|
||||
latestByUserID, err := r.GetLatestUsedAtByUserIDs(ctx, []int64{userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return latestByUserID[userID], nil
|
||||
}
|
||||
|
||||
func userLastUsedAtOrder(sortOrder string) []func(*entsql.Selector) {
|
||||
orderExpr := func(direction, nulls string, tieOrder func(string) string) func(*entsql.Selector) {
|
||||
return func(s *entsql.Selector) {
|
||||
subquery := fmt.Sprintf("(SELECT MAX(created_at) FROM usage_logs WHERE user_id = %s)", s.C(dbuser.FieldID))
|
||||
s.OrderExpr(entsql.Expr(subquery + " " + direction + " NULLS " + nulls))
|
||||
s.OrderBy(tieOrder(s.C(dbuser.FieldID)))
|
||||
}
|
||||
}
|
||||
|
||||
if sortOrder == pagination.SortOrderAsc {
|
||||
return []func(*entsql.Selector){
|
||||
orderExpr("ASC", "FIRST", entsql.Asc),
|
||||
}
|
||||
}
|
||||
return []func(*entsql.Selector){
|
||||
orderExpr("DESC", "LAST", entsql.Desc),
|
||||
}
|
||||
}
|
||||
|
||||
// filterUsersByAttributes returns user IDs that match ALL the given attribute filters
|
||||
func (r *userRepository) filterUsersByAttributes(ctx context.Context, attrs map[int64]string) ([]int64, error) {
|
||||
if len(attrs) == 0 {
|
||||
|
||||
@@ -10,6 +10,24 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
func (s *UserRepoSuite) mustInsertUsageLog(userID int64, createdAt time.Time) {
|
||||
s.T().Helper()
|
||||
|
||||
account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "usage-log-account"})
|
||||
apiKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: userID})
|
||||
|
||||
_, err := integrationDB.ExecContext(
|
||||
s.ctx,
|
||||
`INSERT INTO usage_logs (user_id, api_key_id, account_id, model, input_tokens, output_tokens, total_cost, actual_cost, created_at)
|
||||
VALUES ($1, $2, $3, 'gpt-test', 1, 1, 0.01, 0.01, $4)`,
|
||||
userID,
|
||||
apiKey.ID,
|
||||
account.ID,
|
||||
createdAt.UTC(),
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestListWithFilters_SortByEmailAsc() {
|
||||
s.mustCreateUser(&service.User{Email: "z-last@example.com", Username: "z-user"})
|
||||
s.mustCreateUser(&service.User{Email: "a-first@example.com", Username: "a-user"})
|
||||
@@ -119,4 +137,49 @@ func (s *UserRepoSuite) TestListWithFilters_SortByLastActiveAtAsc() {
|
||||
s.Require().Equal("nil-active@example.com", users[2].Email)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestGetLatestUsedAtByUserIDs_UsesUsageLogs() {
|
||||
older := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Second)
|
||||
newer := time.Now().Add(-90 * time.Minute).UTC().Truncate(time.Second)
|
||||
|
||||
userWithUsage := s.mustCreateUser(&service.User{Email: "usage-source@example.com"})
|
||||
userWithoutUsage := s.mustCreateUser(&service.User{Email: "usage-missing@example.com"})
|
||||
s.mustInsertUsageLog(userWithUsage.ID, older)
|
||||
s.mustInsertUsageLog(userWithUsage.ID, newer)
|
||||
|
||||
got, err := s.repo.GetLatestUsedAtByUserIDs(s.ctx, []int64{userWithUsage.ID, userWithoutUsage.ID})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Contains(got, userWithUsage.ID)
|
||||
s.Require().NotContains(got, userWithoutUsage.ID)
|
||||
s.Require().NotNil(got[userWithUsage.ID])
|
||||
s.Require().True(got[userWithUsage.ID].Equal(newer))
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestListWithFilters_SortByLastUsedAtDesc_UsesUsageLogsNotLastActiveAt() {
|
||||
lastUsedOlder := time.Now().Add(-6 * time.Hour).UTC().Truncate(time.Second)
|
||||
lastUsedNewer := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Second)
|
||||
lastActiveVeryRecent := time.Now().Add(-10 * time.Minute).UTC().Truncate(time.Second)
|
||||
|
||||
nilUsage := s.mustCreateUser(&service.User{Email: "nil-last-used@example.com"})
|
||||
wrongSource := s.mustCreateUser(&service.User{
|
||||
Email: "active-not-usage@example.com",
|
||||
LastActiveAt: &lastActiveVeryRecent,
|
||||
})
|
||||
rightSource := s.mustCreateUser(&service.User{Email: "usage-wins@example.com"})
|
||||
|
||||
s.mustInsertUsageLog(wrongSource.ID, lastUsedOlder)
|
||||
s.mustInsertUsageLog(rightSource.ID, lastUsedNewer)
|
||||
|
||||
users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
SortBy: "last_used_at",
|
||||
SortOrder: "desc",
|
||||
}, service.UserListFilters{})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Len(users, 3)
|
||||
s.Require().Equal(rightSource.ID, users[0].ID)
|
||||
s.Require().Equal(wrongSource.ID, users[1].ID)
|
||||
s.Require().Equal(nilUsage.ID, users[2].ID)
|
||||
}
|
||||
|
||||
func TestUserRepoSortSuiteSmoke(_ *testing.T) {}
|
||||
|
||||
Reference in New Issue
Block a user