From 0fd05f678acdb37638886f470ea1d4e50ef43520 Mon Sep 17 00:00:00 2001 From: Apple Date: Sun, 30 Nov 2025 11:37:56 -0800 Subject: [PATCH] feat: District Portals API (DB-based, no hardcodes) Backend: - GET /api/v1/districts - list all districts from DB - GET /api/v1/districts/{slug} - district detail with lead agent, core team, rooms, nodes repo_city methods: - get_districts() - SELECT FROM microdaos WHERE dao_type='district' - get_district_by_slug() - get_district_lead_agent() - with fallback to orchestrator - get_district_core_team() - get_district_agents() - get_district_rooms() - by slug prefix - get_district_nodes() - get_district_stats() Task doc: TASK_PHASE_DISTRICT_PORTALS_v1.md --- docs/tasks/TASK_PHASE_DISTRICT_PORTALS_v1.md | 451 +++++++++++++++++++ services/city-service/repo_city.py | 188 ++++++++ services/city-service/routes_city.py | 136 ++++++ 3 files changed, 775 insertions(+) create mode 100644 docs/tasks/TASK_PHASE_DISTRICT_PORTALS_v1.md diff --git a/docs/tasks/TASK_PHASE_DISTRICT_PORTALS_v1.md b/docs/tasks/TASK_PHASE_DISTRICT_PORTALS_v1.md new file mode 100644 index 00000000..87e6dffa --- /dev/null +++ b/docs/tasks/TASK_PHASE_DISTRICT_PORTALS_v1.md @@ -0,0 +1,451 @@ +# TASK_PHASE_DISTRICT_PORTALS_v1 + +Version: 1.0 +Status: Ready +Priority: High (City → District → MicroDAO контур) + +--- + +# 1. МЕТА + +Зробити **District-и повноцінними "порталами платформ"** у DAARION.city: + +- окремі сторінки District-ів (SOUL, GREENFOOD, ENERGYUNION), +- прив'язка до існуючих District-протоколів, +- інтеграція з Rooms, Matrix, Presence, Chat Widget, +- відображення MicroDAO всередині District-а. + +Результат: +користувач, заходячи на DAARION.space, може: + +- потрапити в місто (/city), +- з міста — в District-портал (/soul, /greenfood, /energy-union), +- з District — у відповідні MicroDAO / кімнати / агентів. + +--- + +# 2. ВИХІДНІ ДАНІ + +Уже є: + +- Foundation-документи: + - `GREENFOOD_District_Protocol_v1.md` + - `ENERGYUNION_District_Protocol_v1.md` + - `SOUL_District_Protocol_v1.md` + - `District_Interface_Architecture_v1.md` +- Таблиця `microdaos` з полем `dao_type = 'district'` (SOUL, GREENFOOD, ENERGYUNION). +- Rooms Layer: + - District rooms для: + - SOUL (soul-lobby, soul-events, soul-masters, ...) + - GREENFOOD (greenfood-lobby, ... ) + - ENERGYUNION (energyunion-lobby, energyunion-compute, ...) +- Matrix + Chat: + - `rooms.matrix_room_id` заповнено, + - Chat API працює, + - Presence API працює. +- Frontend: + - City Layer /city, /city/{slug} + - Agents, Nodes, MicroDAO базові сторінки + +--- + +# 3. SCOPE + +1. Backend District API: + - `GET /api/v1/districts` + - `GET /api/v1/districts/{slug}` +2. Frontend routing: + - `/districts` (список всіх District-ів) + - `/districts/[slug]` (універсальний портал) + - короткі alias-роути: + - `/soul` → SOUL District + - `/greenfood` → GREENFOOD District + - `/energy-union` → ENERGYUNION District +3. UI District-порталу: + - header (назва, опис, тип, lead agent), + - District rooms (список кімнат + переходи), + - host agents (lead/core team) + presence, + - chat widget (District lobby room), + - список MicroDAO всередині District-а. +4. Інтеграція з City Layer: + - посилання з City → District. + +--- + +# 4. МОДУЛЬ 1 — BACKEND: DISTRICT API (CITY-SERVICE) + +## 4.1. `GET /api/v1/districts` + +Повертає список всіх District-ів. + +Приблизний вихід: + +```json +[ + { + "id": "uuid", + "slug": "soul", + "name": "SOUL Retreat District", + "description": "Wellness / Retreat / Metahuman", + "dao_type": "district", + "lead_agent": { + "id": "agent_id_soul", + "name": "SOUL" + }, + "rooms": [ + { + "id": "uuid", + "slug": "soul-lobby", + "name": "SOUL Lobby" + } + ], + "microdaos_count": 0 + }, + { + "id": "uuid", + "slug": "greenfood", + "name": "GREENFOOD District", + "description": "ERP / Supply Chains / Food", + "dao_type": "district", + "lead_agent": { + "id": "agent_id_greenfood", + "name": "ERP GREENFOOD" + }, + "rooms": [ ... ], + "microdaos_count": 0 + }, + { + "id": "uuid", + "slug": "energy-union", + "name": "Energy Union District", + "description": "DePIN / Energy / Compute", + "dao_type": "district", + "lead_agent": { + "id": "agent_id_helion", + "name": "Helion" + }, + "rooms": [ ... ], + "microdaos_count": 0 + } +] +``` + +Логіка: + +- брати записи з `microdaos` де `dao_type = 'district'`; +- підвантажити lead-agent (згідно District-протоколів); +- підтягнути ключові кімнати (lobby тощо) через `rooms` та `district_id` / зв'язки. + +## 4.2. `GET /api/v1/districts/{slug}` + +Подробиці District-а. + +Приблизний вихід: + +```json +{ + "id": "uuid", + "slug": "soul", + "name": "SOUL Retreat District", + "description": "Wellness / Retreat / Metahuman District", + "dao_type": "district", + "lead_agent": { + "id": "agent_id_soul", + "name": "SOUL", + "dais_id": "dais_soul" + }, + "core_team": [ + { + "id": "agent_spirit", + "name": "Spirit", + "role": "Guidance" + }, + { + "id": "agent_logic", + "name": "Logic", + "role": "Information" + } + ], + "rooms": [ + { + "id": "room_id_lobby", + "slug": "soul-lobby", + "name": "SOUL Lobby", + "matrix_room_id": "!room:matrix..." + }, + { + "id": "room_id_events", + "slug": "soul-events", + "name": "Events" + } + ], + "microdaos": [ + { + "id": "microdao_id_1", + "slug": "soul-hub-1", + "name": "Retreat Hub #1" + } + ] +} +``` + +Якщо District не знайдено → 404. + +--- + +# 5. МОДУЛЬ 2 — FRONTEND API КЛІЄНТ + +У `apps/web/src/lib/api/districts.ts`: + +```ts +export type DistrictSummary = { + id: string; + slug: string; + name: string; + description: string; + daoType: "district"; + leadAgent: { + id: string; + name: string; + }; + rooms: { + id: string; + slug: string; + name: string; + }[]; + microdaosCount: number; +}; + +export type DistrictDetail = { + id: string; + slug: string; + name: string; + description: string; + daoType: "district"; + leadAgent: { + id: string; + name: string; + daisId?: string; + }; + coreTeam: { + id: string; + name: string; + role: string; + }[]; + rooms: { + id: string; + slug: string; + name: string; + matrixRoomId?: string; + }[]; + microdaos: { + id: string; + slug: string; + name: string; + }[]; +}; + +export async function getDistricts(): Promise { ... } + +export async function getDistrictBySlug(slug: string): Promise { ... } +``` + +--- + +# 6. МОДУЛЬ 3 — ROUTING + +## 6.1. `/districts` (список) + +Файл: `apps/web/src/app/districts/page.tsx` + +Повинен: + +- завантажити `getDistricts()`; +- показати карточки District-ів: + - SOUL + - GREENFOOD + - ENERGY UNION +- кожна картка: + - назва + - опис + - lead agent (ім'я + presence) + - кнопка/посилання "Перейти до District". + +## 6.2. `/districts/[slug]` (портал District-а) + +Файл: `apps/web/src/app/districts/[slug]/page.tsx` + +Логіка: + +- прочитати `params.slug`; +- викликати `getDistrictBySlug(slug)`; +- якщо 404 → `notFound()`. + +--- + +# 7. МОДУЛЬ 4 — UI DISTRICT-ПОРТАЛУ + +Структура сторінки `/districts/[slug]`: + +## 7.1. Header + +- Назва District-а (`name`) +- Опис (`description`) +- Badge: "District" +- Breadcrumb: + - `City / Districts / {DistrictName}` + +## 7.2. Lead Agent + Core Team + +Секція "District Agents": + +- Lead Agent: + - аватар, + - ім'я (SOUL / GREENFOOD / Helion), + - PresenceDot (online/offline/away), + - кнопка "Кабінет агента". + +- Core Team (якщо є): + - список агентів (Spirit, Logic, Energia тощо), + - ролі (Guidance, Information, Energy, Scheduler...), + - presence. + +## 7.3. Rooms + +Секція "District Rooms": + +- список кімнат District-а: + - назва, + - опис (якщо є), + - scope tag (lobby / events / labs / providers / compute / guidance / etc.), + - кнопка "Відкрити кімнату" → `/city/{slug}` або окремий `/rooms/{slug}`, залежно від моделі. +- Для lobby-кімнати: + - окремий акцент: "Головний портал District-а". + +(Якщо кімнати реалізовані як `rooms.scope = 'district'`, на UI використовувати їх метадані з БД.) + +## 7.4. MicroDAO + +Секція "MicroDAO цього District-а": + +- таблиця/карточки: + - назва MicroDAO, + - тип (якщо є), + - кнопка "Відкрити MicroDAO" → `/microdao/{slug}`. + +## 7.5. Chat (District Lobby) + +- Внизу чи праворуч: Chat Widget, прив'язаний до **District Lobby Room**: + - отримати lobby room з `rooms` (наприклад, slug `soul-lobby`, `greenfood-lobby`, `energyunion-lobby`); + - чат працює через `/api/v1/chat/rooms/{room_id}`. +- Неавторизованим показувати "Увійти, щоб писати". + +--- + +# 8. МОДУЛЬ 5 — SHORTCUT ROUTES + +Щоб було зручно заходити на платформи: + +- `/soul` → редірект або пряма сторінка, що використовує `getDistrictBySlug("soul")`. +- `/greenfood` → `getDistrictBySlug("greenfood")`. +- `/energy-union` → `getDistrictBySlug("energy-union")`. + +Реалізація: + +- створити файли: + - `apps/web/src/app/soul/page.tsx` + - `apps/web/src/app/greenfood/page.tsx` + - `apps/web/src/app/energy-union/page.tsx` + +Які просто рендерять той самий компонент, що й `/districts/[slug]`, з фіксованим `slug`. + +--- + +# 9. ІНТЕГРАЦІЯ З CITY LAYER + +На сторінці `/city` або в City Square: + +- для портальних точок (District Portals): + - додати кнопки/картки: + - "SOUL Retreat District" + - "GREENFOOD District" + - "Energy Union District" + - клік → переходить на: + - `/soul` + - `/greenfood` + - `/energy-union` + +--- + +# 10. SMOKE-ТЕСТИ + +Після завершення: + +1. `/districts`: + - показує 3 District-и (SOUL, GREENFOOD, ENERGYUNION). + +2. `/districts/soul`: + - рендериться без помилок, + - видно SOUL як lead agent + presence, + - видно Spirit/Logic (як core team, якщо додані), + - видно список rooms (soul-lobby, soul-events, soul-guidance...), + - чат-простір для lobby. + +3. `/districts/greenfood`: + - рендериться, + - видно ERP GREENFOOD agent, + - rooms згідно GREENFOOD_District_Protocol, + - microDAO (якщо є) у списку. + +4. `/districts/energy-union`: + - рендериться, + - видно Helion, Energia, + - rooms (lobby, compute, providers, labs...), + - чат-простір. + +5. `/soul`, `/greenfood`, `/energy-union`: + - відкривають відповідні портали District-ів. + +6. City → District: + - з City UI є лінки, які ведуть на District Portals. + +--- + +# 11. ФІНАЛЬНИЙ ЗВІТ + +Після виконання: + +Створити файл: + +`docs/debug/district_portals_report_.md` + +І включити в нього: + +- список District-ів з `/api/v1/districts`, +- приклади `GET /api/v1/districts/{slug}`, +- скрін/опис `/districts`, `/districts/soul`, `/districts/greenfood`, `/districts/energy-union`, +- підтвердження, що `/soul`, `/greenfood`, `/energy-union` працюють. + +--- + +# 12. PROMPT ДЛЯ CURSOR + +```text +Виконай TASK_PHASE_DISTRICT_PORTALS_v1.md. + +Фокус: +1) Backend: /api/v1/districts, /api/v1/districts/{slug} +2) Frontend: /districts, /districts/[slug], /soul, /greenfood, /energy-union +3) District UI: lead agent, core team, rooms, microDAO list, chat (lobby room) +4) Інтеграція з City Layer (/city → District Portals) + +Після завершення створи: +docs/debug/district_portals_report_.md +``` + +--- + +**Target Date**: Immediate +**Priority**: High +**Dependencies**: City Layer complete, Matrix rooms synced + diff --git a/services/city-service/repo_city.py b/services/city-service/repo_city.py index 41f00bc5..1f245eb8 100644 --- a/services/city-service/repo_city.py +++ b/services/city-service/repo_city.py @@ -2349,3 +2349,191 @@ async def get_or_create_orchestrator_team_room(microdao_id: str) -> Optional[dic ) return dict(new_room) + + +# ============================================================================= +# Districts Repository (DB-based, no hardcodes) +# ============================================================================= + +async def get_districts() -> List[Dict[str, Any]]: + """ + Отримати всі District-и з БД. + District = microdao з dao_type = 'district' + """ + pool = await get_pool() + query = """ + SELECT id, slug, name, description, dao_type, + orchestrator_agent_id, created_at + FROM microdaos + WHERE dao_type = 'district' + ORDER BY name + """ + rows = await pool.fetch(query) + return [dict(r) for r in rows] + + +async def get_district_by_slug(slug: str) -> Optional[Dict[str, Any]]: + """ + Отримати District за slug. + """ + pool = await get_pool() + query = """ + SELECT id, slug, name, description, dao_type, + orchestrator_agent_id, created_at + FROM microdaos + WHERE slug = $1 + AND dao_type = 'district' + """ + row = await pool.fetchrow(query, slug) + return dict(row) if row else None + + +async def get_district_lead_agent(district_id: str) -> Optional[Dict[str, Any]]: + """ + Отримати lead agent District-а. + Шукаємо спочатку role='district_lead', потім fallback на orchestrator. + """ + pool = await get_pool() + + # Try district_lead first + query = """ + SELECT a.id, a.display_name as name, a.kind, a.status, + a.avatar_url, a.gov_level, + ma.role as membership_role + FROM agents a + JOIN microdao_agents ma ON ma.agent_id = a.id + WHERE ma.microdao_id = $1 + AND ma.role = 'district_lead' + LIMIT 1 + """ + row = await pool.fetchrow(query, district_id) + + if not row: + # Fallback: orchestrator + query = """ + SELECT a.id, a.display_name as name, a.kind, a.status, + a.avatar_url, a.gov_level, + ma.role as membership_role + FROM agents a + JOIN microdao_agents ma ON ma.agent_id = a.id + WHERE ma.microdao_id = $1 + AND (ma.role = 'orchestrator' OR ma.is_core = true) + ORDER BY ma.is_core DESC + LIMIT 1 + """ + row = await pool.fetchrow(query, district_id) + + return dict(row) if row else None + + +async def get_district_core_team(district_id: str) -> List[Dict[str, Any]]: + """ + Отримати core team District-а. + """ + pool = await get_pool() + query = """ + SELECT a.id, a.display_name as name, a.kind, a.status, + a.avatar_url, a.gov_level, + ma.role as membership_role + FROM agents a + JOIN microdao_agents ma ON ma.agent_id = a.id + WHERE ma.microdao_id = $1 + AND (ma.role = 'core_team' OR ma.is_core = true) + AND ma.role != 'district_lead' + AND ma.role != 'orchestrator' + ORDER BY a.display_name + """ + rows = await pool.fetch(query, district_id) + return [dict(r) for r in rows] + + +async def get_district_agents(district_id: str) -> List[Dict[str, Any]]: + """ + Отримати всіх агентів District-а. + """ + pool = await get_pool() + query = """ + SELECT a.id, a.display_name as name, a.kind, a.status, + a.avatar_url, a.gov_level, + ma.role as membership_role, ma.is_core + FROM agents a + JOIN microdao_agents ma ON ma.agent_id = a.id + WHERE ma.microdao_id = $1 + ORDER BY + CASE ma.role + WHEN 'district_lead' THEN 0 + WHEN 'orchestrator' THEN 1 + WHEN 'core_team' THEN 2 + ELSE 3 + END, + ma.is_core DESC, + a.display_name + """ + rows = await pool.fetch(query, district_id) + return [dict(r) for r in rows] + + +async def get_district_rooms(district_slug: str) -> List[Dict[str, Any]]: + """ + Отримати кімнати District-а за slug-префіксом. + Наприклад: soul-lobby, soul-events, greenfood-lobby + """ + pool = await get_pool() + query = """ + SELECT id, slug, name, description, + matrix_room_id, matrix_room_alias, + room_role, is_public + FROM city_rooms + WHERE slug LIKE $1 + ORDER BY sort_order, name + """ + rows = await pool.fetch(query, f"{district_slug}-%") + return [dict(r) for r in rows] + + +async def get_district_nodes(district_id: str) -> List[Dict[str, Any]]: + """ + Отримати ноди District-а. + """ + pool = await get_pool() + query = """ + SELECT n.id, n.display_name as name, n.node_type as kind, + n.status, n.hostname as location, + n.guardian_agent_id, n.steward_agent_id + FROM nodes n + WHERE n.owner_microdao_id = $1 + ORDER BY n.display_name + """ + rows = await pool.fetch(query, district_id) + return [dict(r) for r in rows] + + +async def get_district_stats(district_id: str, district_slug: str) -> Dict[str, Any]: + """ + Отримати статистику District-а. + """ + pool = await get_pool() + + # Count agents + agents_count = await pool.fetchval( + "SELECT COUNT(*) FROM microdao_agents WHERE microdao_id = $1", + district_id + ) + + # Count rooms + rooms_count = await pool.fetchval( + "SELECT COUNT(*) FROM city_rooms WHERE slug LIKE $1", + f"{district_slug}-%" + ) + + # Count nodes + nodes_count = await pool.fetchval( + "SELECT COUNT(*) FROM nodes WHERE owner_microdao_id = $1", + district_id + ) + + return { + "agents_count": agents_count or 0, + "rooms_count": rooms_count or 0, + "nodes_count": nodes_count or 0 + } diff --git a/services/city-service/routes_city.py b/services/city-service/routes_city.py index 57a3a95e..1b94765b 100644 --- a/services/city-service/routes_city.py +++ b/services/city-service/routes_city.py @@ -1123,6 +1123,142 @@ async def get_city_room_by_slug(slug: str): raise HTTPException(status_code=500, detail="Failed to get city room") +# ============================================================================= +# Districts API (DB-based, no hardcodes) +# ============================================================================= + +@api_router.get("/districts") +async def get_districts(): + """ + Отримати список всіх District-ів. + District = microdao з dao_type = 'district' + """ + try: + districts = await repo_city.get_districts() + + result = [] + for d in districts: + # Get lead agent for each district + lead_agent = await repo_city.get_district_lead_agent(d["id"]) + + # Get rooms count + rooms = await repo_city.get_district_rooms(d["slug"]) + + result.append({ + "id": d["id"], + "slug": d["slug"], + "name": d["name"], + "description": d.get("description"), + "dao_type": d["dao_type"], + "lead_agent": { + "id": lead_agent["id"], + "name": lead_agent["name"], + "avatar_url": lead_agent.get("avatar_url") + } if lead_agent else None, + "rooms_count": len(rooms), + "rooms": [{"id": r["id"], "slug": r["slug"], "name": r["name"]} for r in rooms[:3]] + }) + + return result + except Exception as e: + logger.error(f"Failed to get districts: {e}") + raise HTTPException(status_code=500, detail="Failed to get districts") + + +@api_router.get("/districts/{slug}") +async def get_district_detail(slug: str): + """ + Отримати деталі District-а за slug. + """ + try: + district = await repo_city.get_district_by_slug(slug) + if not district: + raise HTTPException(status_code=404, detail=f"District not found: {slug}") + + # Get lead agent + lead_agent = await repo_city.get_district_lead_agent(district["id"]) + + # Get core team + core_team = await repo_city.get_district_core_team(district["id"]) + + # Get all agents + agents = await repo_city.get_district_agents(district["id"]) + + # Get rooms + rooms = await repo_city.get_district_rooms(district["slug"]) + + # Get nodes + nodes = await repo_city.get_district_nodes(district["id"]) + + # Get stats + stats = await repo_city.get_district_stats(district["id"], district["slug"]) + + return { + "district": { + "id": district["id"], + "slug": district["slug"], + "name": district["name"], + "description": district.get("description"), + "dao_type": district["dao_type"] + }, + "lead_agent": { + "id": lead_agent["id"], + "name": lead_agent["name"], + "kind": lead_agent.get("kind"), + "status": lead_agent.get("status"), + "avatar_url": lead_agent.get("avatar_url"), + "gov_level": lead_agent.get("gov_level") + } if lead_agent else None, + "core_team": [ + { + "id": a["id"], + "name": a["name"], + "kind": a.get("kind"), + "status": a.get("status"), + "avatar_url": a.get("avatar_url"), + "role": a.get("membership_role") + } for a in core_team + ], + "agents": [ + { + "id": a["id"], + "name": a["name"], + "kind": a.get("kind"), + "status": a.get("status"), + "avatar_url": a.get("avatar_url"), + "role": a.get("membership_role"), + "is_core": a.get("is_core", False) + } for a in agents + ], + "rooms": [ + { + "id": r["id"], + "slug": r["slug"], + "name": r["name"], + "description": r.get("description"), + "matrix_room_id": r.get("matrix_room_id"), + "room_role": r.get("room_role"), + "is_public": r.get("is_public", True) + } for r in rooms + ], + "nodes": [ + { + "id": n["id"], + "name": n["name"], + "kind": n.get("kind"), + "status": n.get("status"), + "location": n.get("location") + } for n in nodes + ], + "stats": stats + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get district {slug}: {e}") + raise HTTPException(status_code=500, detail="Failed to get district") + + @router.get("/rooms/{room_id}", response_model=CityRoomDetail) async def get_city_room(room_id: str): """