Skip to main content

Resource: Spaces (+ SpaceLayouts, SpaceTags)

Tag: Spaces (single tag — space-layout and space-tag ops fold under it per C9; they are sub-resources of Space, not their own tags) · Module: space (extracted from organization)

The persistent 2D collaboration environment a team inhabits. Named Space (not "office" or "room") on the wire — the N1 term, settled 2026-06-15 when the product broadened beyond work offices: "office" was too narrow (a space's backdrop can be a lecture hall, lounge, or event floor — those are just SpaceLayouts), so the neutral category term wins (it's also Gather's core noun for the same concept). The DB table stays private.rooms internally (behind the repository — no rename); only the wire term and the new module are Space. The former "open space" zone (the open, non-room area) is renamed "open area" in lockstep so it no longer collides with the container term.

Implied FE refactor (not this pass): officeLayoutStore, assets/layouts, the virtual-office module / office feature, RoomData/roomId, and the literal "open space" zone term all rename to the Space / open-area vocabulary. Large real-code change — tracked as its own migration.

This resource is delivered as one unit: the module extraction off organization, the endpoint reshape, the type fixes, and the new SpaceLayout/SpaceTag sub-resources.


Operations

Space (CRUD)

OperationIDMethod + PathRBACNotes
create-spacePOST /spaces/createCreateRoomreturns the created Space
list-spacesPOST /spaces/listListRoomsname filter + tag filter + sort (below)
get-spacePOST /spaces/{id}/getListRoomsid = space UUID
update-spacePOST /spaces/{id}/updateUpdateRoompartial patch
delete-spacePOST /spaces/{id}/deleteDeleteRoom

SpaceLayouts (sub-resource — Spaces tag)

| list-space-layouts | POST /space-layouts/list | ListRooms | org-scoped, server-authored layout set |

SpaceTags (sub-resource — Spaces tag)

| list-space-tags | POST /space-tags/list | ListRooms | | | create-space-tag | POST /space-tags/create | ManageSpaceTags | new permission | | update-space-tag | POST /space-tags/{id}/update | ManageSpaceTags | | | delete-space-tag | POST /space-tags/{id}/delete | ManageSpaceTags | |

RBAC keeps its internal *Room permission names (like the DB table, they're behind the boundary); only the net-new permission is Space-named (ManageSpaceTags).

The 6 legacy room ops collapse to 5 CRUD ops: room-by-name is a name filter on list-spaces, and room-by-id is get-space. LiveKit room-auth is not here — it belongs to the Realtime resource.


Identifier — space UUID

Spaces key on RoomId uuid.UUID (room.go:13, uniqueIndex) — opaque, non-enumerable, already a string. The int64 PK is internal and never crosses the wire.


DTOs

Space

{
"id": "uuid",
"name": "string",
"description": "string",
"layoutId": "uuid", // references a SpaceLayout (see §Layouts)
"tags": [ /* SpaceTag */ ], // see §Tags
"occupantUserIds": ["string"], // who is in the space now — snapshot (see §Occupancy)
"createdAt": "2026-06-14T10:00:00Z", // RFC3339
"updatedAt": "2026-06-14T10:00:00Z" // RFC3339
}

create-space

// Request
{
"name": "string", // required
"description?": "string", // C8 — optional
"layoutId": "uuid", // required
"tagIds?": ["uuid"] // C8 — optional
}
// Response: the created Space

list-spaces

// Request
{
"query?": "string", // C8 — optional, free-text search on space name
"tagIds?": ["uuid"], // C8 — optional, match spaces carrying ANY of these tags
"sort?": "relevance", // C8 — optional; relevance | nameAsc | nameDesc | occupancy
"limit": 20, // 1..100, default 20
"offset": 0
}
// Response
{ "data": [ /* Space */ ], "page": { "limit": 20, "offset": 0, "total": 42 } }

sort values: nameAsc/nameDesc alphabetical; occupancy by occupantUserIds length (most occupied first); relevance is a personalized server-side ranking from the requesting user's context (spaces where their recent DMs occurred, spaces tagged like their team, spaces where teammates are present). The relevance formula is computed server-side and may evolve without a contract change.

get-space / delete-space ({id} in path)

// Request body — empty {}
// get-space → the Space object directly (no wrapper)
// delete-space → 204 No Content

update-space ({id} in path) — partial patch, only provided fields change

{
"name?": "string", // C8 — all fields optional (partial patch)
"description?": "string",
"layoutId?": "uuid",
"tagIds?": ["uuid"] // replaces the space's tag set
}
// Response: the updated Space

§Layouts — SpaceLayout

A space's visual template. The legacy (layout, dimension, style) triple is reified into a first-class resource with a surrogate id, and the space references a single layoutId. This makes validation a simple FK check, keeps updates to one field, guarantees a non-null layout via a default, and gives the client a server-authored list of selectable layouts (which it cannot derive otherwise — the legacy valid_room_combinations table is exposed by no endpoint today). Layouts are how a space's setting varies (office, lecture hall, lounge, …) without changing what a Space is.

// SpaceLayout
{
"id": "uuid",
"name": "string", // display name for the picker
"layout": "string", // the original triple, encapsulated here
"dimension": "string",
"style": "string",
"thumbnailUrl": "string | null"
}
// list-space-layouts → { "data": [SpaceLayout], "page": {...} }
  • list-space-layouts returns the selectable layouts. There is no availability field — a layout that isn't usable is simply not returned.
  • A space's layoutId must reference an existing layout; a default layout always exists.
  • Org-specific custom layouts are a future extension and are not modeled yet (YAGNI).

Schema: space_layouts table (= valid_room_combinations + id, name, thumbnail); rooms.layout_id uuid FK; backfill existing rooms' triples → layout ids; then drop rooms.layout/dimension/style.

§Tags — SpaceTag

Org-scoped tags an organization defines and applies to spaces (e.g. "Engineering", "Marketing").

// SpaceTag
{ "id": "uuid", "name": "string", "color": "string | null" }
  • Schema: space_tags (id, org_id, name, color) + space_tag_assignments (space_id, tag_id) M2M.
  • Tag CRUD requires the ManageSpaceTags permission. Spaces receive tags via tagIds on create/update; list-spaces filters by tagIds (ANY match).

§Occupancy — occupantUserIds

Stringified user ids currently in the space — a point-in-time snapshot. "Occupant" names the spatial fact (who is in this space), independent of transport — not "online" (globally connected) or "connected" (implies a connection mechanism). Sourced today from the room_presence table (per-space, mirroring metrics/.../CountByOrg); that table will later be replaced by a dedicated realtime service, so the field is source-agnostic and the contract does not change when the source does. Count is derived (length); the occupancy sort orders by it. The list returns the initial snapshot only — live updates flow through the realtime/presence channel, not by re-fetching spaces.


Module extraction (backend)

New internal/features/space/ (standard module layout), landing with this resource:

  • space/api/organization/api/room_*.go, renamed space_{create,get,list,update,delete} (by_id folds into get, by_name into list).
  • space/service/organization/service/room_*.go.
  • space/models/RoomDTOSpace, RoomCreateRequestCreateSpaceRequest, etc.
  • space/module/ + routes.go ← DI + RegisterRoutes.
  • Shared, unchanged: domain/database/room.go + the room repository (internal/repository) — also used by the realtime auth feature.
  • New tables/repos: space_layouts, space_tags, space_tag_assignments (+ the room_presence per-space count query).
  • organization shrinks to org-only (CreateOrganization, UpdateOrganization, SwitchOrganization).
  • The duplicated PaginationMaxLimit/DefaultLimit consts move to a shared package (see members pass).

Scope note

Beyond a thin extraction, this resource adds 3 tables, a layout-id migration (backfill + column drop), the ManageSpaceTags permission, and a per-space presence query — real Phase-2 work to budget. Plus the implied FE rename (Space / open-area vocabulary) noted up top.

Deferred / verify at implementation

  • The relevance ranking formula (signals settled; exact weighting decided later — no contract impact).
  • Confirm no live rooms hold null layout/dimension/style before the backfill (a default covers any that do).