mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-10 14:58:25 +02:00
Drop the Space `description` field across the whole stack: the workspace_space column (migration 150), sqlc queries + generated code, the handler request/response, core types + zod schema, the `multica space` CLI `--description` flag, and the create/detail forms + i18n. Spaces keep name / key / icon; workspace / project / issue descriptions are untouched. Also reverts the short-lived "inject Space description into the agent brief" chain (space_description through the daemon task pipeline and the `multica space get` CLI) — with the field gone there is nothing to inject. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
614 lines
20 KiB
Go
614 lines
20 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
type SpaceResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Key string `json:"key"`
|
|
Icon *string `json:"icon"`
|
|
IssueCounter int32 `json:"issue_counter"`
|
|
ArchivedAt *string `json:"archived_at"`
|
|
CreatedBy *string `json:"created_by"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
// Requesting user's membership view: the sidebar shows only joined
|
|
// spaces, ordered by SortOrder (per-user fractional position; the first
|
|
// space doubles as the issue-creation default when no context applies).
|
|
IsMember bool `json:"is_member"`
|
|
SortOrder float64 `json:"sort_order"`
|
|
}
|
|
|
|
func spaceToResponse(t db.WorkspaceSpace) SpaceResponse {
|
|
return SpaceResponse{
|
|
ID: uuidToString(t.ID),
|
|
WorkspaceID: uuidToString(t.WorkspaceID),
|
|
Name: t.Name,
|
|
Key: t.Key,
|
|
Icon: textToPtr(t.Icon),
|
|
IssueCounter: t.IssueCounter,
|
|
ArchivedAt: timestampToPtr(t.ArchivedAt),
|
|
CreatedBy: uuidToPtr(t.CreatedBy),
|
|
CreatedAt: timestampToString(t.CreatedAt),
|
|
UpdatedAt: timestampToString(t.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
type CreateSpaceRequest struct {
|
|
Name string `json:"name"`
|
|
Key string `json:"key"`
|
|
Icon *string `json:"icon"`
|
|
// MemberIDs invites workspace members into the new space alongside the
|
|
// creator (who always joins as lead). Minimal v1 membership loop —
|
|
// there is no separate join/leave API yet, so a space is only visible in
|
|
// its members' sidebars.
|
|
MemberIDs []string `json:"member_ids"`
|
|
}
|
|
|
|
type UpdateSpaceRequest struct {
|
|
Name *string `json:"name"`
|
|
Key *string `json:"key"`
|
|
Icon *string `json:"icon"`
|
|
}
|
|
|
|
func (h *Handler) ListSpaces(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := h.Queries.ListWorkspaceSpacesForUser(r.Context(), db.ListWorkspaceSpacesForUserParams{
|
|
WorkspaceID: wsUUID,
|
|
UserID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list spaces")
|
|
return
|
|
}
|
|
resp := make([]SpaceResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = spaceToResponse(row.WorkspaceSpace)
|
|
resp[i].IsMember = row.IsMember
|
|
resp[i].SortOrder = row.MemberSortOrder
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"spaces": resp, "total": len(resp)})
|
|
}
|
|
|
|
func (h *Handler) CreateSpace(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateSpaceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
key := normalizeSpaceKey(req.Key)
|
|
if key == "" {
|
|
key = defaultSpaceKeyFromSlug(req.Name)
|
|
}
|
|
if !validSpaceKey(key) {
|
|
writeError(w, http.StatusBadRequest, "identifier must match ^[A-Z][A-Z0-9]{0,6}$ and must not be a reserved word (e.g. NEW)")
|
|
return
|
|
}
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
memberIDs := make([]pgtype.UUID, 0, len(req.MemberIDs))
|
|
creatorUUID := parseUUID(userID)
|
|
for _, raw := range req.MemberIDs {
|
|
uid, ok := parseUUIDOrBadRequest(w, raw, "member_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
if uid == creatorUUID {
|
|
continue // creator joins as lead below
|
|
}
|
|
memberIDs = append(memberIDs, uid)
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create space")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
space, err := qtx.CreateWorkspaceSpace(r.Context(), db.CreateWorkspaceSpaceParams{
|
|
WorkspaceID: wsUUID,
|
|
Name: req.Name,
|
|
Key: key,
|
|
Icon: ptrToText(req.Icon),
|
|
CreatedBy: creatorUUID,
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) || isCheckViolation(err) {
|
|
writeError(w, http.StatusBadRequest, "space identifier is invalid or already used")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to create space")
|
|
return
|
|
}
|
|
// The creator always joins as lead — a space invisible in its creator's
|
|
// own sidebar would be unreachable (sidebar shows joined spaces only).
|
|
creatorSort, err := addSpaceMember(r.Context(), qtx, wsUUID, space.ID, creatorUUID, "lead")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to add space members")
|
|
return
|
|
}
|
|
for _, uid := range memberIDs {
|
|
if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: uid,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "member_ids must be members of this workspace")
|
|
return
|
|
}
|
|
if _, err := addSpaceMember(r.Context(), qtx, wsUUID, space.ID, uid, "member"); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to add space members")
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create space")
|
|
return
|
|
}
|
|
|
|
resp := spaceToResponse(space)
|
|
resp.IsMember = true
|
|
resp.SortOrder = creatorSort
|
|
h.publish(protocol.EventWorkspaceUpdated, workspaceID, "member", userID, map[string]any{"space": resp})
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// addSpaceMember appends the user to the space at the end of their personal
|
|
// space order and returns the assigned sort position.
|
|
func addSpaceMember(ctx context.Context, q *db.Queries, wsUUID, spaceID, userID pgtype.UUID, role string) (float64, error) {
|
|
sort, err := q.NextSpaceMemberSortOrder(ctx, db.NextSpaceMemberSortOrderParams{
|
|
WorkspaceID: wsUUID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := q.AddWorkspaceSpaceMember(ctx, db.AddWorkspaceSpaceMemberParams{
|
|
WorkspaceID: wsUUID,
|
|
SpaceID: spaceID,
|
|
UserID: userID,
|
|
Role: role,
|
|
SortOrder: sort,
|
|
}); err != nil {
|
|
return 0, err
|
|
}
|
|
return sort, nil
|
|
}
|
|
|
|
type UpdateSpaceMembershipRequest struct {
|
|
SortOrder *float64 `json:"sort_order"`
|
|
}
|
|
|
|
type ReplaceSpaceMembersRequest struct {
|
|
MemberIDs []string `json:"member_ids"`
|
|
}
|
|
|
|
// ReplaceSpaceMembers sets a space's member list wholesale. Anyone in the
|
|
// workspace may configure any space's members — membership only drives the
|
|
// sidebar and personal defaults, never access, so there is no extra
|
|
// permission layer. Kept rows are untouched (their sort_order and role
|
|
// survive); added members land at the end of their own personal order. An
|
|
// empty list is rejected: zero members means "archive the space", which the
|
|
// client performs explicitly via DELETE /api/spaces/{id} after a confirm.
|
|
func (h *Handler) ReplaceSpaceMembers(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
spaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "space id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req ReplaceSpaceMembersRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if len(req.MemberIDs) == 0 {
|
|
writeError(w, http.StatusBadRequest, "empty membership archives the space — archive it instead")
|
|
return
|
|
}
|
|
if _, err := service.ValidateActiveSpace(r.Context(), h.Queries, wsUUID, spaceID); err != nil {
|
|
if !writeSpaceResolveError(w, err) {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
}
|
|
return
|
|
}
|
|
next := make(map[pgtype.UUID]struct{}, len(req.MemberIDs))
|
|
for _, raw := range req.MemberIDs {
|
|
uid, ok := parseUUIDOrBadRequest(w, raw, "member_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
next[uid] = struct{}{}
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update space members")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
current, err := qtx.ListWorkspaceSpaceMembers(r.Context(), db.ListWorkspaceSpaceMembersParams{
|
|
WorkspaceID: wsUUID,
|
|
SpaceID: spaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load space members")
|
|
return
|
|
}
|
|
currentSet := make(map[pgtype.UUID]struct{}, len(current))
|
|
for _, m := range current {
|
|
currentSet[m.UserID] = struct{}{}
|
|
}
|
|
for uid := range next {
|
|
if _, kept := currentSet[uid]; kept {
|
|
continue
|
|
}
|
|
if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: uid,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "member_ids must be members of this workspace")
|
|
return
|
|
}
|
|
if _, err := addSpaceMember(r.Context(), qtx, wsUUID, spaceID, uid, "member"); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update space members")
|
|
return
|
|
}
|
|
}
|
|
for _, m := range current {
|
|
if _, keep := next[m.UserID]; keep {
|
|
continue
|
|
}
|
|
if _, err := qtx.RemoveWorkspaceSpaceMember(r.Context(), db.RemoveWorkspaceSpaceMemberParams{
|
|
WorkspaceID: wsUUID,
|
|
SpaceID: spaceID,
|
|
UserID: m.UserID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update space members")
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update space members")
|
|
return
|
|
}
|
|
h.listSpaceMembersResponse(w, r, wsUUID, spaceID)
|
|
}
|
|
|
|
type SpaceMemberResponse struct {
|
|
UserID string `json:"user_id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
Role string `json:"role"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// ListSpaceMembers lists a space's members with user display data. Membership
|
|
// is configured wholesale via ReplaceSpaceMembers.
|
|
func (h *Handler) ListSpaceMembers(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
spaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "space id")
|
|
if !ok {
|
|
return
|
|
}
|
|
h.listSpaceMembersResponse(w, r, wsUUID, spaceID)
|
|
}
|
|
|
|
// listSpaceMembersResponse writes the members payload shared by the GET and
|
|
// the PUT (replace) endpoints.
|
|
func (h *Handler) listSpaceMembersResponse(w http.ResponseWriter, r *http.Request, wsUUID, spaceID pgtype.UUID) {
|
|
rows, err := h.Queries.ListWorkspaceSpaceMembersWithUser(r.Context(), db.ListWorkspaceSpaceMembersWithUserParams{
|
|
WorkspaceID: wsUUID,
|
|
SpaceID: spaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list space members")
|
|
return
|
|
}
|
|
resp := make([]SpaceMemberResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = SpaceMemberResponse{
|
|
UserID: uuidToString(row.UserID),
|
|
Name: row.UserName,
|
|
Email: row.UserEmail,
|
|
AvatarURL: textToPtr(row.UserAvatarUrl),
|
|
Role: row.Role,
|
|
CreatedAt: timestampToString(row.CreatedAt),
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"members": resp, "total": len(resp)})
|
|
}
|
|
|
|
// UpdateSpaceMembership updates the caller's own membership row — currently
|
|
// just sort_order, the per-user sidebar position. Fractional: a drag sends
|
|
// the midpoint of the drop slot's neighbors, so single-row updates suffice.
|
|
func (h *Handler) UpdateSpaceMembership(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
spaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "space id")
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req UpdateSpaceMembershipRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SortOrder == nil {
|
|
writeError(w, http.StatusBadRequest, "sort_order is required")
|
|
return
|
|
}
|
|
m, err := h.Queries.UpdateSpaceMemberSortOrder(r.Context(), db.UpdateSpaceMemberSortOrderParams{
|
|
WorkspaceID: wsUUID,
|
|
SpaceID: spaceID,
|
|
UserID: parseUUID(userID),
|
|
SortOrder: *req.SortOrder,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "you are not a member of this space")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"space_id": uuidToString(m.SpaceID),
|
|
"sort_order": m.SortOrder,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) UpdateSpace(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
spaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "space id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req UpdateSpaceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
params := db.UpdateWorkspaceSpaceParams{
|
|
ID: spaceID,
|
|
WorkspaceID: wsUUID,
|
|
}
|
|
if req.Name != nil {
|
|
name := strings.TrimSpace(*req.Name)
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
params.Name = pgtype.Text{String: name, Valid: true}
|
|
}
|
|
if req.Key != nil {
|
|
key := normalizeSpaceKey(*req.Key)
|
|
if !validSpaceKey(key) {
|
|
writeError(w, http.StatusBadRequest, "identifier must match ^[A-Z][A-Z0-9]{0,6}$ and must not be a reserved word (e.g. NEW)")
|
|
return
|
|
}
|
|
// Key changes are admin-only: the key is the workspace-wide issue
|
|
// identifier namespace, and the legacy workspace issue_prefix path
|
|
// (admin-gated at the router) funnels into the same row — both
|
|
// doors must carry the same gate.
|
|
current, err := h.Queries.GetWorkspaceSpace(r.Context(), db.GetWorkspaceSpaceParams{
|
|
ID: spaceID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "space not found")
|
|
return
|
|
}
|
|
if current.Key != key {
|
|
member, ok := ctxMember(r.Context())
|
|
if !ok || !roleAllowed(member.Role, "owner", "admin") {
|
|
writeError(w, http.StatusForbidden, "only workspace admins can change the space key")
|
|
return
|
|
}
|
|
}
|
|
params.Key = pgtype.Text{String: key, Valid: true}
|
|
}
|
|
if req.Icon != nil {
|
|
params.Icon = pgtype.Text{String: *req.Icon, Valid: true}
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
space, err := updateWorkspaceSpaceLocked(r.Context(), qtx, params)
|
|
if err != nil {
|
|
if isUniqueViolation(err) || isCheckViolation(err) {
|
|
writeError(w, http.StatusBadRequest, "space identifier is invalid or already used")
|
|
return
|
|
}
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusNotFound, "space not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to update space")
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to commit space update")
|
|
return
|
|
}
|
|
resp := spaceToResponse(space)
|
|
userID := requestUserID(r)
|
|
resp = h.withCallerMembership(r.Context(), resp, space.ID, userID)
|
|
h.publish(protocol.EventWorkspaceUpdated, workspaceID, "member", userID, map[string]any{"space": resp})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// withCallerMembership stamps the caller's membership view onto a single-space
|
|
// response so mutations don't clobber is_member/sort_order in the client's
|
|
// list cache (the list endpoint always carries them).
|
|
func (h *Handler) withCallerMembership(ctx context.Context, resp SpaceResponse, spaceID pgtype.UUID, userID string) SpaceResponse {
|
|
m, err := h.Queries.GetWorkspaceSpaceMember(ctx, db.GetWorkspaceSpaceMemberParams{
|
|
SpaceID: spaceID,
|
|
UserID: parseUUID(userID),
|
|
})
|
|
if err == nil {
|
|
resp.IsMember = true
|
|
resp.SortOrder = m.SortOrder
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func updateWorkspaceSpaceLocked(ctx context.Context, qtx *db.Queries, params db.UpdateWorkspaceSpaceParams) (db.WorkspaceSpace, error) {
|
|
locked, err := qtx.LockWorkspaceSpaceForKeyUpdate(ctx, db.LockWorkspaceSpaceForKeyUpdateParams{
|
|
ID: params.ID,
|
|
WorkspaceID: params.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return db.WorkspaceSpace{}, err
|
|
}
|
|
// When the identifier changes on a space that already holds issues, every
|
|
// existing OLDKEY-N would stop resolving (identifiers are derived from the
|
|
// space key at read time). Record an alias per issue under the old key
|
|
// first, so GitHub/CLI/link references keep landing on the issue.
|
|
if params.Key.Valid && params.Key.String != locked.Key && locked.IssueCounter > 0 {
|
|
if err := qtx.BackfillSpaceKeyAliases(ctx, db.BackfillSpaceKeyAliasesParams{
|
|
WorkspaceID: params.WorkspaceID,
|
|
SpaceID: params.ID,
|
|
SpaceKeyLower: strings.ToLower(locked.Key),
|
|
}); err != nil {
|
|
return db.WorkspaceSpace{}, fmt.Errorf("backfill identifier aliases: %w", err)
|
|
}
|
|
}
|
|
return qtx.UpdateWorkspaceSpace(ctx, params)
|
|
}
|
|
|
|
func (h *Handler) ArchiveSpace(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
spaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "space id")
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Archiving is admin-only, matching the destructive-op convention
|
|
// (project delete, squad delete). Everything else on a space stays
|
|
// member-open — membership is not a permission layer.
|
|
if member, ok := ctxMember(r.Context()); !ok || !roleAllowed(member.Role, "owner", "admin") {
|
|
writeError(w, http.StatusForbidden, "only workspace admins can archive a space")
|
|
return
|
|
}
|
|
// Block archiving a Space that still drives live autopilots — without this
|
|
// an archived Space would leave active autopilots pointing at a Space that
|
|
// can no longer receive work. Existing issues are intentionally NOT a
|
|
// blocker: archived-space issues stay readable.
|
|
activeAutopilots, err := h.Queries.CountActiveAutopilotsBySpace(r.Context(), db.CountActiveAutopilotsBySpaceParams{
|
|
WorkspaceID: wsUUID,
|
|
SpaceID: spaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to validate space usage")
|
|
return
|
|
}
|
|
if activeAutopilots > 0 {
|
|
writeError(w, http.StatusConflict, "cannot archive a space used by active autopilots")
|
|
return
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to archive space")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
// No space is permanently protected — instead every workspace must always
|
|
// keep at least one active Space. FOR UPDATE locks every active Space row
|
|
// for this workspace so a concurrent archive on a different Space in the
|
|
// same workspace serializes behind this one; without the lock, two
|
|
// concurrent archives of the last two Spaces could both read "2 active"
|
|
// and both proceed, leaving zero.
|
|
active, err := qtx.ListActiveWorkspaceSpacesForUpdate(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to validate space usage")
|
|
return
|
|
}
|
|
if len(active) <= 1 {
|
|
writeError(w, http.StatusConflict, "cannot archive the last active space in a workspace")
|
|
return
|
|
}
|
|
|
|
space, err := qtx.ArchiveWorkspaceSpace(r.Context(), db.ArchiveWorkspaceSpaceParams{
|
|
ID: spaceID,
|
|
WorkspaceID: wsUUID,
|
|
ArchivedBy: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "space cannot be archived")
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to archive space")
|
|
return
|
|
}
|
|
resp := h.withCallerMembership(r.Context(), spaceToResponse(space), space.ID, userID)
|
|
h.publish(protocol.EventWorkspaceUpdated, workspaceID, "member", userID, map[string]any{"space": resp})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|