diff --git a/packages/api/internal/handlers/accesstoken.go b/packages/api/internal/handlers/accesstoken.go index 21ef2053fd..e0651cf73f 100644 --- a/packages/api/internal/handlers/accesstoken.go +++ b/packages/api/internal/handlers/accesstoken.go @@ -10,9 +10,9 @@ import ( "github.com/google/uuid" "github.com/e2b-dev/infra/packages/api/internal/api" - "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/keys" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -22,7 +22,7 @@ func (a *APIStore) PostAccessTokens(c *gin.Context) { userID := auth.MustGetUserID(c) - body, err := utils.ParseBody[api.NewAccessToken](ctx, c) + body, err := ginutils.ParseBody[api.NewAccessToken](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/api/internal/handlers/admin.go b/packages/api/internal/handlers/admin.go index 4a4f498cc4..71937e12a0 100644 --- a/packages/api/internal/handlers/admin.go +++ b/packages/api/internal/handlers/admin.go @@ -9,8 +9,8 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/orchestrator" - "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/shared/pkg/clusters" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -48,7 +48,7 @@ func (a *APIStore) GetNodesNodeID(c *gin.Context, nodeID api.NodeID, params api. func (a *APIStore) PostNodesNodeID(c *gin.Context, nodeId api.NodeID) { ctx := c.Request.Context() - body, err := utils.ParseBody[api.PostNodesNodeIDJSONRequestBody](ctx, c) + body, err := ginutils.ParseBody[api.PostNodesNodeIDJSONRequestBody](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/api/internal/handlers/apikey.go b/packages/api/internal/handlers/apikey.go index e74d345dc7..73ef462b85 100644 --- a/packages/api/internal/handlers/apikey.go +++ b/packages/api/internal/handlers/apikey.go @@ -13,9 +13,9 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/team" - "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -23,7 +23,7 @@ import ( func (a *APIStore) PatchApiKeysApiKeyID(c *gin.Context, apiKeyID string) { ctx := c.Request.Context() - body, err := utils.ParseBody[api.UpdateTeamAPIKey](ctx, c) + body, err := ginutils.ParseBody[api.UpdateTeamAPIKey](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) @@ -145,7 +145,7 @@ func (a *APIStore) PostApiKeys(c *gin.Context) { userID := auth.MustGetUserID(c) teamID := auth.MustGetTeamInfo(c).Team.ID - body, err := utils.ParseBody[api.NewTeamAPIKey](ctx, c) + body, err := ginutils.ParseBody[api.NewTeamAPIKey](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/api/internal/handlers/deprecated_template_request_build.go b/packages/api/internal/handlers/deprecated_template_request_build.go index e253355ad5..84c4415467 100644 --- a/packages/api/internal/handlers/deprecated_template_request_build.go +++ b/packages/api/internal/handlers/deprecated_template_request_build.go @@ -11,12 +11,12 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/template" - "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/auth/pkg/types" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/shared/pkg/clusters" "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" "github.com/e2b-dev/infra/packages/shared/pkg/templates" @@ -28,7 +28,7 @@ func (a *APIStore) PostTemplates(c *gin.Context) { userID := auth.MustGetUserID(c) - body, err := utils.ParseBody[api.TemplateBuildRequest](ctx, c) + body, err := ginutils.ParseBody[api.TemplateBuildRequest](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %s", err)) telemetry.ReportCriticalError(ctx, "invalid request body", err) @@ -80,7 +80,7 @@ func (a *APIStore) PostTemplatesTemplateID(c *gin.Context, rawTemplateID api.Tem userID := auth.MustGetUserID(c) - body, err := utils.ParseBody[api.TemplateBuildRequest](ctx, c) + body, err := ginutils.ParseBody[api.TemplateBuildRequest](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %s", err)) telemetry.ReportCriticalError(ctx, "invalid request body", err) diff --git a/packages/api/internal/handlers/deprecated_template_request_build_v2.go b/packages/api/internal/handlers/deprecated_template_request_build_v2.go index 0a7ba4786c..4f8cd2acc2 100644 --- a/packages/api/internal/handlers/deprecated_template_request_build_v2.go +++ b/packages/api/internal/handlers/deprecated_template_request_build_v2.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/e2b-dev/infra/packages/api/internal/api" - apiutils "github.com/e2b-dev/infra/packages/api/internal/utils" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -15,7 +15,7 @@ import ( func (a *APIStore) PostV2Templates(c *gin.Context) { ctx := c.Request.Context() - body, err := apiutils.ParseBody[api.TemplateBuildRequestV2](ctx, c) + body, err := ginutils.ParseBody[api.TemplateBuildRequestV2](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %s", err)) telemetry.ReportCriticalError(ctx, "invalid request body", err) diff --git a/packages/api/internal/handlers/sandbox_connect.go b/packages/api/internal/handlers/sandbox_connect.go index 652c1bd6c2..efaca5d6a7 100644 --- a/packages/api/internal/handlers/sandbox_connect.go +++ b/packages/api/internal/handlers/sandbox_connect.go @@ -16,6 +16,7 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/db/pkg/types" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/logger" sbxlogger "github.com/e2b-dev/infra/packages/shared/pkg/logger/sandbox" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" @@ -33,7 +34,7 @@ func (a *APIStore) PostSandboxesSandboxIDConnect(c *gin.Context, sandboxID api.S telemetry.ReportEvent(ctx, "Parsed body") - body, err := utils.ParseBody[api.PostSandboxesSandboxIDConnectJSONRequestBody](ctx, c) + body, err := ginutils.ParseBody[api.PostSandboxesSandboxIDConnectJSONRequestBody](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/api/internal/handlers/sandbox_create.go b/packages/api/internal/handlers/sandbox_create.go index 0ce7f9f684..de9c8bc54d 100644 --- a/packages/api/internal/handlers/sandbox_create.go +++ b/packages/api/internal/handlers/sandbox_create.go @@ -22,13 +22,13 @@ import ( templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" "github.com/e2b-dev/infra/packages/api/internal/middleware/otel/metrics" "github.com/e2b-dev/infra/packages/api/internal/sandbox" - "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" sqlcdb "github.com/e2b-dev/infra/packages/db/client" "github.com/e2b-dev/infra/packages/db/pkg/types" "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/clusters" "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/grpc/orchestrator" "github.com/e2b-dev/infra/packages/shared/pkg/id" sbxlogger "github.com/e2b-dev/infra/packages/shared/pkg/logger/sandbox" @@ -58,7 +58,7 @@ func (a *APIStore) PostSandboxes(c *gin.Context) { traceID := span.SpanContext().TraceID().String() c.Set("traceID", traceID) - body, err := utils.ParseBody[api.PostSandboxesJSONRequestBody](ctx, c) + body, err := ginutils.ParseBody[api.PostSandboxesJSONRequestBody](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/api/internal/handlers/sandbox_network_update.go b/packages/api/internal/handlers/sandbox_network_update.go index f682a3ff2b..936cb3dc06 100644 --- a/packages/api/internal/handlers/sandbox_network_update.go +++ b/packages/api/internal/handlers/sandbox_network_update.go @@ -9,6 +9,7 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -28,7 +29,7 @@ func (a *APIStore) PutSandboxesSandboxIDNetwork( team := auth.MustGetTeamInfo(c) - body, err := utils.ParseBody[api.PutSandboxesSandboxIDNetworkJSONBody](ctx, c) + body, err := ginutils.ParseBody[api.PutSandboxesSandboxIDNetworkJSONBody](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) telemetry.ReportCriticalError(ctx, "error when parsing request", err) diff --git a/packages/api/internal/handlers/sandbox_refresh.go b/packages/api/internal/handlers/sandbox_refresh.go index 5329cbe74d..40dab91a00 100644 --- a/packages/api/internal/handlers/sandbox_refresh.go +++ b/packages/api/internal/handlers/sandbox_refresh.go @@ -11,6 +11,7 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/sandbox" "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -31,7 +32,7 @@ func (a *APIStore) PostSandboxesSandboxIDRefreshes( team := auth.MustGetTeamInfo(c) var duration time.Duration - body, err := utils.ParseBody[api.PostSandboxesSandboxIDRefreshesJSONBody](ctx, c) + body, err := ginutils.ParseBody[api.PostSandboxesSandboxIDRefreshesJSONBody](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/api/internal/handlers/sandbox_resume.go b/packages/api/internal/handlers/sandbox_resume.go index bb018462bf..ef1d627ae8 100644 --- a/packages/api/internal/handlers/sandbox_resume.go +++ b/packages/api/internal/handlers/sandbox_resume.go @@ -16,6 +16,7 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/db/pkg/types" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/grpc/orchestrator" "github.com/e2b-dev/infra/packages/shared/pkg/logger" sbxlogger "github.com/e2b-dev/infra/packages/shared/pkg/logger/sandbox" @@ -34,7 +35,7 @@ func (a *APIStore) PostSandboxesSandboxIDResume(c *gin.Context, sandboxID api.Sa telemetry.ReportEvent(ctx, "Parsed body") - body, err := utils.ParseBody[api.PostSandboxesSandboxIDResumeJSONRequestBody](ctx, c) + body, err := ginutils.ParseBody[api.PostSandboxesSandboxIDResumeJSONRequestBody](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/api/internal/handlers/sandbox_timeout.go b/packages/api/internal/handlers/sandbox_timeout.go index 7b0f8655bf..be73f5adc0 100644 --- a/packages/api/internal/handlers/sandbox_timeout.go +++ b/packages/api/internal/handlers/sandbox_timeout.go @@ -10,6 +10,7 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -31,7 +32,7 @@ func (a *APIStore) PostSandboxesSandboxIDTimeout( var duration time.Duration - body, err := utils.ParseBody[api.PostSandboxesSandboxIDTimeoutJSONBody](ctx, c) + body, err := ginutils.ParseBody[api.PostSandboxesSandboxIDTimeoutJSONBody](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/api/internal/handlers/snapshot_template_create.go b/packages/api/internal/handlers/snapshot_template_create.go index d19d04e3e3..e74dc1c7b9 100644 --- a/packages/api/internal/handlers/snapshot_template_create.go +++ b/packages/api/internal/handlers/snapshot_template_create.go @@ -15,6 +15,7 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/sandbox" "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" @@ -44,7 +45,7 @@ func (a *APIStore) PostSandboxesSandboxIDSnapshots(c *gin.Context, sandboxID api return } - body, err := utils.ParseBody[api.PostSandboxesSandboxIDSnapshotsJSONRequestBody](ctx, c) + body, err := ginutils.ParseBody[api.PostSandboxesSandboxIDSnapshotsJSONRequestBody](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/api/internal/handlers/template_request_build_v3.go b/packages/api/internal/handlers/template_request_build_v3.go index bea2cf68bc..755c8bb077 100644 --- a/packages/api/internal/handlers/template_request_build_v3.go +++ b/packages/api/internal/handlers/template_request_build_v3.go @@ -11,9 +11,9 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" "github.com/e2b-dev/infra/packages/api/internal/template" - apiutils "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/shared/pkg/clusters" "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" "github.com/e2b-dev/infra/packages/shared/pkg/templates" @@ -24,7 +24,7 @@ import ( func (a *APIStore) PostV3Templates(c *gin.Context) { ctx := c.Request.Context() - body, err := apiutils.ParseBody[api.TemplateBuildRequestV3](ctx, c) + body, err := ginutils.ParseBody[api.TemplateBuildRequestV3](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %s", err)) telemetry.ReportCriticalError(ctx, "invalid request body", err) diff --git a/packages/api/internal/handlers/template_start_build_v2.go b/packages/api/internal/handlers/template_start_build_v2.go index 8e8e4bfac7..3871063632 100644 --- a/packages/api/internal/handlers/template_start_build_v2.go +++ b/packages/api/internal/handlers/template_start_build_v2.go @@ -14,11 +14,11 @@ import ( "go.uber.org/zap" "github.com/e2b-dev/infra/packages/api/internal/api" - apiutils "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/db/pkg/types" "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/clusters" "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" "github.com/e2b-dev/infra/packages/shared/pkg/templates" @@ -40,7 +40,7 @@ type dockerfileStore struct { func (a *APIStore) PostV2TemplatesTemplateIDBuildsBuildID(c *gin.Context, templateID api.TemplateID, buildID api.BuildID) { ctx := c.Request.Context() - body, err := apiutils.ParseBody[api.TemplateBuildStartV2](ctx, c) + body, err := ginutils.ParseBody[api.TemplateBuildStartV2](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %s", err)) telemetry.ReportCriticalError(ctx, "invalid request body", err) diff --git a/packages/api/internal/handlers/template_tags.go b/packages/api/internal/handlers/template_tags.go index c4f3776479..7d0f56a986 100644 --- a/packages/api/internal/handlers/template_tags.go +++ b/packages/api/internal/handlers/template_tags.go @@ -11,9 +11,9 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" - "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/db/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" @@ -24,7 +24,7 @@ import ( func (a *APIStore) PostTemplatesTags(c *gin.Context) { ctx := c.Request.Context() - body, err := utils.ParseBody[api.AssignTemplateTagsRequest](ctx, c) + body, err := ginutils.ParseBody[api.AssignTemplateTagsRequest](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %s", err)) @@ -179,7 +179,7 @@ func (a *APIStore) PostTemplatesTags(c *gin.Context) { func (a *APIStore) DeleteTemplatesTags(c *gin.Context) { ctx := c.Request.Context() - body, err := utils.ParseBody[api.DeleteTemplateTagsRequest](ctx, c) + body, err := ginutils.ParseBody[api.DeleteTemplateTagsRequest](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %s", err)) diff --git a/packages/api/internal/handlers/template_update.go b/packages/api/internal/handlers/template_update.go index 39cac67531..de3c2e9063 100644 --- a/packages/api/internal/handlers/template_update.go +++ b/packages/api/internal/handlers/template_update.go @@ -11,10 +11,10 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" - "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/types" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/db/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" @@ -67,7 +67,7 @@ func (a *APIStore) PatchV2TemplatesTemplateID(c *gin.Context, aliasOrTemplateID // updateTemplate contains the shared logic for updating a template. // Returns the resolved team and aliasInfo on success, or an APIError on failure. func (a *APIStore) updateTemplate(ctx context.Context, c *gin.Context, aliasOrTemplateID api.TemplateID, createBackwardCompatAlias bool) (*types.Team, *templatecache.AliasInfo, *api.APIError) { - body, err := utils.ParseBody[api.TemplateUpdateRequest](ctx, c) + body, err := ginutils.ParseBody[api.TemplateUpdateRequest](ctx, c) if err != nil { return nil, nil, &api.APIError{ Code: http.StatusBadRequest, diff --git a/packages/api/internal/handlers/volume_create.go b/packages/api/internal/handlers/volume_create.go index c61e3638ef..776e493ed9 100644 --- a/packages/api/internal/handlers/volume_create.go +++ b/packages/api/internal/handlers/volume_create.go @@ -12,11 +12,11 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/clusters" - "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/db/queries" clustershared "github.com/e2b-dev/infra/packages/shared/pkg/clusters" "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/grpc/orchestrator" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -44,7 +44,7 @@ func (a *APIStore) PostVolumes(c *gin.Context) { } // parse body - body, err := utils.ParseBody[api.PostVolumesJSONRequestBody](ctx, c) + body, err := ginutils.ParseBody[api.PostVolumesJSONRequestBody](ctx, c) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) diff --git a/packages/dashboard-api/go.mod b/packages/dashboard-api/go.mod index e146465003..cd69e6352b 100644 --- a/packages/dashboard-api/go.mod +++ b/packages/dashboard-api/go.mod @@ -119,6 +119,7 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/testcontainers/testcontainers-go v0.40.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.39.0 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/packages/dashboard-api/go.sum b/packages/dashboard-api/go.sum index 191e7a9ec6..18fd96aa91 100644 --- a/packages/dashboard-api/go.sum +++ b/packages/dashboard-api/go.sum @@ -178,6 +178,8 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -271,6 +273,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/postgres v0.39.0 h1:REJz+XwNpGC/dCgTfYvM4SKqobNqDBfvhq74s2oHTUM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.39.0/go.mod h1:4K2OhtHEeT+JSIFX4V8DkGKsyLa96Y2vLdd3xsxD5HE= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index d1a4570b11..5426f426e7 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -32,6 +32,11 @@ const ( Success BuildStatus = "success" ) +// AddTeamMemberRequest defines model for AddTeamMemberRequest. +type AddTeamMemberRequest struct { + Email openapi_types.Email `json:"email"` +} + // BuildInfo defines model for BuildInfo. type BuildInfo struct { // CreatedAt Build creation timestamp in RFC3339 format. @@ -85,6 +90,32 @@ type BuildsStatusesResponse struct { // CPUCount CPU cores for the sandbox type CPUCount = int64 +// DefaultTemplate defines model for DefaultTemplate. +type DefaultTemplate struct { + Aliases []DefaultTemplateAlias `json:"aliases"` + BuildCount int32 `json:"buildCount"` + BuildId openapi_types.UUID `json:"buildId"` + CreatedAt time.Time `json:"createdAt"` + EnvdVersion *string `json:"envdVersion"` + Id string `json:"id"` + Public bool `json:"public"` + RamMb int64 `json:"ramMb"` + SpawnCount int64 `json:"spawnCount"` + TotalDiskSizeMb *int64 `json:"totalDiskSizeMb"` + Vcpu int64 `json:"vcpu"` +} + +// DefaultTemplateAlias defines model for DefaultTemplateAlias. +type DefaultTemplateAlias struct { + Alias string `json:"alias"` + Namespace *string `json:"namespace"` +} + +// DefaultTemplatesResponse defines model for DefaultTemplatesResponse. +type DefaultTemplatesResponse struct { + Templates []DefaultTemplate `json:"templates"` +} + // DiskSizeMB Disk size for the sandbox in MiB type DiskSizeMB = int64 @@ -160,6 +191,69 @@ type SandboxRecord struct { TemplateID string `json:"templateID"` } +// TeamMember defines model for TeamMember. +type TeamMember struct { + AddedBy *openapi_types.UUID `json:"addedBy"` + CreatedAt *time.Time `json:"createdAt"` + Email string `json:"email"` + Id openapi_types.UUID `json:"id"` + IsDefault bool `json:"isDefault"` +} + +// TeamMembersResponse defines model for TeamMembersResponse. +type TeamMembersResponse struct { + Members []TeamMember `json:"members"` +} + +// TeamResolveResponse defines model for TeamResolveResponse. +type TeamResolveResponse struct { + Id openapi_types.UUID `json:"id"` + Slug string `json:"slug"` +} + +// UpdateTeamRequest defines model for UpdateTeamRequest. +type UpdateTeamRequest struct { + Name *string `json:"name,omitempty"` + ProfilePictureUrl *string `json:"profilePictureUrl"` +} + +// UpdateTeamResponse defines model for UpdateTeamResponse. +type UpdateTeamResponse struct { + Id openapi_types.UUID `json:"id"` + Name string `json:"name"` + ProfilePictureUrl *string `json:"profilePictureUrl"` +} + +// UserTeam defines model for UserTeam. +type UserTeam struct { + BlockedReason *string `json:"blockedReason"` + Email string `json:"email"` + Id openapi_types.UUID `json:"id"` + IsBanned bool `json:"isBanned"` + IsBlocked bool `json:"isBlocked"` + IsDefault bool `json:"isDefault"` + Limits UserTeamLimits `json:"limits"` + Name string `json:"name"` + ProfilePictureUrl *string `json:"profilePictureUrl"` + Slug string `json:"slug"` + Tier string `json:"tier"` +} + +// UserTeamLimits defines model for UserTeamLimits. +type UserTeamLimits struct { + ConcurrentSandboxes int32 `json:"concurrentSandboxes"` + ConcurrentTemplateBuilds int32 `json:"concurrentTemplateBuilds"` + DiskMb int32 `json:"diskMb"` + MaxLengthHours int64 `json:"maxLengthHours"` + MaxRamMb int32 `json:"maxRamMb"` + MaxVcpu int32 `json:"maxVcpu"` +} + +// UserTeamsResponse defines model for UserTeamsResponse. +type UserTeamsResponse struct { + Teams []UserTeam `json:"teams"` +} + // BuildId defines model for build_id. type BuildId = openapi_types.UUID @@ -181,6 +275,15 @@ type BuildsLimit = int32 // SandboxID defines model for sandboxID. type SandboxID = string +// TeamID defines model for teamID. +type TeamID = openapi_types.UUID + +// TeamSlug defines model for teamSlug. +type TeamSlug = string + +// UserId defines model for userId. +type UserId = openapi_types.UUID + // N400 defines model for 400. type N400 = Error @@ -217,6 +320,18 @@ type GetBuildsStatusesParams struct { BuildIds BuildIds `form:"build_ids" json:"build_ids"` } +// GetTeamsResolveParams defines parameters for GetTeamsResolve. +type GetTeamsResolveParams struct { + // Slug Team slug to resolve. + Slug TeamSlug `form:"slug" json:"slug"` +} + +// PatchTeamsTeamIDJSONRequestBody defines body for PatchTeamsTeamID for application/json ContentType. +type PatchTeamsTeamIDJSONRequestBody = UpdateTeamRequest + +// PostTeamsTeamIDMembersJSONRequestBody defines body for PostTeamsTeamIDMembers for application/json ContentType. +type PostTeamsTeamIDMembersJSONRequestBody = AddTeamMemberRequest + // ServerInterface represents all server handlers. type ServerInterface interface { // List team builds @@ -234,6 +349,27 @@ type ServerInterface interface { // Get sandbox record // (GET /sandboxes/{sandboxID}/record) GetSandboxesSandboxIDRecord(c *gin.Context, sandboxID SandboxID) + // List user teams + // (GET /teams) + GetTeams(c *gin.Context) + // Resolve team identity + // (GET /teams/resolve) + GetTeamsResolve(c *gin.Context, params GetTeamsResolveParams) + // Update team + // (PATCH /teams/{teamID}) + PatchTeamsTeamID(c *gin.Context, teamID TeamID) + // List team members + // (GET /teams/{teamID}/members) + GetTeamsTeamIDMembers(c *gin.Context, teamID TeamID) + // Add team member + // (POST /teams/{teamID}/members) + PostTeamsTeamIDMembers(c *gin.Context, teamID TeamID) + // Remove team member + // (DELETE /teams/{teamID}/members/{userId}) + DeleteTeamsTeamIDMembersUserId(c *gin.Context, teamID TeamID, userId UserId) + // List default templates + // (GET /templates/defaults) + GetTemplatesDefaults(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -405,6 +541,192 @@ func (siw *ServerInterfaceWrapper) GetSandboxesSandboxIDRecord(c *gin.Context) { siw.Handler.GetSandboxesSandboxIDRecord(c, sandboxID) } +// GetTeams operation middleware +func (siw *ServerInterfaceWrapper) GetTeams(c *gin.Context) { + + c.Set(Supabase1TokenAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTeams(c) +} + +// GetTeamsResolve operation middleware +func (siw *ServerInterfaceWrapper) GetTeamsResolve(c *gin.Context) { + + var err error + + c.Set(Supabase1TokenAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetTeamsResolveParams + + // ------------- Required query parameter "slug" ------------- + + if paramValue := c.Query("slug"); paramValue != "" { + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument slug is required, but not found"), http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "slug", c.Request.URL.Query(), ¶ms.Slug) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter slug: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTeamsResolve(c, params) +} + +// PatchTeamsTeamID operation middleware +func (siw *ServerInterfaceWrapper) PatchTeamsTeamID(c *gin.Context) { + + var err error + + // ------------- Path parameter "teamID" ------------- + var teamID TeamID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + c.Set(Supabase1TokenAuthScopes, []string{}) + + c.Set(Supabase2TeamAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchTeamsTeamID(c, teamID) +} + +// GetTeamsTeamIDMembers operation middleware +func (siw *ServerInterfaceWrapper) GetTeamsTeamIDMembers(c *gin.Context) { + + var err error + + // ------------- Path parameter "teamID" ------------- + var teamID TeamID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + c.Set(Supabase1TokenAuthScopes, []string{}) + + c.Set(Supabase2TeamAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTeamsTeamIDMembers(c, teamID) +} + +// PostTeamsTeamIDMembers operation middleware +func (siw *ServerInterfaceWrapper) PostTeamsTeamIDMembers(c *gin.Context) { + + var err error + + // ------------- Path parameter "teamID" ------------- + var teamID TeamID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + c.Set(Supabase1TokenAuthScopes, []string{}) + + c.Set(Supabase2TeamAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostTeamsTeamIDMembers(c, teamID) +} + +// DeleteTeamsTeamIDMembersUserId operation middleware +func (siw *ServerInterfaceWrapper) DeleteTeamsTeamIDMembersUserId(c *gin.Context) { + + var err error + + // ------------- Path parameter "teamID" ------------- + var teamID TeamID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "userId" ------------- + var userId UserId + + err = runtime.BindStyledParameterWithOptions("simple", "userId", c.Param("userId"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter userId: %w", err), http.StatusBadRequest) + return + } + + c.Set(Supabase1TokenAuthScopes, []string{}) + + c.Set(Supabase2TeamAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteTeamsTeamIDMembersUserId(c, teamID, userId) +} + +// GetTemplatesDefaults operation middleware +func (siw *ServerInterfaceWrapper) GetTemplatesDefaults(c *gin.Context) { + + c.Set(Supabase1TokenAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTemplatesDefaults(c) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -437,42 +759,66 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/builds/:build_id", wrapper.GetBuildsBuildId) router.GET(options.BaseURL+"/health", wrapper.GetHealth) router.GET(options.BaseURL+"/sandboxes/:sandboxID/record", wrapper.GetSandboxesSandboxIDRecord) + router.GET(options.BaseURL+"/teams", wrapper.GetTeams) + router.GET(options.BaseURL+"/teams/resolve", wrapper.GetTeamsResolve) + router.PATCH(options.BaseURL+"/teams/:teamID", wrapper.PatchTeamsTeamID) + router.GET(options.BaseURL+"/teams/:teamID/members", wrapper.GetTeamsTeamIDMembers) + router.POST(options.BaseURL+"/teams/:teamID/members", wrapper.PostTeamsTeamIDMembers) + router.DELETE(options.BaseURL+"/teams/:teamID/members/:userId", wrapper.DeleteTeamsTeamIDMembersUserId) + router.GET(options.BaseURL+"/templates/defaults", wrapper.GetTemplatesDefaults) } // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "IwAA", + "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", + "ANV44NZdX6//GwAA//86rG96N0IAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/packages/dashboard-api/internal/handlers/sandbox_record_test.go b/packages/dashboard-api/internal/handlers/sandbox_record_test.go index 838795311e..bfe646cf2b 100644 --- a/packages/dashboard-api/internal/handlers/sandbox_record_test.go +++ b/packages/dashboard-api/internal/handlers/sandbox_record_test.go @@ -46,7 +46,7 @@ func TestGetSandboxesSandboxIDRecordReturns404WhenRecordRetentionNotMet(t *testi recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest(http.MethodGet, "/sandboxes/sbx_1/record", nil) + ctx.Request = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/sandboxes/sbx_1/record", nil) teamID := uuid.New() auth.SetTeamInfo(ctx, &authtypes.Team{ diff --git a/packages/dashboard-api/internal/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go new file mode 100644 index 0000000000..e4b4bd3366 --- /dev/null +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -0,0 +1,285 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth" + authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types" + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" + "github.com/e2b-dev/infra/packages/db/queries" +) + +func TestParseUpdateTeamBody_ProfilePictureNullClearsValue(t *testing.T) { + t.Parallel() + + body, err := parseUpdateTeamBody(strings.NewReader(`{"profilePictureUrl":null}`)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !body.ProfilePictureUrlSet { + t.Fatalf("expected profilePictureUrl to be marked as set") + } + if body.ProfilePictureUrl != nil { + t.Fatalf("expected nil profilePictureUrl for explicit null") + } +} + +func TestParseUpdateTeamBody_ProfilePictureOmittedIsNoop(t *testing.T) { + t.Parallel() + + body, err := parseUpdateTeamBody(strings.NewReader(`{"name":"team-a"}`)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if body.ProfilePictureUrlSet { + t.Fatalf("expected profilePictureUrl to be unset when omitted") + } +} + +func TestParseUpdateTeamBody_NameNullRejected(t *testing.T) { + t.Parallel() + + _, err := parseUpdateTeamBody(strings.NewReader(`{"name":null}`)) + if err == nil { + t.Fatalf("expected error for null name") + } +} + +func TestRequireAuthedTeamMatchesPath_Success(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + + teamID := uuid.New() + auth.SetTeamInfo(ctx, &authtypes.Team{ + Team: &authqueries.Team{ID: teamID}, + }) + + store := &APIStore{} + _, ok := store.requireAuthedTeamMatchesPath(ctx, teamID) + if !ok { + t.Fatalf("expected team parity check to pass") + } +} + +func TestRequireAuthedTeamMatchesPath_Mismatch(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + + auth.SetTeamInfo(ctx, &authtypes.Team{ + Team: &authqueries.Team{ID: uuid.New()}, + }) + + store := &APIStore{} + _, ok := store.requireAuthedTeamMatchesPath(ctx, uuid.New()) + if ok { + t.Fatalf("expected team parity check to fail") + } + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", recorder.Code) + } +} + +func TestPostTeamsTeamIDMembers_DuplicateMemberReturnsBadRequest(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, testDB) + targetUserID := createHandlerTestUser(t, testDB) + addedByUserID := createHandlerTestUser(t, testDB) + + insertHandlerTestTeamMember(t, testDB, targetUserID, teamID, false) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + body := `{"email":"` + handlerTestUserEmail(targetUserID) + `"}` + request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + ginCtx.Request = request + + auth.SetUserID(ginCtx, addedByUserID) + auth.SetTeamInfo(ginCtx, &authtypes.Team{ + Team: &authqueries.Team{ID: teamID}, + }) + + store := &APIStore{db: testDB.SqlcClient} + store.PostTeamsTeamIDMembers(ginCtx, teamID) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "User is already a member of this team") { + t.Fatalf("unexpected response body: %s", recorder.Body.String()) + } +} + +func TestDeleteTeamsTeamIDMembersUserId_NonMemberReturnsBadRequest(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, testDB) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodDelete, "/", nil) + auth.SetTeamInfo(ginCtx, &authtypes.Team{ + Team: &authqueries.Team{ID: teamID}, + }) + + store := &APIStore{db: testDB.SqlcClient} + store.DeleteTeamsTeamIDMembersUserId(ginCtx, teamID, uuid.New()) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "User is not a member of this team") { + t.Fatalf("unexpected response body: %s", recorder.Body.String()) + } +} + +func TestDeleteTeamsTeamIDMembersUserId_RechecksDefaultAfterLock(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, testDB) + targetUserID := createHandlerTestUser(t, testDB) + otherUserID := createHandlerTestUser(t, testDB) + + insertHandlerTestTeamMember(t, testDB, targetUserID, teamID, false) + insertHandlerTestTeamMember(t, testDB, otherUserID, teamID, true) + + _, tx, err := testDB.SqlcClient.WithTx(ctx) + if err != nil { + t.Fatalf("failed to start locking transaction: %v", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + var lockedUserID uuid.UUID + err = tx.QueryRow( + ctx, + `SELECT user_id + FROM public.users_teams + WHERE team_id = $1 AND user_id = $2 + FOR UPDATE`, + teamID, + targetUserID, + ).Scan(&lockedUserID) + if err != nil { + t.Fatalf("failed to lock target team member: %v", err) + } + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodDelete, "/", nil) + auth.SetTeamInfo(ginCtx, &authtypes.Team{ + Team: &authqueries.Team{ID: teamID}, + }) + + store := &APIStore{db: testDB.SqlcClient} + done := make(chan struct{}) + + go func() { + store.DeleteTeamsTeamIDMembersUserId(ginCtx, teamID, targetUserID) + close(done) + }() + + select { + case <-done: + t.Fatalf("expected delete handler to wait for the locked member row") + case <-time.After(100 * time.Millisecond): + } + + _, err = tx.Exec( + ctx, + `UPDATE public.users_teams + SET is_default = true + WHERE team_id = $1 AND user_id = $2`, + teamID, + targetUserID, + ) + if err != nil { + t.Fatalf("failed to promote target team member to default: %v", err) + } + + if err := tx.Commit(ctx); err != nil { + t.Fatalf("failed to commit locking transaction: %v", err) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("delete handler did not finish after releasing the lock") + } + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "Cannot remove a default team member") { + t.Fatalf("unexpected response body: %s", recorder.Body.String()) + } + + relation, err := testDB.SqlcClient.GetTeamMemberRelation(ctx, queries.GetTeamMemberRelationParams{ + TeamID: teamID, + UserID: targetUserID, + }) + if err != nil { + t.Fatalf("expected target team member relation to remain, got %v", err) + } + if !relation.IsDefault { + t.Fatal("expected target team member to remain marked as default") + } +} + +func createHandlerTestUser(t *testing.T, db *testutils.Database) uuid.UUID { + t.Helper() + + userID := uuid.New() + email := handlerTestUserEmail(userID) + + err := db.AuthDb.TestsRawSQL(t.Context(), ` +INSERT INTO auth.users (id, email) +VALUES ($1, $2) +`, userID, email) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + return userID +} + +func handlerTestUserEmail(userID uuid.UUID) string { + return "user-" + userID.String() + "@example.com" +} + +func insertHandlerTestTeamMember(t *testing.T, db *testutils.Database, userID, teamID uuid.UUID, isDefault bool) { + t.Helper() + + err := db.AuthDb.TestsRawSQL(t.Context(), ` +INSERT INTO public.users_teams (user_id, team_id, is_default) +VALUES ($1, $2, $3) +`, userID, teamID, isDefault) + if err != nil { + t.Fatalf("failed to create team member relation: %v", err) + } +} diff --git a/packages/dashboard-api/internal/handlers/team_members.go b/packages/dashboard-api/internal/handlers/team_members.go new file mode 100644 index 0000000000..9c489df81f --- /dev/null +++ b/packages/dashboard-api/internal/handlers/team_members.go @@ -0,0 +1,191 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/db/pkg/dberrors" + "github.com/e2b-dev/infra/packages/db/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +func (s *APIStore) GetTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { + ctx := c.Request.Context() + telemetry.ReportEvent(ctx, "list team members") + + teamInfo, ok := s.requireAuthedTeamMatchesPath(c, teamID) + if !ok { + return + } + + telemetry.SetAttributes(ctx, telemetry.WithTeamID(teamInfo.Team.ID.String())) + + rows, err := s.db.GetTeamMembers(ctx, teamInfo.Team.ID) + if err != nil { + logger.L().Error(ctx, "failed to get team members", zap.Error(err), logger.WithTeamID(teamInfo.Team.ID.String())) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to get team members") + + return + } + + members := make([]api.TeamMember, 0, len(rows)) + for _, row := range rows { + member := api.TeamMember{ + Id: row.UserID, + Email: row.Email, + IsDefault: row.IsDefault, + AddedBy: row.AddedBy, + } + + if row.CreatedAt.Valid { + t := row.CreatedAt.Time.UTC() + member.CreatedAt = &t + } + + members = append(members, member) + } + + c.JSON(http.StatusOK, api.TeamMembersResponse{ + Members: members, + }) +} + +func (s *APIStore) PostTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { + ctx := c.Request.Context() + telemetry.ReportEvent(ctx, "add team member") + + teamInfo, ok := s.requireAuthedTeamMatchesPath(c, teamID) + if !ok { + return + } + + userID := auth.MustGetUserID(c) + telemetry.SetAttributes(ctx, telemetry.WithTeamID(teamInfo.Team.ID.String())) + + body, err := ginutils.ParseBody[api.AddTeamMemberRequest](ctx, c) + if err != nil { + s.sendAPIStoreError(c, http.StatusBadRequest, "Invalid request body") + + return + } + + user, err := s.db.GetUserByEmail(ctx, string(body.Email)) + if err != nil { + if dberrors.IsNotFoundError(err) { + s.sendAPIStoreError(c, http.StatusNotFound, "User with this email does not exist. Please ask them to sign up first.") + + return + } + + logger.L().Error(ctx, "failed to look up user by email", zap.Error(err)) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to look up user") + + return + } + + err = s.db.AddTeamMember(ctx, queries.AddTeamMemberParams{ + UserID: user.ID, + TeamID: teamInfo.Team.ID, + AddedBy: userID, + }) + if err != nil { + if dberrors.IsUniqueConstraintViolation(err) { + s.sendAPIStoreError(c, http.StatusBadRequest, "User is already a member of this team") + + return + } + + logger.L().Error(ctx, "failed to add team member", zap.Error(err), logger.WithTeamID(teamInfo.Team.ID.String())) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to add team member") + + return + } + + c.Status(http.StatusCreated) +} + +func (s *APIStore) DeleteTeamsTeamIDMembersUserId(c *gin.Context, teamID api.TeamID, userId api.UserId) { + ctx := c.Request.Context() + telemetry.ReportEvent(ctx, "remove team member") + + teamInfo, ok := s.requireAuthedTeamMatchesPath(c, teamID) + if !ok { + return + } + + telemetry.SetAttributes(ctx, telemetry.WithTeamID(teamInfo.Team.ID.String())) + + txDB, tx, err := s.db.WithTx(ctx) + if err != nil { + logger.L().Error(ctx, "failed to start transaction for removing team member", zap.Error(err)) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to remove team member") + + return + } + defer func() { + _ = tx.Rollback(ctx) + }() + + lockedMembers, err := txDB.LockTeamMembersForUpdate(ctx, teamInfo.Team.ID) + if err != nil { + logger.L().Error(ctx, "failed to lock team members", zap.Error(err)) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to check team members") + + return + } + + relation, err := txDB.GetTeamMemberRelation(ctx, queries.GetTeamMemberRelationParams{ + TeamID: teamInfo.Team.ID, + UserID: userId, + }) + if err != nil { + if dberrors.IsNotFoundError(err) { + s.sendAPIStoreError(c, http.StatusBadRequest, "User is not a member of this team") + + return + } + + logger.L().Error(ctx, "failed to get team member relation", zap.Error(err)) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to get team member") + + return + } + + if relation.IsDefault { + s.sendAPIStoreError(c, http.StatusBadRequest, "Cannot remove a default team member") + + return + } + + if len(lockedMembers) <= 1 { + s.sendAPIStoreError(c, http.StatusBadRequest, "Cannot remove the last team member") + + return + } + + err = txDB.RemoveTeamMember(ctx, queries.RemoveTeamMemberParams{ + TeamID: teamInfo.Team.ID, + UserID: userId, + }) + if err != nil { + logger.L().Error(ctx, "failed to remove team member", zap.Error(err), logger.WithTeamID(teamInfo.Team.ID.String())) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to remove team member") + + return + } + + if err := tx.Commit(ctx); err != nil { + logger.L().Error(ctx, "failed to commit team member removal", zap.Error(err), logger.WithTeamID(teamInfo.Team.ID.String())) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to remove team member") + + return + } + + c.Status(http.StatusNoContent) +} diff --git a/packages/dashboard-api/internal/handlers/team_update.go b/packages/dashboard-api/internal/handlers/team_update.go new file mode 100644 index 0000000000..e844fc0b88 --- /dev/null +++ b/packages/dashboard-api/internal/handlers/team_update.go @@ -0,0 +1,133 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/db/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +func (s *APIStore) PatchTeamsTeamID(c *gin.Context, teamID api.TeamID) { + ctx := c.Request.Context() + telemetry.ReportEvent(ctx, "update team") + + teamInfo, ok := s.requireAuthedTeamMatchesPath(c, teamID) + if !ok { + return + } + + telemetry.SetAttributes(ctx, telemetry.WithTeamID(teamInfo.Team.ID.String())) + + body, err := ginutils.ParseBodyWith(ctx, c, parseUpdateTeamBody) + if err != nil { + s.sendAPIStoreError(c, http.StatusBadRequest, "Invalid request body") + + return + } + + if !body.NameSet && !body.ProfilePictureUrlSet { + s.sendAPIStoreError(c, http.StatusBadRequest, "At least one field must be provided") + + return + } + + if body.NameSet && strings.TrimSpace(body.Name) == "" { + s.sendAPIStoreError(c, http.StatusBadRequest, "Name must not be empty") + + return + } + + row, err := s.db.UpdateTeam(ctx, queries.UpdateTeamParams{ + TeamID: teamInfo.Team.ID, + Name: body.NamePtr(), + NameSet: body.NameSet, + ProfilePictureUrl: body.ProfilePictureUrl, + ProfilePictureUrlSet: body.ProfilePictureUrlSet, + }) + if err != nil { + logger.L().Error(ctx, "failed to update team", zap.Error(err), logger.WithTeamID(teamInfo.Team.ID.String())) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to update team") + + return + } + + c.JSON(http.StatusOK, api.UpdateTeamResponse{ + Id: row.ID, + Name: row.Name, + ProfilePictureUrl: row.ProfilePictureUrl, + }) +} + +type updateTeamBody struct { + NameSet bool + Name string + ProfilePictureUrlSet bool + ProfilePictureUrl *string +} + +func (b updateTeamBody) NamePtr() *string { + if !b.NameSet { + return nil + } + + return &b.Name +} + +func parseUpdateTeamBody(bodyReader io.Reader) (updateTeamBody, error) { + var body updateTeamBody + + var payload map[string]json.RawMessage + decoder := json.NewDecoder(bodyReader) + if err := decoder.Decode(&payload); err != nil { + return body, err + } + + for field := range payload { + if field != "name" && field != "profilePictureUrl" { + return body, errors.New("unknown field") + } + } + + nameRaw, hasName := payload["name"] + if hasName { + body.NameSet = true + if bytes.Equal(nameRaw, []byte("null")) { + return body, errors.New("name cannot be null") + } + + var name string + if err := json.Unmarshal(nameRaw, &name); err != nil { + return body, err + } + + body.Name = name + } + + profilePictureURLRaw, hasProfilePictureURL := payload["profilePictureUrl"] + if hasProfilePictureURL { + body.ProfilePictureUrlSet = true + if bytes.Equal(profilePictureURLRaw, []byte("null")) { + body.ProfilePictureUrl = nil + } else { + var profilePictureURL string + if err := json.Unmarshal(profilePictureURLRaw, &profilePictureURL); err != nil { + return body, err + } + + body.ProfilePictureUrl = &profilePictureURL + } + } + + return body, nil +} diff --git a/packages/dashboard-api/internal/handlers/teams_list.go b/packages/dashboard-api/internal/handlers/teams_list.go new file mode 100644 index 0000000000..b1ede4b900 --- /dev/null +++ b/packages/dashboard-api/internal/handlers/teams_list.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +func (s *APIStore) GetTeams(c *gin.Context) { + ctx := c.Request.Context() + telemetry.ReportEvent(ctx, "list user teams") + + userID := auth.MustGetUserID(c) + + rows, err := s.db.GetDashboardTeamsWithUsersTeamsWithTier(ctx, userID) + if err != nil { + logger.L().Error(ctx, "failed to get user teams", zap.Error(err)) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to get user teams") + + return + } + + teams := make([]api.UserTeam, 0, len(rows)) + for _, row := range rows { + teams = append(teams, api.UserTeam{ + Id: row.Team.ID, + Name: row.Team.Name, + Slug: row.Team.Slug, + Tier: row.Team.Tier, + Email: row.Team.Email, + ProfilePictureUrl: row.Team.ProfilePictureUrl, + IsBlocked: row.Team.IsBlocked, + IsBanned: row.Team.IsBanned, + BlockedReason: row.Team.BlockedReason, + IsDefault: row.IsDefault, + Limits: api.UserTeamLimits{ + MaxLengthHours: row.TeamLimit.MaxLengthHours, + ConcurrentSandboxes: row.TeamLimit.ConcurrentSandboxes, + ConcurrentTemplateBuilds: row.TeamLimit.ConcurrentTemplateBuilds, + MaxVcpu: row.TeamLimit.MaxVcpu, + MaxRamMb: row.TeamLimit.MaxRamMb, + DiskMb: row.TeamLimit.DiskMb, + }, + }) + } + + c.JSON(http.StatusOK, api.UserTeamsResponse{ + Teams: teams, + }) +} diff --git a/packages/dashboard-api/internal/handlers/teams_resolve.go b/packages/dashboard-api/internal/handlers/teams_resolve.go new file mode 100644 index 0000000000..4a91a84b4a --- /dev/null +++ b/packages/dashboard-api/internal/handlers/teams_resolve.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/db/pkg/dberrors" + "github.com/e2b-dev/infra/packages/db/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +func (s *APIStore) GetTeamsResolve(c *gin.Context, params api.GetTeamsResolveParams) { + ctx := c.Request.Context() + telemetry.ReportEvent(ctx, "resolve team by slug") + + userID := auth.MustGetUserID(c) + + row, err := s.db.ResolveTeamBySlugAndUser(ctx, queries.ResolveTeamBySlugAndUserParams{ + UserID: userID, + Slug: params.Slug, + }) + if err != nil { + if dberrors.IsNotFoundError(err) { + s.sendAPIStoreError(c, http.StatusNotFound, "Team not found") + + return + } + + logger.L().Warn(ctx, "team resolve by slug failed", zap.Error(err), zap.String("slug", params.Slug)) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to resolve team") + + return + } + + c.JSON(http.StatusOK, api.TeamResolveResponse{ + Id: row.ID, + Slug: row.Slug, + }) +} diff --git a/packages/dashboard-api/internal/handlers/templates_defaults.go b/packages/dashboard-api/internal/handlers/templates_defaults.go new file mode 100644 index 0000000000..de8c76f73d --- /dev/null +++ b/packages/dashboard-api/internal/handlers/templates_defaults.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +func (s *APIStore) GetTemplatesDefaults(c *gin.Context) { + ctx := c.Request.Context() + telemetry.ReportEvent(ctx, "list default templates") + + rows, err := s.db.GetDefaultTemplates(ctx) + if err != nil { + logger.L().Error(ctx, "failed to get default templates", zap.Error(err)) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to get default templates") + + return + } + + if len(rows) == 0 { + c.JSON(http.StatusOK, api.DefaultTemplatesResponse{ + Templates: []api.DefaultTemplate{}, + }) + + return + } + + envIDs := make([]string, 0, len(rows)) + for _, row := range rows { + envIDs = append(envIDs, row.TemplateID) + } + + aliasRows, err := s.db.GetTemplateAliases(ctx, envIDs) + if err != nil { + logger.L().Error(ctx, "failed to get default template aliases", zap.Error(err)) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to get default template aliases") + + return + } + + aliasesByEnv := make(map[string][]api.DefaultTemplateAlias, len(rows)) + for _, a := range aliasRows { + aliasesByEnv[a.EnvID] = append(aliasesByEnv[a.EnvID], api.DefaultTemplateAlias{ + Alias: a.Alias, + Namespace: a.Namespace, + }) + } + + templates := make([]api.DefaultTemplate, 0, len(rows)) + for _, row := range rows { + aliases := aliasesByEnv[row.TemplateID] + if aliases == nil { + aliases = []api.DefaultTemplateAlias{} + } + + templates = append(templates, api.DefaultTemplate{ + Id: row.TemplateID, + Aliases: aliases, + BuildId: row.BuildID, + RamMb: row.RamMb, + Vcpu: row.Vcpu, + TotalDiskSizeMb: row.TotalDiskSizeMb, + EnvdVersion: row.EnvdVersion, + CreatedAt: row.CreatedAt, + Public: row.Public, + BuildCount: row.BuildCount, + SpawnCount: row.SpawnCount, + }) + } + + c.JSON(http.StatusOK, api.DefaultTemplatesResponse{ + Templates: templates, + }) +} diff --git a/packages/dashboard-api/internal/handlers/utils.go b/packages/dashboard-api/internal/handlers/utils.go new file mode 100644 index 0000000000..f73e2a36c3 --- /dev/null +++ b/packages/dashboard-api/internal/handlers/utils.go @@ -0,0 +1,22 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth" + authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" +) + +func (s *APIStore) requireAuthedTeamMatchesPath(c *gin.Context, teamID api.TeamID) (*authtypes.Team, bool) { + teamInfo := auth.MustGetTeamInfo(c) + if teamInfo.Team.ID != teamID { + s.sendAPIStoreError(c, http.StatusForbidden, "Team path parameter does not match authenticated team") + + return nil, false + } + + return teamInfo, true +} diff --git a/packages/db/pkg/dashboard/migrations/20260316130000_dashboard_add_env_defaults_and_team_profile_picture.sql b/packages/db/pkg/dashboard/migrations/20260316130000_dashboard_add_env_defaults_and_team_profile_picture.sql new file mode 100644 index 0000000000..c29485cf30 --- /dev/null +++ b/packages/db/pkg/dashboard/migrations/20260316130000_dashboard_add_env_defaults_and_team_profile_picture.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE IF NOT EXISTS public.env_defaults ( + env_id TEXT PRIMARY KEY REFERENCES public.envs(id), + description TEXT +); + +ALTER TABLE public.env_defaults ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.teams ADD COLUMN IF NOT EXISTS profile_picture_url TEXT; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +ALTER TABLE public.teams DROP COLUMN IF EXISTS profile_picture_url; +DROP TABLE IF EXISTS public.env_defaults; + +-- +goose StatementEnd diff --git a/packages/db/pkg/dashboard/sql_queries/teams/get_dashboard_teams_with_users_teams_with_tier.sql b/packages/db/pkg/dashboard/sql_queries/teams/get_dashboard_teams_with_users_teams_with_tier.sql new file mode 100644 index 0000000000..b2fc362011 --- /dev/null +++ b/packages/db/pkg/dashboard/sql_queries/teams/get_dashboard_teams_with_users_teams_with_tier.sql @@ -0,0 +1,6 @@ +-- name: GetDashboardTeamsWithUsersTeamsWithTier :many +SELECT sqlc.embed(t), ut.is_default, sqlc.embed(tl) +FROM "public"."teams" t +JOIN "public"."users_teams" ut ON ut.team_id = t.id +JOIN "public"."team_limits" tl ON tl.id = t.id +WHERE ut.user_id = sqlc.arg(user_id)::uuid; diff --git a/packages/db/pkg/dashboard/sql_queries/teams/update_team.sql b/packages/db/pkg/dashboard/sql_queries/teams/update_team.sql new file mode 100644 index 0000000000..5367509b42 --- /dev/null +++ b/packages/db/pkg/dashboard/sql_queries/teams/update_team.sql @@ -0,0 +1,13 @@ +-- name: UpdateTeam :one +UPDATE public.teams +SET + name = CASE + WHEN sqlc.arg(name_set)::bool THEN sqlc.narg(name)::text + ELSE name + END, + profile_picture_url = CASE + WHEN sqlc.arg(profile_picture_url_set)::bool THEN sqlc.narg(profile_picture_url)::text + ELSE profile_picture_url + END +WHERE id = sqlc.arg(team_id)::uuid +RETURNING id, name, profile_picture_url; diff --git a/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql new file mode 100644 index 0000000000..943914bd5f --- /dev/null +++ b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql @@ -0,0 +1,24 @@ +-- name: GetDefaultTemplates :many +SELECT + e.id AS template_id, + e.created_at, + e.public, + e.build_count, + e.spawn_count, + b.id AS build_id, + b.ram_mb, + b.vcpu, + b.total_disk_size_mb, + b.envd_version +FROM public.env_defaults ed +JOIN public.envs e ON e.id = ed.env_id +JOIN LATERAL ( + SELECT a.build_id + FROM public.env_build_assignments a + JOIN public.env_builds eb ON eb.id = a.build_id + WHERE a.env_id = e.id + AND eb.status = 'uploaded' + ORDER BY a.created_at DESC, a.id DESC + LIMIT 1 +) latest_assignment ON TRUE +JOIN public.env_builds b ON b.id = latest_assignment.build_id; diff --git a/packages/db/queries/get_dashboard_teams_with_users_teams_with_tier.sql.go b/packages/db/queries/get_dashboard_teams_with_users_teams_with_tier.sql.go new file mode 100644 index 0000000000..86a156ea51 --- /dev/null +++ b/packages/db/queries/get_dashboard_teams_with_users_teams_with_tier.sql.go @@ -0,0 +1,67 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: get_dashboard_teams_with_users_teams_with_tier.sql + +package queries + +import ( + "context" + + "github.com/google/uuid" +) + +const getDashboardTeamsWithUsersTeamsWithTier = `-- name: GetDashboardTeamsWithUsersTeamsWithTier :many +SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.slug, t.profile_picture_url, ut.is_default, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb +FROM "public"."teams" t +JOIN "public"."users_teams" ut ON ut.team_id = t.id +JOIN "public"."team_limits" tl ON tl.id = t.id +WHERE ut.user_id = $1::uuid +` + +type GetDashboardTeamsWithUsersTeamsWithTierRow struct { + Team Team + IsDefault bool + TeamLimit TeamLimit +} + +func (q *Queries) GetDashboardTeamsWithUsersTeamsWithTier(ctx context.Context, userID uuid.UUID) ([]GetDashboardTeamsWithUsersTeamsWithTierRow, error) { + rows, err := q.db.Query(ctx, getDashboardTeamsWithUsersTeamsWithTier, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetDashboardTeamsWithUsersTeamsWithTierRow + for rows.Next() { + var i GetDashboardTeamsWithUsersTeamsWithTierRow + if err := rows.Scan( + &i.Team.ID, + &i.Team.CreatedAt, + &i.Team.IsBlocked, + &i.Team.Name, + &i.Team.Tier, + &i.Team.Email, + &i.Team.IsBanned, + &i.Team.BlockedReason, + &i.Team.ClusterID, + &i.Team.SandboxSchedulingLabels, + &i.Team.Slug, + &i.Team.ProfilePictureUrl, + &i.IsDefault, + &i.TeamLimit.ID, + &i.TeamLimit.MaxLengthHours, + &i.TeamLimit.ConcurrentSandboxes, + &i.TeamLimit.ConcurrentTemplateBuilds, + &i.TeamLimit.MaxVcpu, + &i.TeamLimit.MaxRamMb, + &i.TeamLimit.DiskMb, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/packages/db/queries/get_default_templates.sql.go b/packages/db/queries/get_default_templates.sql.go new file mode 100644 index 0000000000..fd85b9e153 --- /dev/null +++ b/packages/db/queries/get_default_templates.sql.go @@ -0,0 +1,83 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: get_default_templates.sql + +package queries + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const getDefaultTemplates = `-- name: GetDefaultTemplates :many +SELECT + e.id AS template_id, + e.created_at, + e.public, + e.build_count, + e.spawn_count, + b.id AS build_id, + b.ram_mb, + b.vcpu, + b.total_disk_size_mb, + b.envd_version +FROM public.env_defaults ed +JOIN public.envs e ON e.id = ed.env_id +JOIN LATERAL ( + SELECT a.build_id + FROM public.env_build_assignments a + JOIN public.env_builds eb ON eb.id = a.build_id + WHERE a.env_id = e.id + AND eb.status = 'uploaded' + ORDER BY a.created_at DESC, a.id DESC + LIMIT 1 +) latest_assignment ON TRUE +JOIN public.env_builds b ON b.id = latest_assignment.build_id +` + +type GetDefaultTemplatesRow struct { + TemplateID string + CreatedAt time.Time + Public bool + BuildCount int32 + SpawnCount int64 + BuildID uuid.UUID + RamMb int64 + Vcpu int64 + TotalDiskSizeMb *int64 + EnvdVersion *string +} + +func (q *Queries) GetDefaultTemplates(ctx context.Context) ([]GetDefaultTemplatesRow, error) { + rows, err := q.db.Query(ctx, getDefaultTemplates) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetDefaultTemplatesRow + for rows.Next() { + var i GetDefaultTemplatesRow + if err := rows.Scan( + &i.TemplateID, + &i.CreatedAt, + &i.Public, + &i.BuildCount, + &i.SpawnCount, + &i.BuildID, + &i.RamMb, + &i.Vcpu, + &i.TotalDiskSizeMb, + &i.EnvdVersion, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/packages/db/queries/get_inprogress_builds.sql.go b/packages/db/queries/get_inprogress_builds.sql.go index fd1e13946b..451a5e62a3 100644 --- a/packages/db/queries/get_inprogress_builds.sql.go +++ b/packages/db/queries/get_inprogress_builds.sql.go @@ -55,7 +55,7 @@ func (q *Queries) GetCancellableTemplateBuildsByTeam(ctx context.Context, teamID } const getInProgressTemplateBuilds = `-- name: GetInProgressTemplateBuilds :many -SELECT DISTINCT ON (b.id) t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.slug, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id +SELECT DISTINCT ON (b.id) t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.slug, t.profile_picture_url, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id FROM public.env_builds b JOIN public.env_build_assignments eba ON eba.build_id = b.id JOIN public.envs e ON e.id = eba.env_id @@ -92,6 +92,7 @@ func (q *Queries) GetInProgressTemplateBuilds(ctx context.Context) ([]GetInProgr &i.Team.ClusterID, &i.Team.SandboxSchedulingLabels, &i.Team.Slug, + &i.Team.ProfilePictureUrl, &i.Env.ID, &i.Env.CreatedAt, &i.Env.UpdatedAt, diff --git a/packages/db/queries/get_template_aliases.sql.go b/packages/db/queries/get_template_aliases.sql.go new file mode 100644 index 0000000000..cdb12576d1 --- /dev/null +++ b/packages/db/queries/get_template_aliases.sql.go @@ -0,0 +1,45 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: get_template_aliases.sql + +package queries + +import ( + "context" +) + +const getTemplateAliases = `-- name: GetTemplateAliases :many +SELECT + ea.alias, + ea.namespace, + ea.env_id +FROM public.env_aliases ea +WHERE ea.env_id = ANY($1::text[]) +` + +type GetTemplateAliasesRow struct { + Alias string + Namespace *string + EnvID string +} + +func (q *Queries) GetTemplateAliases(ctx context.Context, envIds []string) ([]GetTemplateAliasesRow, error) { + rows, err := q.db.Query(ctx, getTemplateAliases, envIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTemplateAliasesRow + for rows.Next() { + var i GetTemplateAliasesRow + if err := rows.Scan(&i.Alias, &i.Namespace, &i.EnvID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/packages/db/queries/models.go b/packages/db/queries/models.go index eb3b178d0c..6c960a7a59 100644 --- a/packages/db/queries/models.go +++ b/packages/db/queries/models.go @@ -136,6 +136,11 @@ type EnvBuildAssignment struct { CreatedAt pgtype.Timestamptz } +type EnvDefault struct { + EnvID string + Description *string +} + type Snapshot struct { CreatedAt pgtype.Timestamptz EnvID string @@ -172,6 +177,7 @@ type Team struct { ClusterID *uuid.UUID SandboxSchedulingLabels []string Slug string + ProfilePictureUrl *string } type TeamApiKey struct { diff --git a/packages/db/queries/resolve_team.sql.go b/packages/db/queries/resolve_team.sql.go new file mode 100644 index 0000000000..a9481e83fa --- /dev/null +++ b/packages/db/queries/resolve_team.sql.go @@ -0,0 +1,37 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: resolve_team.sql + +package queries + +import ( + "context" + + "github.com/google/uuid" +) + +const resolveTeamBySlugAndUser = `-- name: ResolveTeamBySlugAndUser :one +SELECT t.id, t.slug +FROM public.teams t +JOIN public.users_teams ut ON ut.team_id = t.id +WHERE ut.user_id = $1::uuid + AND t.slug = $2::text +` + +type ResolveTeamBySlugAndUserParams struct { + UserID uuid.UUID + Slug string +} + +type ResolveTeamBySlugAndUserRow struct { + ID uuid.UUID + Slug string +} + +func (q *Queries) ResolveTeamBySlugAndUser(ctx context.Context, arg ResolveTeamBySlugAndUserParams) (ResolveTeamBySlugAndUserRow, error) { + row := q.db.QueryRow(ctx, resolveTeamBySlugAndUser, arg.UserID, arg.Slug) + var i ResolveTeamBySlugAndUserRow + err := row.Scan(&i.ID, &i.Slug) + return i, err +} diff --git a/packages/db/queries/team_members.sql.go b/packages/db/queries/team_members.sql.go new file mode 100644 index 0000000000..4e46b0d4c0 --- /dev/null +++ b/packages/db/queries/team_members.sql.go @@ -0,0 +1,168 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: team_members.sql + +package queries + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const addTeamMember = `-- name: AddTeamMember :exec +INSERT INTO public.users_teams (user_id, team_id, is_default, added_by) +VALUES ( + $1::uuid, + $2::uuid, + false, + $3::uuid +) +` + +type AddTeamMemberParams struct { + UserID uuid.UUID + TeamID uuid.UUID + AddedBy uuid.UUID +} + +func (q *Queries) AddTeamMember(ctx context.Context, arg AddTeamMemberParams) error { + _, err := q.db.Exec(ctx, addTeamMember, arg.UserID, arg.TeamID, arg.AddedBy) + return err +} + +const getTeamMemberRelation = `-- name: GetTeamMemberRelation :one +SELECT id, user_id, team_id, is_default, added_by, created_at, uuid_id FROM public.users_teams +WHERE team_id = $1::uuid + AND user_id = $2::uuid +` + +type GetTeamMemberRelationParams struct { + TeamID uuid.UUID + UserID uuid.UUID +} + +func (q *Queries) GetTeamMemberRelation(ctx context.Context, arg GetTeamMemberRelationParams) (UsersTeam, error) { + row := q.db.QueryRow(ctx, getTeamMemberRelation, arg.TeamID, arg.UserID) + var i UsersTeam + err := row.Scan( + &i.ID, + &i.UserID, + &i.TeamID, + &i.IsDefault, + &i.AddedBy, + &i.CreatedAt, + &i.UuidID, + ) + return i, err +} + +const getTeamMembers = `-- name: GetTeamMembers :many +SELECT + ut.user_id, + ut.team_id, + ut.is_default, + ut.added_by, + ut.created_at, + u.email +FROM public.users_teams ut +JOIN public.users u ON u.id = ut.user_id +WHERE ut.team_id = $1::uuid +` + +type GetTeamMembersRow struct { + UserID uuid.UUID + TeamID uuid.UUID + IsDefault bool + AddedBy *uuid.UUID + CreatedAt pgtype.Timestamp + Email string +} + +func (q *Queries) GetTeamMembers(ctx context.Context, teamID uuid.UUID) ([]GetTeamMembersRow, error) { + rows, err := q.db.Query(ctx, getTeamMembers, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTeamMembersRow + for rows.Next() { + var i GetTeamMembersRow + if err := rows.Scan( + &i.UserID, + &i.TeamID, + &i.IsDefault, + &i.AddedBy, + &i.CreatedAt, + &i.Email, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email FROM public.users +WHERE email = $1::text +` + +type GetUserByEmailRow struct { + ID uuid.UUID + Email string +} + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i GetUserByEmailRow + err := row.Scan(&i.ID, &i.Email) + return i, err +} + +const lockTeamMembersForUpdate = `-- name: LockTeamMembersForUpdate :many +SELECT user_id FROM public.users_teams +WHERE team_id = $1::uuid +FOR UPDATE +` + +func (q *Queries) LockTeamMembersForUpdate(ctx context.Context, teamID uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, lockTeamMembersForUpdate, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var user_id uuid.UUID + if err := rows.Scan(&user_id); err != nil { + return nil, err + } + items = append(items, user_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeTeamMember = `-- name: RemoveTeamMember :exec +DELETE FROM public.users_teams +WHERE team_id = $1::uuid + AND user_id = $2::uuid +` + +type RemoveTeamMemberParams struct { + TeamID uuid.UUID + UserID uuid.UUID +} + +func (q *Queries) RemoveTeamMember(ctx context.Context, arg RemoveTeamMemberParams) error { + _, err := q.db.Exec(ctx, removeTeamMember, arg.TeamID, arg.UserID) + return err +} diff --git a/packages/db/queries/teams/resolve_team.sql b/packages/db/queries/teams/resolve_team.sql new file mode 100644 index 0000000000..4266407959 --- /dev/null +++ b/packages/db/queries/teams/resolve_team.sql @@ -0,0 +1,6 @@ +-- name: ResolveTeamBySlugAndUser :one +SELECT t.id, t.slug +FROM public.teams t +JOIN public.users_teams ut ON ut.team_id = t.id +WHERE ut.user_id = sqlc.arg(user_id)::uuid + AND t.slug = sqlc.arg(slug)::text; diff --git a/packages/db/queries/teams/team_members.sql b/packages/db/queries/teams/team_members.sql new file mode 100644 index 0000000000..14d0b7de3d --- /dev/null +++ b/packages/db/queries/teams/team_members.sql @@ -0,0 +1,39 @@ +-- name: GetTeamMembers :many +SELECT + ut.user_id, + ut.team_id, + ut.is_default, + ut.added_by, + ut.created_at, + u.email +FROM public.users_teams ut +JOIN public.users u ON u.id = ut.user_id +WHERE ut.team_id = sqlc.arg(team_id)::uuid; + +-- name: GetTeamMemberRelation :one +SELECT * FROM public.users_teams +WHERE team_id = sqlc.arg(team_id)::uuid + AND user_id = sqlc.arg(user_id)::uuid; + +-- name: LockTeamMembersForUpdate :many +SELECT user_id FROM public.users_teams +WHERE team_id = sqlc.arg(team_id)::uuid +FOR UPDATE; + +-- name: GetUserByEmail :one +SELECT id, email FROM public.users +WHERE email = sqlc.arg(email)::text; + +-- name: AddTeamMember :exec +INSERT INTO public.users_teams (user_id, team_id, is_default, added_by) +VALUES ( + sqlc.arg(user_id)::uuid, + sqlc.arg(team_id)::uuid, + false, + sqlc.arg(added_by)::uuid +); + +-- name: RemoveTeamMember :exec +DELETE FROM public.users_teams +WHERE team_id = sqlc.arg(team_id)::uuid + AND user_id = sqlc.arg(user_id)::uuid; diff --git a/packages/db/queries/templates/get_template_aliases.sql b/packages/db/queries/templates/get_template_aliases.sql new file mode 100644 index 0000000000..237120bee8 --- /dev/null +++ b/packages/db/queries/templates/get_template_aliases.sql @@ -0,0 +1,7 @@ +-- name: GetTemplateAliases :many +SELECT + ea.alias, + ea.namespace, + ea.env_id +FROM public.env_aliases ea +WHERE ea.env_id = ANY(sqlc.arg(env_ids)::text[]); diff --git a/packages/db/queries/update_team.sql.go b/packages/db/queries/update_team.sql.go new file mode 100644 index 0000000000..34f57bd997 --- /dev/null +++ b/packages/db/queries/update_team.sql.go @@ -0,0 +1,54 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: update_team.sql + +package queries + +import ( + "context" + + "github.com/google/uuid" +) + +const updateTeam = `-- name: UpdateTeam :one +UPDATE public.teams +SET + name = CASE + WHEN $1::bool THEN $2::text + ELSE name + END, + profile_picture_url = CASE + WHEN $3::bool THEN $4::text + ELSE profile_picture_url + END +WHERE id = $5::uuid +RETURNING id, name, profile_picture_url +` + +type UpdateTeamParams struct { + NameSet bool + Name *string + ProfilePictureUrlSet bool + ProfilePictureUrl *string + TeamID uuid.UUID +} + +type UpdateTeamRow struct { + ID uuid.UUID + Name string + ProfilePictureUrl *string +} + +func (q *Queries) UpdateTeam(ctx context.Context, arg UpdateTeamParams) (UpdateTeamRow, error) { + row := q.db.QueryRow(ctx, updateTeam, + arg.NameSet, + arg.Name, + arg.ProfilePictureUrlSet, + arg.ProfilePictureUrl, + arg.TeamID, + ) + var i UpdateTeamRow + err := row.Scan(&i.ID, &i.Name, &i.ProfilePictureUrl) + return i, err +} diff --git a/packages/db/sqlc.yaml b/packages/db/sqlc.yaml index fdb318004a..206c468907 100644 --- a/packages/db/sqlc.yaml +++ b/packages/db/sqlc.yaml @@ -1,10 +1,13 @@ version: "2" sql: - engine: "postgresql" - queries: "queries/**" + queries: + - "queries/**" + - "pkg/dashboard/sql_queries/**" schema: - "migrations" - "schema" + - "pkg/dashboard/migrations" gen: go: emit_pointers_for_null_types: true diff --git a/packages/api/internal/utils/body.go b/packages/shared/pkg/ginutils/body.go similarity index 52% rename from packages/api/internal/utils/body.go rename to packages/shared/pkg/ginutils/body.go index 91007779a7..9f0318471d 100644 --- a/packages/api/internal/utils/body.go +++ b/packages/shared/pkg/ginutils/body.go @@ -1,8 +1,9 @@ -package utils +package ginutils import ( "context" "fmt" + "io" "github.com/gin-gonic/gin" @@ -19,3 +20,14 @@ func ParseBody[B any](ctx context.Context, c *gin.Context) (body B, err error) { return body, nil } + +func ParseBodyWith[B any](ctx context.Context, c *gin.Context, parse func(io.Reader) (B, error)) (body B, err error) { + body, err = parse(c.Request.Body) + if err != nil { + telemetry.ReportCriticalError(ctx, "error when parsing request", err) + + return body, fmt.Errorf("error when parsing request: %w", err) + } + + return body, nil +} diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index acf6ac9150..9d0a4fbac1 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -81,6 +81,29 @@ components: format: uuid maxItems: 100 uniqueItems: true + teamID: + name: teamID + in: path + required: true + description: Identifier of the team. + schema: + type: string + format: uuid + userId: + name: userId + in: path + required: true + description: Identifier of the user. + schema: + type: string + format: uuid + teamSlug: + name: slug + in: query + required: true + description: Team slug to resolve. + schema: + type: string responses: "400": @@ -320,9 +343,242 @@ components: type: string description: Human-readable health check result. + UserTeamLimits: + type: object + required: + - maxLengthHours + - concurrentSandboxes + - concurrentTemplateBuilds + - maxVcpu + - maxRamMb + - diskMb + properties: + maxLengthHours: + type: integer + format: int64 + concurrentSandboxes: + type: integer + format: int32 + concurrentTemplateBuilds: + type: integer + format: int32 + maxVcpu: + type: integer + format: int32 + maxRamMb: + type: integer + format: int32 + diskMb: + type: integer + format: int32 + + UserTeam: + type: object + required: + - id + - name + - slug + - tier + - email + - profilePictureUrl + - isBlocked + - isBanned + - blockedReason + - isDefault + - limits + properties: + id: + type: string + format: uuid + name: + type: string + slug: + type: string + tier: + type: string + email: + type: string + profilePictureUrl: + type: string + nullable: true + isBlocked: + type: boolean + isBanned: + type: boolean + blockedReason: + type: string + nullable: true + isDefault: + type: boolean + limits: + $ref: "#/components/schemas/UserTeamLimits" + + UserTeamsResponse: + type: object + required: + - teams + properties: + teams: + type: array + items: + $ref: "#/components/schemas/UserTeam" + + TeamMember: + type: object + required: + - id + - email + - isDefault + - createdAt + properties: + id: + type: string + format: uuid + email: + type: string + isDefault: + type: boolean + addedBy: + type: string + format: uuid + nullable: true + createdAt: + type: string + format: date-time + nullable: true + + TeamMembersResponse: + type: object + required: + - members + properties: + members: + type: array + items: + $ref: "#/components/schemas/TeamMember" + + UpdateTeamRequest: + type: object + minProperties: 1 + properties: + name: + type: string + minLength: 1 + maxLength: 255 + profilePictureUrl: + type: string + nullable: true + + UpdateTeamResponse: + type: object + required: + - id + - name + properties: + id: + type: string + format: uuid + name: + type: string + profilePictureUrl: + type: string + nullable: true + + AddTeamMemberRequest: + type: object + required: + - email + properties: + email: + type: string + format: email + + DefaultTemplateAlias: + type: object + required: + - alias + properties: + alias: + type: string + namespace: + type: string + nullable: true + + DefaultTemplate: + type: object + required: + - id + - aliases + - buildId + - ramMb + - vcpu + - totalDiskSizeMb + - createdAt + - public + - buildCount + - spawnCount + properties: + id: + type: string + aliases: + type: array + items: + $ref: "#/components/schemas/DefaultTemplateAlias" + buildId: + type: string + format: uuid + ramMb: + type: integer + format: int64 + vcpu: + type: integer + format: int64 + totalDiskSizeMb: + type: integer + format: int64 + nullable: true + envdVersion: + type: string + nullable: true + createdAt: + type: string + format: date-time + public: + type: boolean + buildCount: + type: integer + format: int32 + spawnCount: + type: integer + format: int64 + + DefaultTemplatesResponse: + type: object + required: + - templates + properties: + templates: + type: array + items: + $ref: "#/components/schemas/DefaultTemplate" + + TeamResolveResponse: + type: object + required: + - id + - slug + properties: + id: + type: string + format: uuid + slug: + type: string + tags: - name: builds - name: sandboxes + - name: teams + - name: templates paths: /health: @@ -439,3 +695,169 @@ paths: $ref: "#/components/responses/404" "500": $ref: "#/components/responses/500" + + /teams: + get: + summary: List user teams + description: Returns all teams the authenticated user belongs to, with limits and default flag. + tags: [teams] + security: + - Supabase1TokenAuth: [] + responses: + "200": + description: Successfully returned user teams. + content: + application/json: + schema: + $ref: "#/components/schemas/UserTeamsResponse" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /teams/resolve: + get: + summary: Resolve team identity + description: Resolves a team slug to the team's identity, validating the user is a member. + tags: [teams] + security: + - Supabase1TokenAuth: [] + parameters: + - $ref: "#/components/parameters/teamSlug" + responses: + "200": + description: Successfully resolved team. + content: + application/json: + schema: + $ref: "#/components/schemas/TeamResolveResponse" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + /teams/{teamID}: + patch: + summary: Update team + tags: [teams] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/teamID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateTeamRequest" + responses: + "200": + description: Successfully updated team. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateTeamResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "500": + $ref: "#/components/responses/500" + + /teams/{teamID}/members: + get: + summary: List team members + tags: [teams] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/teamID" + responses: + "200": + description: Successfully returned team members. + content: + application/json: + schema: + $ref: "#/components/schemas/TeamMembersResponse" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "500": + $ref: "#/components/responses/500" + post: + summary: Add team member + tags: [teams] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/teamID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AddTeamMemberRequest" + responses: + "201": + description: Successfully added team member. + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + /teams/{teamID}/members/{userId}: + delete: + summary: Remove team member + tags: [teams] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/teamID" + - $ref: "#/components/parameters/userId" + responses: + "204": + description: Successfully removed team member. + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "500": + $ref: "#/components/responses/500" + + /templates/defaults: + get: + summary: List default templates + description: Returns the list of default templates with their latest build info and aliases. + tags: [templates] + security: + - Supabase1TokenAuth: [] + responses: + "200": + description: Successfully returned default templates. + content: + application/json: + schema: + $ref: "#/components/schemas/DefaultTemplatesResponse" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500"