diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index ed2f700..21a07e0 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -52,6 +52,19 @@ func (q *Queries) CountAllProjects(ctx context.Context) (int64, error) { return count, err } +const countDefinitionsByUser = `-- name: CountDefinitionsByUser :one +SELECT COUNT(*) +FROM definitions +WHERE "owner" = $1 +` + +func (q *Queries) CountDefinitionsByUser(ctx context.Context, owner string) (int64, error) { + row := q.db.QueryRow(ctx, countDefinitionsByUser, owner) + var count int64 + err := row.Scan(&count) + return count, err +} + const countEmbeddingsByProject = `-- name: CountEmbeddingsByProject :one SELECT COUNT(*) FROM embeddings @@ -73,6 +86,19 @@ func (q *Queries) CountEmbeddingsByProject(ctx context.Context, arg CountEmbeddi return count, err } +const countInstancesByDefinition = `-- name: CountInstancesByDefinition :one +SELECT COUNT(*) +FROM instances +WHERE "definition_id" = $1 +` + +func (q *Queries) CountInstancesByDefinition(ctx context.Context, definitionID pgtype.Int4) (int64, error) { + row := q.db.QueryRow(ctx, countInstancesByDefinition, definitionID) + var count int64 + err := row.Scan(&count) + return count, err +} + const countInstancesByUser = `-- name: CountInstancesByUser :one SELECT COUNT(*) FROM instances @@ -86,6 +112,45 @@ func (q *Queries) CountInstancesByUser(ctx context.Context, owner string) (int64 return count, err } +const countProjectsByUser = `-- name: CountProjectsByUser :one +SELECT COUNT(DISTINCT projects."project_id") +FROM projects +WHERE projects."owner" = $1 +` + +func (q *Queries) CountProjectsByUser(ctx context.Context, owner string) (int64, error) { + row := q.db.QueryRow(ctx, countProjectsByUser, owner) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countProjectsUsingInstance = `-- name: CountProjectsUsingInstance :one +SELECT COUNT(*) +FROM projects +WHERE "instance_id" = $1 +` + +func (q *Queries) CountProjectsUsingInstance(ctx context.Context, instanceID pgtype.Int4) (int64, error) { + row := q.db.QueryRow(ctx, countProjectsUsingInstance, instanceID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countSharedUsersForInstance = `-- name: CountSharedUsersForInstance :one +SELECT COUNT(*) +FROM instances_shared_with +WHERE "instance_id" = $1 +` + +func (q *Queries) CountSharedUsersForInstance(ctx context.Context, instanceID int32) (int64, error) { + row := q.db.QueryRow(ctx, countSharedUsersForInstance, instanceID) + var count int64 + err := row.Scan(&count) + return count, err +} + const deleteAPIStandard = `-- name: DeleteAPIStandard :exec DELETE FROM api_standards @@ -547,6 +612,71 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]str return items, nil } +const getAllUsersWithCounts = `-- name: GetAllUsersWithCounts :many +SELECT + users."user_handle", + users."name", + COALESCE(project_counts.count, 0) AS project_count, + COALESCE(definition_counts.count, 0) AS definition_count, + COALESCE(instance_counts.count, 0) AS instance_count +FROM users +LEFT JOIN ( + SELECT "owner", COUNT(DISTINCT "project_id") AS count + FROM projects + GROUP BY "owner" +) project_counts ON users."user_handle" = project_counts."owner" +LEFT JOIN ( + SELECT "owner", COUNT(*) AS count + FROM definitions + GROUP BY "owner" +) definition_counts ON users."user_handle" = definition_counts."owner" +LEFT JOIN ( + SELECT "owner", COUNT(*) AS count + FROM instances + GROUP BY "owner" +) instance_counts ON users."user_handle" = instance_counts."owner" +ORDER BY users."user_handle" ASC LIMIT $1 OFFSET $2 +` + +type GetAllUsersWithCountsParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetAllUsersWithCountsRow struct { + UserHandle string `db:"user_handle" json:"user_handle"` + Name pgtype.Text `db:"name" json:"name"` + ProjectCount int64 `db:"project_count" json:"project_count"` + DefinitionCount int64 `db:"definition_count" json:"definition_count"` + InstanceCount int64 `db:"instance_count" json:"instance_count"` +} + +func (q *Queries) GetAllUsersWithCounts(ctx context.Context, arg GetAllUsersWithCountsParams) ([]GetAllUsersWithCountsRow, error) { + rows, err := q.db.Query(ctx, getAllUsersWithCounts, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllUsersWithCountsRow + for rows.Next() { + var i GetAllUsersWithCountsRow + if err := rows.Scan( + &i.UserHandle, + &i.Name, + &i.ProjectCount, + &i.DefinitionCount, + &i.InstanceCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getDefinitionsByUser = `-- name: GetDefinitionsByUser :many SELECT definitions."definition_handle", definitions."definition_id" FROM definitions diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 6cc7e8b..f6aa9e3 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -48,6 +48,41 @@ SELECT "user_handle" FROM users ORDER BY "user_handle" ASC LIMIT $1 OFFSET $2; +-- name: GetAllUsersWithCounts :many +SELECT + users."user_handle", + users."name", + COALESCE(project_counts.count, 0) AS project_count, + COALESCE(definition_counts.count, 0) AS definition_count, + COALESCE(instance_counts.count, 0) AS instance_count +FROM users +LEFT JOIN ( + SELECT "owner", COUNT(DISTINCT "project_id") AS count + FROM projects + GROUP BY "owner" +) project_counts ON users."user_handle" = project_counts."owner" +LEFT JOIN ( + SELECT "owner", COUNT(*) AS count + FROM definitions + GROUP BY "owner" +) definition_counts ON users."user_handle" = definition_counts."owner" +LEFT JOIN ( + SELECT "owner", COUNT(*) AS count + FROM instances + GROUP BY "owner" +) instance_counts ON users."user_handle" = instance_counts."owner" +ORDER BY users."user_handle" ASC LIMIT $1 OFFSET $2; + +-- name: CountProjectsByUser :one +SELECT COUNT(DISTINCT projects."project_id") +FROM projects +WHERE projects."owner" = $1; + +-- name: CountDefinitionsByUser :one +SELECT COUNT(*) +FROM definitions +WHERE "owner" = $1; + -- name: GetUsersByProject :many SELECT users."user_handle", users_projects."role" FROM users JOIN users_projects @@ -186,6 +221,11 @@ ORDER BY "owner" ASC, "project_handle" ASC LIMIT $1 OFFSET $2; SELECT COUNT(*) FROM projects; +-- name: CountProjectsUsingInstance :one +SELECT COUNT(*) +FROM projects +WHERE "instance_id" = $1; + -- name: LinkProjectToUser :one INSERT INTO users_projects ( @@ -305,6 +345,11 @@ WHERE definitions."owner" = $1 ORDER BY definitions."owner" ASC, definitions."definition_handle" ASC LIMIT $2 OFFSET $3; +-- name: CountInstancesByDefinition :one +SELECT COUNT(*) +FROM instances +WHERE "definition_id" = $1; + -- === LLM Service Instances (user-specific instances with optional API keys) === @@ -564,6 +609,11 @@ SELECT COUNT(*) FROM instances WHERE "owner" = $1; +-- name: CountSharedUsersForInstance :one +SELECT COUNT(*) +FROM instances_shared_with +WHERE "instance_id" = $1; + -- === EMBEDDINGS === diff --git a/internal/handlers/llm_services.go b/internal/handlers/llm_services.go index 9ae88a6..d34fe25 100644 --- a/internal/handlers/llm_services.go +++ b/internal/handlers/llm_services.go @@ -227,11 +227,18 @@ func getUserDefinitionsFunc(ctx context.Context, input *models.GetUserDefinition // Build response ls := []models.DefinitionBrief{} for _, d := range def { + // Count instances using this definition + instanceCount, err := queries.CountInstancesByDefinition(ctx, pgtype.Int4{Int32: d.DefinitionID, Valid: true}) + if err != nil { + instanceCount = 0 + } + ls = append(ls, models.DefinitionBrief{ - Owner: d.Owner, - DefinitionHandle: d.DefinitionHandle, - DefinitionID: int(d.DefinitionID), - IsPublic: d.IsPublic, + Owner: d.Owner, + DefinitionHandle: d.DefinitionHandle, + DefinitionID: int(d.DefinitionID), + IsPublic: d.IsPublic, + NumberOfInstances: int(instanceCount), }) } response := &models.GetUserDefinitionsResponse{} @@ -765,10 +772,24 @@ func getUserInstancesFunc(ctx context.Context, input *models.GetUserInstancesReq // Build response (hide API keys for shared instances) ls := []models.InstanceBrief{} for _, llm := range llms { + // Count projects using this instance + projectCount, err := queries.CountProjectsUsingInstance(ctx, pgtype.Int4{Int32: llm.InstanceID, Valid: true}) + if err != nil { + projectCount = 0 + } + + // Count shared users for this instance + sharedUserCount, err := queries.CountSharedUsersForInstance(ctx, llm.InstanceID) + if err != nil { + sharedUserCount = 0 + } + ls = append(ls, models.InstanceBrief{ - Owner: llm.Owner, - InstanceHandle: llm.InstanceHandle, - InstanceID: int(llm.InstanceID), + Owner: llm.Owner, + InstanceHandle: llm.InstanceHandle, + InstanceID: int(llm.InstanceID), + NumberOfProjects: int(projectCount), + NumberOfSharedUsers: int(sharedUserCount), }) } response := &models.GetUserInstancesResponse{} diff --git a/internal/handlers/llm_services_test.go b/internal/handlers/llm_services_test.go index 884209e..edcfe6c 100644 --- a/internal/handlers/llm_services_test.go +++ b/internal/handlers/llm_services_test.go @@ -105,7 +105,7 @@ func TestInstancesFunc(t *testing.T) { requestPath: "/v1/llm-instances/alice", bodyPath: "", EmbAPIKey: options.AdminKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetUserInstancesResponseBody.json\",\n \"instances\": [\n {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1\n }\n ]\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetUserInstancesResponseBody.json\",\n \"instances\": [\n {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1,\n \"number_of_projects\": 0,\n \"number_of_shared_users\": 0\n }\n ]\n}\n", expectStatus: http.StatusOK, }, { @@ -114,7 +114,7 @@ func TestInstancesFunc(t *testing.T) { requestPath: "/v1/llm-instances/alice", bodyPath: "", EmbAPIKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetUserInstancesResponseBody.json\",\n \"instances\": [\n {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1\n }\n ]\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetUserInstancesResponseBody.json\",\n \"instances\": [\n {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1,\n \"number_of_projects\": 0,\n \"number_of_shared_users\": 0\n }\n ]\n}\n", expectStatus: http.StatusOK, }, { diff --git a/internal/handlers/pagination_test.go b/internal/handlers/pagination_test.go index dc4044b..56ad4bb 100644 --- a/internal/handlers/pagination_test.go +++ b/internal/handlers/pagination_test.go @@ -1,72 +1,80 @@ package handlers_test import ( -"encoding/json" -"fmt" -"io" -"net/http" -"testing" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" -"github.com/stretchr/testify/assert" + "github.com/stretchr/testify/assert" ) func TestPaginationForGetUsers(t *testing.T) { -// Get the database connection pool from package variable -pool := connPool + // Get the database connection pool from package variable + pool := connPool -// Create a mock key generator -mockKeyGen := new(MockKeyGen) -mockKeyGen.On("RandomKey", 32).Return("12345678901234567890123456789012", nil).Maybe() + // Create a mock key generator + mockKeyGen := new(MockKeyGen) + mockKeyGen.On("RandomKey", 32).Return("12345678901234567890123456789012", nil).Maybe() -// Start the server -err, shutDownServer := startTestServer(t, pool, mockKeyGen) -assert.NoError(t, err) -defer shutDownServer() + // Start the server + err, shutDownServer := startTestServer(t, pool, mockKeyGen) + assert.NoError(t, err) + defer shutDownServer() -fmt.Printf("\nRunning pagination tests ...\n\n") + fmt.Printf("\nRunning pagination tests ...\n\n") -// Test pagination: limit=1, offset=0 (should get first user) -req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/v1/users?limit=1&offset=0", options.Port), nil) -assert.NoError(t, err) -req.Header.Set("Authorization", "Bearer "+options.AdminKey) + // Test pagination: limit=1, offset=0 (should get first user) + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/v1/users?limit=1&offset=0", options.Port), nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+options.AdminKey) -client := &http.Client{} -resp, err := client.Do(req) -assert.NoError(t, err) -defer resp.Body.Close() + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() -assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, http.StatusOK, resp.StatusCode) -bodyBytes, err := io.ReadAll(resp.Body) -assert.NoError(t, err) + bodyBytes, err := io.ReadAll(resp.Body) + assert.NoError(t, err) -var userList []string -err = json.Unmarshal(bodyBytes, &userList) -assert.NoError(t, err) -assert.Equal(t, 1, len(userList), "Expected exactly 1 user with limit=1") + // Parse the response as UserBrief array (direct array, not wrapped) + type UserBrief struct { + UserHandle string `json:"user_handle"` + Name string `json:"name,omitempty"` + NumberOfProjects int `json:"number_of_projects"` + NumberOfDefinitions int `json:"number_of_definitions"` + NumberOfInstances int `json:"number_of_instances"` + } + var users []UserBrief + err = json.Unmarshal(bodyBytes, &users) + assert.NoError(t, err) + assert.Equal(t, 1, len(users), "Expected exactly 1 user with limit=1") -fmt.Printf("First page (limit=1, offset=0): %v\n", userList) + fmt.Printf("First page (limit=1, offset=0): %v\n", users) -// Test getting all users with high limit -req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/v1/users?limit=100", options.Port), nil) -assert.NoError(t, err) -req.Header.Set("Authorization", "Bearer "+options.AdminKey) + // Test getting all users with high limit + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/v1/users?limit=100", options.Port), nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+options.AdminKey) -resp3, err := client.Do(req) -assert.NoError(t, err) -defer resp3.Body.Close() + resp3, err := client.Do(req) + assert.NoError(t, err) + defer resp3.Body.Close() -assert.Equal(t, http.StatusOK, resp3.StatusCode) + assert.Equal(t, http.StatusOK, resp3.StatusCode) -bodyBytes, err = io.ReadAll(resp3.Body) -assert.NoError(t, err) + bodyBytes, err = io.ReadAll(resp3.Body) + assert.NoError(t, err) -err = json.Unmarshal(bodyBytes, &userList) -assert.NoError(t, err) -// We should have at least the _system user -assert.GreaterOrEqual(t, len(userList), 1, "Expected at least 1 user") + err = json.Unmarshal(bodyBytes, &users) + assert.NoError(t, err) + // We should have at least the _system user + assert.GreaterOrEqual(t, len(users), 1, "Expected at least 1 user") -fmt.Printf("All users (limit=100): %d users found\n", len(userList)) + fmt.Printf("All users (limit=100): %d users found\n", len(users)) -fmt.Printf("\nPagination tests completed successfully!\n\n") + fmt.Printf("\nPagination tests completed successfully!\n\n") } diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 3a65cb6..f42e85f 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -192,12 +192,23 @@ func getProjectsFunc(ctx context.Context, input *models.GetProjectsRequest) (*mo /* Build response array with brief output */ for _, p := range projectHandles { - projects = append(projects, models.ProjectBrief{ + // Get the count of embeddings for this project + count, err := queries.CountEmbeddingsByProject(ctx, database.CountEmbeddingsByProjectParams{ Owner: p.Owner, ProjectHandle: p.ProjectHandle, - ProjectID: int(p.ProjectID), - PublicRead: p.PublicRead.Bool, - Role: p.Role.(string), + }) + if err != nil { + // If there's an error counting, default to 0 + count = 0 + } + + projects = append(projects, models.ProjectBrief{ + Owner: p.Owner, + ProjectHandle: p.ProjectHandle, + ProjectID: int(p.ProjectID), + PublicRead: p.PublicRead.Bool, + Role: p.Role.(string), + NumberOfEmbeddings: int(count), }) } // Build the response @@ -323,11 +334,25 @@ func getProjectFunc(ctx context.Context, input *models.GetProjectRequest) (*mode } } } + // Count projects using this instance + projectCount, err := queries.CountProjectsUsingInstance(ctx, pgtype.Int4{Int32: llmRow.InstanceID, Valid: true}) + if err != nil { + projectCount = 0 + } + + // Count shared users for this instance + sharedUserCount, err := queries.CountSharedUsersForInstance(ctx, llmRow.InstanceID) + if err != nil { + sharedUserCount = 0 + } + instance = models.InstanceBrief{ - Owner: llmRow.Owner, - InstanceID: int(llmRow.InstanceID), - InstanceHandle: llmRow.InstanceHandle, - AccessRole: accessRole, + Owner: llmRow.Owner, + InstanceID: int(llmRow.InstanceID), + InstanceHandle: llmRow.InstanceHandle, + AccessRole: accessRole, + NumberOfProjects: int(projectCount), + NumberOfSharedUsers: int(sharedUserCount), } } diff --git a/internal/handlers/projects_creation_with_sharing_test.go b/internal/handlers/projects_creation_with_sharing_test.go index 2f29c87..762ee83 100644 --- a/internal/handlers/projects_creation_with_sharing_test.go +++ b/internal/handlers/projects_creation_with_sharing_test.go @@ -73,7 +73,7 @@ func TestProjectCreationWithSharingFunc(t *testing.T) { requestPath: "/v1/projects/alice/shared-project", bodyPath: "../../testdata/project_with_shared_user.json", apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectBrief.json\",\n \"owner\": \"alice\",\n \"project_handle\": \"shared-project\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\"\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectBrief.json\",\n \"owner\": \"alice\",\n \"project_handle\": \"shared-project\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\",\n \"number_of_embeddings\": 0\n}\n", expectStatus: http.StatusCreated, }, { diff --git a/internal/handlers/projects_test.go b/internal/handlers/projects_test.go index 434d246..8338b4c 100644 --- a/internal/handlers/projects_test.go +++ b/internal/handlers/projects_test.go @@ -83,7 +83,7 @@ func TestProjectsFunc(t *testing.T) { requestPath: "/v1/projects/alice/test1", bodyPath: "../../testdata/valid_project.json", apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectBrief.json\",\n \"owner\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\"\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectBrief.json\",\n \"owner\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\",\n \"number_of_embeddings\": 0\n}\n", expectStatus: http.StatusCreated, }, { @@ -110,7 +110,7 @@ func TestProjectsFunc(t *testing.T) { requestPath: "/v1/projects/alice", bodyPath: "../../testdata/valid_project.json", apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectBrief.json\",\n \"owner\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\"\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectBrief.json\",\n \"owner\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\",\n \"number_of_embeddings\": 0\n}\n", expectStatus: http.StatusCreated, }, { @@ -128,7 +128,7 @@ func TestProjectsFunc(t *testing.T) { requestPath: "/v1/projects/alice/test1", bodyPath: "", apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectFull.json\",\n \"project_id\": 1,\n \"project_handle\": \"test1\",\n \"owner\": \"alice\",\n \"description\": \"This is a test project\",\n \"public_read\": false,\n \"shared_with\": [\n {\n \"user_handle\": \"alice\",\n \"role\": \"owner\"\n }\n ],\n \"instance\": {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1,\n \"access_role\": \"owner\"\n },\n \"role\": \"owner\",\n \"number_of_embeddings\": 0\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectFull.json\",\n \"project_id\": 1,\n \"project_handle\": \"test1\",\n \"owner\": \"alice\",\n \"description\": \"This is a test project\",\n \"public_read\": false,\n \"shared_with\": [\n {\n \"user_handle\": \"alice\",\n \"role\": \"owner\"\n }\n ],\n \"instance\": {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1,\n \"access_role\": \"owner\",\n \"number_of_projects\": 1,\n \"number_of_shared_users\": 0\n },\n \"role\": \"owner\",\n \"number_of_embeddings\": 0\n}\n", expectStatus: http.StatusOK, }, { @@ -137,7 +137,7 @@ func TestProjectsFunc(t *testing.T) { requestPath: "/v1/projects/alice", bodyPath: "", apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjectsResponseBody.json\",\n \"projects\": [\n {\n \"owner\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\"\n }\n ]\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjectsResponseBody.json\",\n \"projects\": [\n {\n \"owner\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\",\n \"number_of_embeddings\": 0\n }\n ]\n}\n", expectStatus: http.StatusOK, }, { diff --git a/internal/handlers/public_access_test.go b/internal/handlers/public_access_test.go index 7202de4..694f97d 100644 --- a/internal/handlers/public_access_test.go +++ b/internal/handlers/public_access_test.go @@ -97,7 +97,7 @@ func TestPublicAccess(t *testing.T) { requestPath: "/v1/projects/alice/public-test", bodyPath: "", EmbAPIKey: "", - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectFull.json\",\n \"project_id\": 1,\n \"project_handle\": \"public-test\",\n \"owner\": \"alice\",\n \"description\": \"This is a test project\",\n \"public_read\": false,\n \"instance\": {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1\n },\n \"role\": \"owner\",\n \"number_of_embeddings\": 3\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectFull.json\",\n \"project_id\": 1,\n \"project_handle\": \"public-test\",\n \"owner\": \"alice\",\n \"description\": \"This is a test project\",\n \"public_read\": false,\n \"instance\": {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1,\n \"number_of_projects\": 1,\n \"number_of_shared_users\": 0\n },\n \"role\": \"owner\",\n \"number_of_embeddings\": 3\n}\n", expectStatus: http.StatusOK, }, { diff --git a/internal/handlers/users.go b/internal/handlers/users.go index 53f2044..1256611 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -119,9 +119,12 @@ func getUsersFunc(ctx context.Context, input *models.GetUsersRequest) (*models.G return nil, huma.Error500InternalServerError("database connection pool is nil") } - // Run the query + // Run the query to get all users with counts in a single query queries := database.New(pool) - allUsers, err := queries.GetAllUsers(ctx, database.GetAllUsersParams{Limit: int32(input.Limit), Offset: int32(input.Offset)}) + allUsersWithCounts, err := queries.GetAllUsersWithCounts(ctx, database.GetAllUsersWithCountsParams{ + Limit: int32(input.Limit), + Offset: int32(input.Offset), + }) if err != nil { if err.Error() == "no rows in result set" { return nil, huma.Error404NotFound("no users found.") @@ -129,13 +132,25 @@ func getUsersFunc(ctx context.Context, input *models.GetUsersRequest) (*models.G return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get list of users. %v", err)) } } - if len(allUsers) == 0 { + if len(allUsersWithCounts) == 0 { return nil, huma.Error404NotFound("no users found.") } + // Build the response with user briefs including counts + userBriefs := []models.UserBrief{} + for _, user := range allUsersWithCounts { + userBriefs = append(userBriefs, models.UserBrief{ + UserHandle: user.UserHandle, + Name: user.Name.String, + NumberOfProjects: int(user.ProjectCount), + NumberOfDefinitions: int(user.DefinitionCount), + NumberOfInstances: int(user.InstanceCount), + }) + } + // Build the response response := &models.GetUsersResponse{} - response.Body = allUsers + response.Body = userBriefs return response, nil } diff --git a/internal/handlers/users_test.go b/internal/handlers/users_test.go index 235edb6..af70be8 100644 --- a/internal/handlers/users_test.go +++ b/internal/handlers/users_test.go @@ -134,7 +134,7 @@ func TestUserFunc(t *testing.T) { requestPath: "/v1/users", bodyPath: "", apiKey: options.AdminKey, - expectBody: "[\n \"alice\",\n \"_system\"\n]\n", + expectBody: "[\n {\n \"user_handle\": \"alice\",\n \"name\": \"Alice Doe\",\n \"number_of_projects\": 0,\n \"number_of_definitions\": 0,\n \"number_of_instances\": 0\n },\n {\n \"user_handle\": \"_system\",\n \"name\": \"System User\",\n \"number_of_projects\": 0,\n \"number_of_definitions\": 4,\n \"number_of_instances\": 0\n }\n]\n", expectStatus: http.StatusOK, }, { diff --git a/internal/models/llm_services.go b/internal/models/llm_services.go index 12c62bb..2e2f003 100644 --- a/internal/models/llm_services.go +++ b/internal/models/llm_services.go @@ -46,10 +46,11 @@ type DefinitionFull struct { } type DefinitionBrief struct { - DefinitionID int `json:"definition_id" readOnly:"true" doc:"Unique LLM Service Definition identifier" example:"42"` - DefinitionHandle string `json:"definition_handle" minLength:"3" maxLength:"20" example:"openai-large" doc:"LLM Service Definition handle"` - Owner string `json:"owner" readOnly:"true" doc:"User handle of the LLM Service Definition owner (_system for global)" example:"_system"` - IsPublic bool `json:"is_public" doc:"Whether the definition is public (shared with all users)"` + DefinitionID int `json:"definition_id" readOnly:"true" doc:"Unique LLM Service Definition identifier" example:"42"` + DefinitionHandle string `json:"definition_handle" minLength:"3" maxLength:"20" example:"openai-large" doc:"LLM Service Definition handle"` + Owner string `json:"owner" readOnly:"true" doc:"User handle of the LLM Service Definition owner (_system for global)" example:"_system"` + IsPublic bool `json:"is_public" doc:"Whether the definition is public (shared with all users)"` + NumberOfInstances int `json:"number_of_instances" readOnly:"true" doc:"Number of instances using this definition"` } // Request and Response structs for the LLM Service Instance administration API @@ -217,10 +218,12 @@ type InstanceInput struct { } type InstanceBrief struct { - Owner string `json:"owner" readOnly:"true" doc:"User handle of the LLM Service Instance owner"` - InstanceHandle string `json:"instance_handle" minLength:"3" maxLength:"20" example:"my-openai-large" doc:"LLM Service Instance handle"` - InstanceID int `json:"instance_id" readOnly:"true" doc:"Unique LLM Service Instance identifier" example:"153"` - AccessRole string `json:"access_role,omitempty" readOnly:"true" doc:"Access role of the requesting user for this instance (owner, editor, reader)"` + Owner string `json:"owner" readOnly:"true" doc:"User handle of the LLM Service Instance owner"` + InstanceHandle string `json:"instance_handle" minLength:"3" maxLength:"20" example:"my-openai-large" doc:"LLM Service Instance handle"` + InstanceID int `json:"instance_id" readOnly:"true" doc:"Unique LLM Service Instance identifier" example:"153"` + AccessRole string `json:"access_role,omitempty" readOnly:"true" doc:"Access role of the requesting user for this instance (owner, editor, reader)"` + NumberOfProjects int `json:"number_of_projects" readOnly:"true" doc:"Number of projects using this instance"` + NumberOfSharedUsers int `json:"number_of_shared_users" readOnly:"true" doc:"Number of users this instance is shared with"` } // In Output, never return the API key diff --git a/internal/models/projects.go b/internal/models/projects.go index 1da46a4..9574041 100644 --- a/internal/models/projects.go +++ b/internal/models/projects.go @@ -17,11 +17,12 @@ type ProjectFull struct { } type ProjectBrief struct { - Owner string `json:"owner" readOnly:"true" doc:"User handle of the project owner"` - ProjectHandle string `json:"project_handle" minLength:"3" maxLength:"20" example:"my-gpt-4" doc:"Project handle"` - ProjectID int `json:"project_id" readOnly:"true" doc:"Unique project identifier"` - PublicRead bool `json:"public_read" doc:"Whether the project is public or not"` - Role string `json:"role,omitempty" doc:"Role of the requesting user in the project (can be owner or some other role)"` + Owner string `json:"owner" readOnly:"true" doc:"User handle of the project owner"` + ProjectHandle string `json:"project_handle" minLength:"3" maxLength:"20" example:"my-gpt-4" doc:"Project handle"` + ProjectID int `json:"project_id" readOnly:"true" doc:"Unique project identifier"` + PublicRead bool `json:"public_read" doc:"Whether the project is public or not"` + Role string `json:"role,omitempty" doc:"Role of the requesting user in the project (can be owner or some other role)"` + NumberOfEmbeddings int `json:"number_of_embeddings" readOnly:"true" doc:"Number of embeddings in the project"` } type ProjectSubmission struct { diff --git a/internal/models/users.go b/internal/models/users.go index 0853db8..344d7f2 100644 --- a/internal/models/users.go +++ b/internal/models/users.go @@ -36,6 +36,15 @@ type InstanceMembership struct { type InstanceMemberships []InstanceMembership +// UserBrief represents brief user information with counts for list responses +type UserBrief struct { + UserHandle string `json:"user_handle" doc:"User handle" maxLength:"20" minLength:"3" example:"jdoe"` + Name string `json:"name,omitempty" doc:"User name" maxLength:"50" example:"Jane Doe"` + NumberOfProjects int `json:"number_of_projects" readOnly:"true" doc:"Number of projects owned by the user"` + NumberOfDefinitions int `json:"number_of_definitions" readOnly:"true" doc:"Number of LLM Service Definitions created by the user"` + NumberOfInstances int `json:"number_of_instances" readOnly:"true" doc:"Number of LLM Service Instances owned by the user"` +} + // Request and Response structs for the user administration API // The request structs must be structs with fields for the request path/query/header/cookie parameters and/or body. // The response structs must be structs with fields for the output headers and body of the operation, if any. @@ -74,7 +83,7 @@ type GetUsersRequest struct { type GetUsersResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` - Body []string `json:"handles" doc:"Handles of all registered user accounts"` + Body []UserBrief `json:"users" doc:"Brief information about all registered user accounts"` } // Get single user information