mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(environments): env CRUD, workspace switcher, CLI + MCP parity (#68)
Environment lanes existed as seeded rows (production/preview/development) with a per-service environment label, but there was no way to list, create, or delete them. Adds the management surface and the switcher: - GET /projects/:id/environments with live service counts (sqlc) - POST /projects/:id/environments — lowercase dns-label names, 1-50 chars, unique per project (409 on dupe) - DELETE /environments/:id — refuses while it holds services (409) - Workspace service list gains env filter chips with counts plus an inline "+ env" creator - CLI: containr environments list|create|delete (--yes on delete); MCP: containr_environments_list/create/delete (delete needs confirm) - OpenAPI: new Environments tag + ProjectEnvironment schema Live-verified: list counts, name validation, dupe 409, empty delete, non-empty 409, CLI list. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
dd7b735b38
commit
60145f7b21
13 files changed
+727
-6
No files matched your search
@@ -0,0 +1,140 @@
|
||||
package api
|
||||
|
||||
// Project environments — the named lanes services deploy into
|
||||
// (production/development/preview are seeded; custom names allowed).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var envNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$`)
|
||||
|
||||
type environmentDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ServiceCount int64 `json:"service_count"`
|
||||
}
|
||||
|
||||
func toEnvironmentDTO(id uuid.UUID, name string, projectID uuid.UUID, created, updated time.Time, count int64) environmentDTO {
|
||||
return environmentDTO{ID: id, Name: name, ProjectID: projectID, CreatedAt: created, UpdatedAt: updated, ServiceCount: count}
|
||||
}
|
||||
|
||||
// handleListEnvironments returns the project's environments with live
|
||||
// service counts — the data behind the env switcher.
|
||||
func handleListEnvironments(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
|
||||
return
|
||||
}
|
||||
if exists, allowed := projectReadAccess(c, db, projectID); !exists || !allowed {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Project not found")
|
||||
return
|
||||
}
|
||||
rows, err := sqlcdb.New(db.DB).ListProjectEnvironments(context.Background(), projectID)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list environments")
|
||||
return
|
||||
}
|
||||
envs := make([]environmentDTO, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
envs = append(envs, toEnvironmentDTO(r.ID, r.Name, r.ProjectID, r.CreatedAt.Time, r.UpdatedAt.Time, r.ServiceCount))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"environments": envs})
|
||||
}
|
||||
|
||||
// handleCreateEnvironment adds a named environment lane to the project.
|
||||
func handleCreateEnvironment(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists || !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
|
||||
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
|
||||
return
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if !envNamePattern.MatchString(name) {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Name must be lowercase alphanumeric with dashes, 1-50 chars")
|
||||
return
|
||||
}
|
||||
q := sqlcdb.New(db.DB)
|
||||
if _, err := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
|
||||
ProjectID: projectID, Name: name,
|
||||
}); err == nil {
|
||||
respondError(c, http.StatusConflict, "CONFLICT", "Environment already exists")
|
||||
return
|
||||
}
|
||||
if err := q.InsertProjectEnvironment(context.Background(), sqlcdb.InsertProjectEnvironmentParams{
|
||||
Name: name, ProjectID: projectID,
|
||||
}); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create environment")
|
||||
return
|
||||
}
|
||||
env, _ := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
|
||||
ProjectID: projectID, Name: name,
|
||||
})
|
||||
LogAuditWithRequest(c, "project", projectID.String(), "environment.create", map[string]interface{}{"name": name})
|
||||
c.JSON(http.StatusCreated, gin.H{"environment": toEnvironmentDTO(env.ID, env.Name, env.ProjectID, env.CreatedAt.Time, env.UpdatedAt.Time, 0)})
|
||||
}
|
||||
|
||||
// handleDeleteEnvironment removes an environment that holds no services.
|
||||
func handleDeleteEnvironment(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
envID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid environment ID")
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
respondError(c, http.StatusUnauthorized, "UNAUTHORIZED", "User not authenticated")
|
||||
return
|
||||
}
|
||||
var projectID uuid.UUID
|
||||
if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
|
||||
return
|
||||
}
|
||||
if !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
|
||||
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
|
||||
return
|
||||
}
|
||||
q := sqlcdb.New(db.DB)
|
||||
if n, err := q.CountEnvironmentServices(context.Background(), envID); err == nil && n > 0 {
|
||||
respondError(c, http.StatusConflict, "CONFLICT", "Environment still contains services — move them first")
|
||||
return
|
||||
}
|
||||
if err := q.DeleteProjectEnvironment(context.Background(), sqlcdb.DeleteProjectEnvironmentParams{
|
||||
ID: envID, ProjectID: projectID,
|
||||
}); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete environment")
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "project", projectID.String(), "environment.delete", map[string]interface{}{"environment_id": envID.String()})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Environment deleted"})
|
||||
}
|
||||
@@ -307,6 +307,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
read.GET("/projects", handleGetProjects)
|
||||
read.GET("/projects/:id", handleGetProject)
|
||||
read.GET("/projects/:id/services", handleGetServices)
|
||||
read.GET("/projects/:id/environments", handleListEnvironments)
|
||||
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
|
||||
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
|
||||
@@ -476,6 +477,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
|
||||
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
||||
|
||||
authed.POST("/projects/:id/environments", handleCreateEnvironment)
|
||||
authed.DELETE("/environments/:id", handleDeleteEnvironment)
|
||||
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
|
||||
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
|
||||
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// EnvironmentsCmd manages a project's environment lanes
|
||||
// (production/development/preview are seeded; custom names allowed).
|
||||
var EnvironmentsCmd = &cobra.Command{
|
||||
Use: "environments",
|
||||
Aliases: []string{"env", "envs"},
|
||||
Short: "Manage project environments",
|
||||
}
|
||||
|
||||
var envListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List environments in a project",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, _ := cmd.Flags().GetString("project")
|
||||
if project == "" {
|
||||
return fmt.Errorf("--project is required")
|
||||
}
|
||||
data, err := c.Do("GET", "/projects/"+project+"/environments", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "environments")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, e := range items {
|
||||
rows = append(rows, []string{
|
||||
str(e, "id"), str(e, "name"), str(e, "service_count"), relTime(e, "created_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "SERVICES", "CREATED"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var envCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create an environment in a project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, _ := cmd.Flags().GetString("project")
|
||||
if project == "" {
|
||||
return fmt.Errorf("--project is required")
|
||||
}
|
||||
data, err := c.Do("POST", "/projects/"+project+"/environments", map[string]interface{}{
|
||||
"name": args[0],
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var envDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete an empty environment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !Confirm("Delete environment " + args[0] + "?") {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.Do("DELETE", "/environments/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Environment deleted")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
envListCmd.Flags().String("project", "", "project ID (required)")
|
||||
envCreateCmd.Flags().String("project", "", "project ID (required)")
|
||||
EnvironmentsCmd.AddCommand(envListCmd, envCreateCmd, envDeleteCmd)
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func init() {
|
||||
rootCmd.AddCommand(commands.InvitesCmd)
|
||||
rootCmd.AddCommand(commands.UpCmd)
|
||||
rootCmd.AddCommand(commands.MigrateCmd)
|
||||
rootCmd.AddCommand(commands.EnvironmentsCmd)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
|
||||
@@ -12,6 +12,17 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const countEnvironmentServices = `-- name: CountEnvironmentServices :one
|
||||
SELECT COUNT(*) FROM services WHERE environment_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countEnvironmentServices, environmentID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countProjectsByUser = `-- name: CountProjectsByUser :one
|
||||
SELECT COUNT(*)::bigint AS total
|
||||
FROM projects p
|
||||
@@ -86,6 +97,20 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deleteProjectEnvironment = `-- name: DeleteProjectEnvironment :exec
|
||||
DELETE FROM environments WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type DeleteProjectEnvironmentParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteProjectEnvironment, arg.ID, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
@@ -123,6 +148,30 @@ func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDF
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getProjectEnvironmentByName = `-- name: GetProjectEnvironmentByName :one
|
||||
SELECT id, name, project_id, created_at, updated_at
|
||||
FROM environments
|
||||
WHERE project_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
type GetProjectEnvironmentByNameParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error) {
|
||||
row := q.db.QueryRowContext(ctx, getProjectEnvironmentByName, arg.ProjectID, arg.Name)
|
||||
var i Environment
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.ProjectID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getProjectOwnerByID = `-- name: GetProjectOwnerByID :one
|
||||
SELECT owner_id
|
||||
FROM projects
|
||||
@@ -212,6 +261,53 @@ func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listProjectEnvironments = `-- name: ListProjectEnvironments :many
|
||||
SELECT e.id, e.name, e.project_id, e.created_at, e.updated_at,
|
||||
(SELECT COUNT(*) FROM services s WHERE s.environment_id = e.id) AS service_count
|
||||
FROM environments e
|
||||
WHERE e.project_id = $1
|
||||
ORDER BY e.created_at
|
||||
`
|
||||
|
||||
type ListProjectEnvironmentsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ServiceCount int64 `json:"service_count"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listProjectEnvironments, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListProjectEnvironmentsRow{}
|
||||
for rows.Next() {
|
||||
var i ListProjectEnvironmentsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.ProjectID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ServiceCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
|
||||
|
||||
SELECT
|
||||
|
||||
@@ -17,6 +17,7 @@ type Querier interface {
|
||||
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
||||
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
|
||||
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
|
||||
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
|
||||
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
||||
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
||||
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
@@ -45,6 +46,7 @@ type Querier interface {
|
||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
|
||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
|
||||
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
|
||||
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
|
||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||
@@ -65,6 +67,7 @@ type Querier interface {
|
||||
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
||||
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
||||
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||
GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error)
|
||||
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||
@@ -91,6 +94,7 @@ type Querier interface {
|
||||
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
|
||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListPendingProjects(ctx context.Context) ([]Project, error)
|
||||
ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error)
|
||||
// Visibility model: a project is readable when it is approved for public
|
||||
// display, when the caller owns or is a member of it, or when the caller is a
|
||||
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||
|
||||
@@ -50,6 +50,11 @@ func init() {
|
||||
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
|
||||
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
|
||||
|
||||
// Environments
|
||||
register(spec{"containr_environments_list", "List environments in a project with service counts", "GET", "/projects/{id}/environments", []string{"id"}, nil, false})
|
||||
register(spec{"containr_environments_create", "Create an environment. Args: name (required — lowercase alphanumeric with dashes, 1-50 chars).", "POST", "/projects/{id}/environments", []string{"id"}, []string{"name"}, false})
|
||||
register(spec{"containr_environments_delete", "Delete an empty environment. Requires confirm:true.", "DELETE", "/environments/{id}", []string{"id"}, nil, true})
|
||||
|
||||
// Services
|
||||
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
||||
|
||||
@@ -125,3 +125,21 @@ SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.u
|
||||
FROM projects p
|
||||
WHERE p.is_approved = false
|
||||
ORDER BY p.created_at DESC;
|
||||
|
||||
-- name: ListProjectEnvironments :many
|
||||
SELECT e.id, e.name, e.project_id, e.created_at, e.updated_at,
|
||||
(SELECT COUNT(*) FROM services s WHERE s.environment_id = e.id) AS service_count
|
||||
FROM environments e
|
||||
WHERE e.project_id = $1
|
||||
ORDER BY e.created_at;
|
||||
|
||||
-- name: GetProjectEnvironmentByName :one
|
||||
SELECT id, name, project_id, created_at, updated_at
|
||||
FROM environments
|
||||
WHERE project_id = $1 AND name = $2;
|
||||
|
||||
-- name: DeleteProjectEnvironment :exec
|
||||
DELETE FROM environments WHERE id = $1 AND project_id = $2;
|
||||
|
||||
-- name: CountEnvironmentServices :one
|
||||
SELECT COUNT(*) FROM services WHERE environment_id = $1;
|
||||
Reference in new issue
Block a user