feat: TASK 034-036 - MicroDAO Multi-Room Support
TASK 034: MicroDAO Multi-Room Backend
- Added migration 031_microdao_multi_room.sql
- Extended city_rooms with microdao_id, room_role, is_public, sort_order
- Added CityRoomSummary, MicrodaoRoomsList, MicrodaoRoomUpdate models
- Added get_microdao_rooms, get_microdao_rooms_by_slug functions
- Added attach_room_to_microdao, update_microdao_room functions
- Added API endpoints: GET/POST/PATCH /city/microdao/{slug}/rooms
TASK 035: MicroDAO Multi-Room UI
- Added proxy routes for rooms API
- Extended CityRoomSummary type with multi-room fields
- Added useMicrodaoRooms hook
- Created MicrodaoRoomsSection component with role labels/icons
TASK 036: MicroDAO Room Orchestrator Panel
- Created MicrodaoRoomsAdminPanel component
- Role selector, visibility toggle, set primary button
- Attach existing room form
- Integrated into /microdao/[slug] page
This commit is contained in:
@@ -409,11 +409,39 @@ class MicrodaoAgentView(BaseModel):
|
||||
|
||||
|
||||
class CityRoomSummary(BaseModel):
|
||||
"""Summary of a city room for chat embedding"""
|
||||
"""Summary of a city room for chat embedding and multi-room support"""
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
matrix_room_id: Optional[str] = None
|
||||
microdao_id: Optional[str] = None
|
||||
microdao_slug: Optional[str] = None
|
||||
room_role: Optional[str] = None # 'primary', 'lobby', 'team', 'research', 'security', 'governance'
|
||||
is_public: bool = True
|
||||
sort_order: int = 100
|
||||
|
||||
|
||||
class MicrodaoRoomsList(BaseModel):
|
||||
"""List of rooms belonging to a MicroDAO"""
|
||||
microdao_id: str
|
||||
microdao_slug: str
|
||||
rooms: List[CityRoomSummary] = []
|
||||
|
||||
|
||||
class MicrodaoRoomUpdate(BaseModel):
|
||||
"""Update request for MicroDAO room settings"""
|
||||
room_role: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
set_primary: Optional[bool] = None # if true, mark as primary
|
||||
|
||||
|
||||
class AttachExistingRoomRequest(BaseModel):
|
||||
"""Request to attach an existing city room to a MicroDAO"""
|
||||
room_id: str
|
||||
room_role: Optional[str] = None
|
||||
is_public: bool = True
|
||||
sort_order: int = 100
|
||||
|
||||
|
||||
class MicrodaoDetail(BaseModel):
|
||||
@@ -442,6 +470,9 @@ class MicrodaoDetail(BaseModel):
|
||||
logo_url: Optional[str] = None
|
||||
agents: List[MicrodaoAgentView] = []
|
||||
channels: List[MicrodaoChannelView] = []
|
||||
|
||||
# Multi-room support
|
||||
rooms: List[CityRoomSummary] = []
|
||||
public_citizens: List[MicrodaoCitizenView] = []
|
||||
|
||||
# Primary city room for chat
|
||||
|
||||
@@ -1733,7 +1733,7 @@ async def create_microdao_for_agent(
|
||||
async def get_microdao_primary_room(microdao_id: str) -> Optional[dict]:
|
||||
"""
|
||||
Отримати основну кімнату MicroDAO для чату.
|
||||
Пріоритет: primary room → перша публічна кімната → будь-яка кімната.
|
||||
Пріоритет: room_role='primary' → найнижчий sort_order → перша кімната.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
@@ -1742,15 +1742,17 @@ async def get_microdao_primary_room(microdao_id: str) -> Optional[dict]:
|
||||
cr.id,
|
||||
cr.slug,
|
||||
cr.name,
|
||||
cr.matrix_room_id
|
||||
cr.matrix_room_id,
|
||||
cr.microdao_id,
|
||||
cr.room_role,
|
||||
cr.is_public,
|
||||
cr.sort_order
|
||||
FROM city_rooms cr
|
||||
WHERE cr.microdao_id = $1
|
||||
AND cr.is_active = true
|
||||
ORDER BY
|
||||
CASE WHEN cr.room_type = 'primary' THEN 0
|
||||
WHEN cr.room_type = 'public' THEN 1
|
||||
ELSE 2 END,
|
||||
cr.created_at
|
||||
CASE WHEN cr.room_role = 'primary' THEN 0 ELSE 1 END,
|
||||
cr.sort_order ASC,
|
||||
cr.name ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
@@ -1760,7 +1762,195 @@ async def get_microdao_primary_room(microdao_id: str) -> Optional[dict]:
|
||||
"id": str(row["id"]),
|
||||
"slug": row["slug"],
|
||||
"name": row["name"],
|
||||
"matrix_room_id": row.get("matrix_room_id")
|
||||
"matrix_room_id": row.get("matrix_room_id"),
|
||||
"microdao_id": str(row["microdao_id"]) if row.get("microdao_id") else None,
|
||||
"room_role": row.get("room_role"),
|
||||
"is_public": row.get("is_public", True),
|
||||
"sort_order": row.get("sort_order", 100)
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
async def get_microdao_rooms(microdao_id: str) -> List[dict]:
|
||||
"""
|
||||
Отримати всі кімнати MicroDAO, впорядковані за sort_order.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
cr.id,
|
||||
cr.slug,
|
||||
cr.name,
|
||||
cr.matrix_room_id,
|
||||
cr.microdao_id,
|
||||
cr.room_role,
|
||||
cr.is_public,
|
||||
cr.sort_order,
|
||||
m.slug AS microdao_slug
|
||||
FROM city_rooms cr
|
||||
LEFT JOIN microdaos m ON cr.microdao_id = m.id
|
||||
WHERE cr.microdao_id = $1
|
||||
ORDER BY
|
||||
CASE WHEN cr.room_role = 'primary' THEN 0 ELSE 1 END,
|
||||
cr.sort_order ASC,
|
||||
cr.name ASC
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query, microdao_id)
|
||||
return [
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"slug": row["slug"],
|
||||
"name": row["name"],
|
||||
"matrix_room_id": row.get("matrix_room_id"),
|
||||
"microdao_id": str(row["microdao_id"]) if row.get("microdao_id") else None,
|
||||
"microdao_slug": row.get("microdao_slug"),
|
||||
"room_role": row.get("room_role"),
|
||||
"is_public": row.get("is_public", True),
|
||||
"sort_order": row.get("sort_order", 100)
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_microdao_rooms_by_slug(slug: str) -> Optional[dict]:
|
||||
"""
|
||||
Отримати MicroDAO та всі його кімнати за slug.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
# Get microdao first
|
||||
microdao_query = """
|
||||
SELECT id, slug FROM microdaos
|
||||
WHERE slug = $1
|
||||
AND COALESCE(is_archived, false) = false
|
||||
AND COALESCE(is_test, false) = false
|
||||
"""
|
||||
microdao = await pool.fetchrow(microdao_query, slug)
|
||||
if not microdao:
|
||||
return None
|
||||
|
||||
microdao_id = str(microdao["id"])
|
||||
rooms = await get_microdao_rooms(microdao_id)
|
||||
|
||||
return {
|
||||
"microdao_id": microdao_id,
|
||||
"microdao_slug": microdao["slug"],
|
||||
"rooms": rooms
|
||||
}
|
||||
|
||||
|
||||
async def attach_room_to_microdao(
|
||||
microdao_id: str,
|
||||
room_id: str,
|
||||
room_role: Optional[str] = None,
|
||||
is_public: bool = True,
|
||||
sort_order: int = 100
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Прив'язати існуючу кімнату до MicroDAO.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
UPDATE city_rooms
|
||||
SET microdao_id = $1,
|
||||
room_role = $2,
|
||||
is_public = $3,
|
||||
sort_order = $4
|
||||
WHERE id = $5
|
||||
RETURNING id, slug, name, matrix_room_id, microdao_id, room_role, is_public, sort_order
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, microdao_id, room_role, is_public, sort_order, room_id)
|
||||
if row:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"slug": row["slug"],
|
||||
"name": row["name"],
|
||||
"matrix_room_id": row.get("matrix_room_id"),
|
||||
"microdao_id": str(row["microdao_id"]) if row.get("microdao_id") else None,
|
||||
"room_role": row.get("room_role"),
|
||||
"is_public": row.get("is_public", True),
|
||||
"sort_order": row.get("sort_order", 100)
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
async def update_microdao_room(
|
||||
microdao_id: str,
|
||||
room_id: str,
|
||||
room_role: Optional[str] = None,
|
||||
is_public: Optional[bool] = None,
|
||||
sort_order: Optional[int] = None,
|
||||
set_primary: bool = False
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Оновити налаштування кімнати MicroDAO.
|
||||
Якщо set_primary=True, скидає роль 'primary' з інших кімнат.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# If setting as primary, clear previous primary
|
||||
if set_primary:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE city_rooms
|
||||
SET room_role = NULL
|
||||
WHERE microdao_id = $1 AND room_role = 'primary'
|
||||
""",
|
||||
microdao_id
|
||||
)
|
||||
room_role = 'primary'
|
||||
|
||||
# Build update query
|
||||
set_parts = []
|
||||
params = [room_id, microdao_id]
|
||||
param_idx = 3
|
||||
|
||||
if room_role is not None:
|
||||
set_parts.append(f"room_role = ${param_idx}")
|
||||
params.append(room_role)
|
||||
param_idx += 1
|
||||
|
||||
if is_public is not None:
|
||||
set_parts.append(f"is_public = ${param_idx}")
|
||||
params.append(is_public)
|
||||
param_idx += 1
|
||||
|
||||
if sort_order is not None:
|
||||
set_parts.append(f"sort_order = ${param_idx}")
|
||||
params.append(sort_order)
|
||||
param_idx += 1
|
||||
|
||||
if not set_parts:
|
||||
# Nothing to update, just return current state
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM city_rooms WHERE id = $1 AND microdao_id = $2",
|
||||
room_id, microdao_id
|
||||
)
|
||||
else:
|
||||
query = f"""
|
||||
UPDATE city_rooms
|
||||
SET {', '.join(set_parts)}
|
||||
WHERE id = $1 AND microdao_id = $2
|
||||
RETURNING id, slug, name, matrix_room_id, microdao_id, room_role, is_public, sort_order
|
||||
"""
|
||||
row = await conn.fetchrow(query, *params)
|
||||
|
||||
if row:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"slug": row["slug"],
|
||||
"name": row["name"],
|
||||
"matrix_room_id": row.get("matrix_room_id"),
|
||||
"microdao_id": str(row["microdao_id"]) if row.get("microdao_id") else None,
|
||||
"room_role": row.get("room_role"),
|
||||
"is_public": row.get("is_public", True),
|
||||
"sort_order": row.get("sort_order", 100)
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@@ -1510,17 +1510,28 @@ async def get_microdao_by_slug(slug: str):
|
||||
is_platform=child.get("is_platform", False)
|
||||
))
|
||||
|
||||
# Get primary city room for MicroDAO
|
||||
primary_city_room = await repo_city.get_microdao_primary_room(dao["id"])
|
||||
primary_room_summary = None
|
||||
if primary_city_room:
|
||||
from models_city import CityRoomSummary
|
||||
primary_room_summary = CityRoomSummary(
|
||||
id=primary_city_room["id"],
|
||||
slug=primary_city_room["slug"],
|
||||
name=primary_city_room["name"],
|
||||
matrix_room_id=primary_city_room.get("matrix_room_id")
|
||||
# Get all rooms for MicroDAO (multi-room support)
|
||||
all_rooms = await repo_city.get_microdao_rooms(dao["id"])
|
||||
rooms_list = [
|
||||
CityRoomSummary(
|
||||
id=room["id"],
|
||||
slug=room["slug"],
|
||||
name=room["name"],
|
||||
matrix_room_id=room.get("matrix_room_id"),
|
||||
microdao_id=room.get("microdao_id"),
|
||||
microdao_slug=room.get("microdao_slug"),
|
||||
room_role=room.get("room_role"),
|
||||
is_public=room.get("is_public", True),
|
||||
sort_order=room.get("sort_order", 100)
|
||||
)
|
||||
for room in all_rooms
|
||||
]
|
||||
|
||||
# Get primary city room (first room with role='primary' or first by sort_order)
|
||||
primary_room_summary = None
|
||||
if rooms_list:
|
||||
primary = next((r for r in rooms_list if r.room_role == 'primary'), rooms_list[0])
|
||||
primary_room_summary = primary
|
||||
|
||||
return MicrodaoDetail(
|
||||
id=dao["id"],
|
||||
@@ -1540,7 +1551,8 @@ async def get_microdao_by_slug(slug: str):
|
||||
agents=agents,
|
||||
channels=channels,
|
||||
public_citizens=public_citizens,
|
||||
primary_city_room=primary_room_summary
|
||||
primary_city_room=primary_room_summary,
|
||||
rooms=rooms_list
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
@@ -1552,6 +1564,143 @@ async def get_microdao_by_slug(slug: str):
|
||||
raise HTTPException(status_code=500, detail="Failed to get microdao")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MicroDAO Multi-Room API (Task 034)
|
||||
# =============================================================================
|
||||
|
||||
from models_city import MicrodaoRoomsList, MicrodaoRoomUpdate, AttachExistingRoomRequest
|
||||
|
||||
|
||||
@router.get("/microdao/{slug}/rooms", response_model=MicrodaoRoomsList)
|
||||
async def get_microdao_rooms_endpoint(slug: str):
|
||||
"""
|
||||
Отримати всі кімнати MicroDAO (Task 034).
|
||||
Повертає список кімнат, впорядкованих за sort_order.
|
||||
"""
|
||||
try:
|
||||
result = await repo_city.get_microdao_rooms_by_slug(slug)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
|
||||
|
||||
rooms = [
|
||||
CityRoomSummary(
|
||||
id=room["id"],
|
||||
slug=room["slug"],
|
||||
name=room["name"],
|
||||
matrix_room_id=room.get("matrix_room_id"),
|
||||
microdao_id=room.get("microdao_id"),
|
||||
microdao_slug=room.get("microdao_slug"),
|
||||
room_role=room.get("room_role"),
|
||||
is_public=room.get("is_public", True),
|
||||
sort_order=room.get("sort_order", 100)
|
||||
)
|
||||
for room in result["rooms"]
|
||||
]
|
||||
|
||||
return MicrodaoRoomsList(
|
||||
microdao_id=result["microdao_id"],
|
||||
microdao_slug=result["microdao_slug"],
|
||||
rooms=rooms
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get microdao rooms for {slug}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get microdao rooms")
|
||||
|
||||
|
||||
@router.post("/microdao/{slug}/rooms/attach-existing", response_model=CityRoomSummary)
|
||||
async def attach_existing_room_endpoint(
|
||||
slug: str,
|
||||
payload: AttachExistingRoomRequest
|
||||
):
|
||||
"""
|
||||
Прив'язати існуючу кімнату до MicroDAO (Task 036).
|
||||
Потребує прав адміністратора або оркестратора MicroDAO.
|
||||
"""
|
||||
try:
|
||||
# Get microdao by slug
|
||||
dao = await repo_city.get_microdao_by_slug(slug)
|
||||
if not dao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
|
||||
|
||||
# TODO: Add authorization check (assert_can_manage_microdao)
|
||||
|
||||
result = await repo_city.attach_room_to_microdao(
|
||||
microdao_id=dao["id"],
|
||||
room_id=payload.room_id,
|
||||
room_role=payload.room_role,
|
||||
is_public=payload.is_public,
|
||||
sort_order=payload.sort_order
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
return CityRoomSummary(
|
||||
id=result["id"],
|
||||
slug=result["slug"],
|
||||
name=result["name"],
|
||||
matrix_room_id=result.get("matrix_room_id"),
|
||||
microdao_id=result.get("microdao_id"),
|
||||
room_role=result.get("room_role"),
|
||||
is_public=result.get("is_public", True),
|
||||
sort_order=result.get("sort_order", 100)
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to attach room to microdao {slug}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to attach room")
|
||||
|
||||
|
||||
@router.patch("/microdao/{slug}/rooms/{room_id}", response_model=CityRoomSummary)
|
||||
async def update_microdao_room_endpoint(
|
||||
slug: str,
|
||||
room_id: str,
|
||||
payload: MicrodaoRoomUpdate
|
||||
):
|
||||
"""
|
||||
Оновити налаштування кімнати MicroDAO (Task 036).
|
||||
Потребує прав адміністратора або оркестратора MicroDAO.
|
||||
"""
|
||||
try:
|
||||
# Get microdao by slug
|
||||
dao = await repo_city.get_microdao_by_slug(slug)
|
||||
if not dao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
|
||||
|
||||
# TODO: Add authorization check (assert_can_manage_microdao)
|
||||
|
||||
result = await repo_city.update_microdao_room(
|
||||
microdao_id=dao["id"],
|
||||
room_id=room_id,
|
||||
room_role=payload.room_role,
|
||||
is_public=payload.is_public,
|
||||
sort_order=payload.sort_order,
|
||||
set_primary=payload.set_primary or False
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Room not found or not attached to this MicroDAO")
|
||||
|
||||
return CityRoomSummary(
|
||||
id=result["id"],
|
||||
slug=result["slug"],
|
||||
name=result["name"],
|
||||
matrix_room_id=result.get("matrix_room_id"),
|
||||
microdao_id=result.get("microdao_id"),
|
||||
room_role=result.get("room_role"),
|
||||
is_public=result.get("is_public", True),
|
||||
sort_order=result.get("sort_order", 100)
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update room {room_id} for microdao {slug}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to update room")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MicroDAO Visibility & Creation (Task 029)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user