package platform import ( "context" "database/sql" "net/http" "strings" ) func (s *service) workspaces(w http.ResponseWriter, r *http.Request, project, account string) error { switch r.Method { case http.MethodGet: rows, err := s.db.QueryContext(r.Context(), ` SELECT w.id::text,w.name,m.role,w.owner_id::text FROM workspaces w JOIN workspace_members m ON (m.project,m.workspace_id)=(w.project,w.id) WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200 `, project, account) if err != nil { return err } defer rows.Close() items := []response{} for rows.Next() { var id, name, role, owner string if err := rows.Scan(&id, &name, &role, &owner); err != nil { return err } items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner}) } if err := rows.Err(); err != nil { return err } reply(w, 200, response{"items": items}) case http.MethodPost: var input struct { Name string `json:"name"` } if !decode(w, r, &input) { return nil } input.Name = strings.TrimSpace(input.Name) if input.Name == "" || len(input.Name) > 160 { reject(w, 400, "invalid_workspace") return nil } tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() id, err := createWorkspace(r.Context(), tx, project, account, input.Name) if err != nil { return err } if err = tx.Commit(); err != nil { return err } reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account}) default: reject(w, 405, "method_not_allowed") } return nil } func createWorkspace(ctx context.Context, tx *sql.Tx, project, account, name string) (string, error) { var id string err := tx.QueryRowContext(ctx, ` INSERT INTO workspaces(project,name,owner_id) VALUES($1,$2,$3) RETURNING id::text `, project, name, account).Scan(&id) if err != nil { return "", err } _, err = tx.ExecContext(ctx, ` INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin') `, project, id, account) return id, err } func lockWorkspace(ctx context.Context, tx *sql.Tx, project, workspace, account string) (string, string, error) { var owner, role string err := tx.QueryRowContext(ctx, ` SELECT owner_id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE `, project, workspace).Scan(&owner) if err != nil { return "", "", err } // Read membership after the workspace lock, so concurrent revocation is visible. err = tx.QueryRowContext(ctx, ` SELECT role FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3 `, project, workspace, account).Scan(&role) return owner, role, err } func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, account string) error { workspace := r.PathValue("workspace") tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) if err != nil { return err } switch r.Method { case http.MethodGet: var name string if err := tx.QueryRowContext(r.Context(), "SELECT name FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name); err != nil { return err } reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner}) return nil case http.MethodPatch: if role != "admin" { reject(w, 403, "forbidden") return nil } var input struct { Name string `json:"name"` } if !decode(w, r, &input) { return nil } input.Name = strings.TrimSpace(input.Name) if input.Name == "" || len(input.Name) > 160 { reject(w, 400, "invalid_workspace") return nil } _, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name) case http.MethodDelete: if owner != account { reject(w, 403, "owner_required") return nil } _, err = tx.ExecContext(r.Context(), "DELETE FROM workspaces WHERE project=$1 AND id=$2", project, workspace) default: reject(w, 405, "method_not_allowed") return nil } if err != nil { return err } if err = tx.Commit(); err != nil { return err } reply(w, 204, nil) return nil } func (s *service) members(w http.ResponseWriter, r *http.Request, project, account string) error { if r.Method != http.MethodGet { reject(w, 405, "method_not_allowed") return nil } rows, err := s.db.QueryContext(r.Context(), ` SELECT m.account_id::text,m.role,a.username,a.display_name FROM workspace_members m JOIN project_accounts a ON (a.project,a.id)=(m.project,m.account_id) WHERE m.project=$1 AND m.workspace_id=$2 AND EXISTS( SELECT 1 FROM workspace_members caller WHERE caller.project=$1 AND caller.workspace_id=$2 AND caller.account_id=$3) ORDER BY m.joined_at,m.account_id LIMIT 200 `, project, r.PathValue("workspace"), account) if err != nil { return err } defer rows.Close() items := []response{} for rows.Next() { var id, role, name string var username *string if err := rows.Scan(&id, &role, &username, &name); err != nil { return err } items = append(items, response{"account_id": id, "role": role, "username": username, "display_name": name}) } if err := rows.Err(); err != nil { return err } if len(items) == 0 { return sql.ErrNoRows } reply(w, 200, response{"items": items}) return nil } func (s *service) member(w http.ResponseWriter, r *http.Request, project, account string) error { workspace, target := r.PathValue("workspace"), r.PathValue("account") if r.Method != http.MethodPatch && r.Method != http.MethodDelete { reject(w, 405, "method_not_allowed") return nil } tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) if err != nil { return err } if owner == target { reject(w, 409, "owner_membership_required") return nil } if role != "admin" && (r.Method != http.MethodDelete || target != account) { reject(w, 403, "forbidden") return nil } var result sql.Result if r.Method == http.MethodPatch { var input struct { Role string `json:"role"` } if !decode(w, r, &input) { return nil } if input.Role != "admin" && input.Role != "editor" && input.Role != "viewer" { reject(w, 400, "invalid_role") return nil } result, err = tx.ExecContext(r.Context(), "UPDATE workspace_members SET role=$4 WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target, input.Role) } else { result, err = tx.ExecContext(r.Context(), "DELETE FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target) } if err != nil { return err } n, err := result.RowsAffected() if err != nil { return err } if n == 0 { return sql.ErrNoRows } if err = tx.Commit(); err != nil { return err } reply(w, 204, nil) return nil }