Files
multica/server/internal/handler/space.go
Naiyuan Qing afc7ac4bd0 feat: space rollout — space-first navigation, per-user membership, move-to-space (MUL-4142)
Squashed history of PR #4892 (pr-4784-fix). Makes spaces the primary
navigation and working surface, on the "associations bind at creation
time only" model.

Model
- Issue <-> space is the only enforced ownership (per-space numbering).
  Parent/child and project<->space associations only seed defaults at
  creation; cross-space/child and project-association validations are
  removed.
- Moving an issue renumbers it and records the old identifier in
  issue_identifier_alias; API/CLI lookups and GitHub branch/PR
  auto-linking fall back to the alias, so old identifiers resolve forever.
- Membership drives only the sidebar and personal defaults — never
  access. Anyone can configure any space's member set wholesale
  (PUT /api/spaces/{id}/members); saving an empty set archives the
  space behind a confirm.
- Per-user space order (workspace_space_member.sort_order, fractional):
  drag-sorted sidebar, "my first space" is the personal issue-creation
  default; the workspace default space backs headless creation
  (agents/CLI/Slack) and system placement.

Surfaces
- Sidebar: joined-spaces section (drag reorder, row -> space page,
  per-group persisted collapse), Workspace group with a More menu,
  Settings demoted to a footer icon.
- /space/:key/{issues,projects,autopilots,settings} — space surfaces
  reuse shared page components; a routed /space/new create page
  (replacing the earlier create-space modal), reserved key "NEW" so it
  can never collide with a real space's /space/:key detail page.
- Issue detail moves to /issue/:id (identifier-first, Linear-style; old
  /issues/:id redirects); create dialogs lead with a required space pill.
- Agent runtime brief now carries Space context (id/key/name) through
  the daemon claim -> TaskContextForEnv -> prompt pipeline, with a
  "## Space Context" section and --space on issue create/update in both
  brief renderers, matching Project's existing treatment.
- zh-Hans: Space translated to 空间 across locales and conventions.zh.mdx.

Fixes along the way
- Cache membership judgment gains the space dimension + space_changed
  WS flag.
- Silent skip on default-space lookup during invite acceptance is now a
  hard failure (no space-less members).
- Backfilled space_id into ~28 raw-SQL Go test fixtures across
  internal/handler and cmd/server that predated migration 132's NOT
  NULL cutover.
- Fixed a resolve-loop bug in the IssueDetail identifier wrapper (mount/
  unmount cycle on resolution failure) and gave it its own loading
  skeleton instead of a blank screen while resolving.
- reserved-slugs generator's stale hardcoded doc-comment example synced
  back to /create-space.

Verification
- go build ./..., go vet ./..., go test ./... all clean.
- pnpm typecheck (core/views/web/desktop) clean.
- packages/views: 162 files / 1656 tests passing.
- pnpm generate:reserved-slugs produces no diff.

Follow-ups tracked in docs/follow-ups/space-rollout.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 20:57:38 +08:00

617 lines
20 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"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"
)
var errSpaceKeyFrozen = errors.New("space identifier cannot be changed after issues have been created")
type SpaceResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Key string `json:"key"`
Description string `json:"description"`
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,
Description: t.Description,
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"`
Description *string `json:"description"`
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"`
Description *string `json:"description"`
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,
Description: ptrToText(req.Description),
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.Description != nil {
params.Description = pgtype.Text{String: *req.Description, 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 errors.Is(err, errSpaceKeyFrozen) {
writeError(w, http.StatusConflict, "space identifier cannot be changed after issues have been created")
return
}
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
}
if params.Key.Valid && params.Key.String != locked.Key && locked.IssueCounter > 0 {
return db.WorkspaceSpace{}, errSpaceKeyFrozen
}
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)
}