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:
44
apps/web/src/app/api/microdao/[slug]/rooms/[roomId]/route.ts
Normal file
44
apps/web/src/app/api/microdao/[slug]/rooms/[roomId]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const CITY_API_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || "http://daarion-city-service:7001";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/microdao/[slug]/rooms/[roomId]
|
||||||
|
* Update a MicroDAO room settings
|
||||||
|
*/
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: Promise<{ slug: string; roomId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { slug, roomId } = await context.params;
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
const response = await fetch(`${CITY_API_URL}/city/microdao/${slug}/rooms/${roomId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error("Failed to update microdao room:", response.status, text);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update room", detail: text },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating microdao room:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const CITY_API_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || "http://daarion-city-service:7001";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/microdao/[slug]/rooms/attach-existing
|
||||||
|
* Attach an existing city room to a MicroDAO
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { slug } = await context.params;
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
const response = await fetch(`${CITY_API_URL}/city/microdao/${slug}/rooms/attach-existing`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error("Failed to attach room to microdao:", response.status, text);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to attach room", detail: text },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error attaching room to microdao:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
42
apps/web/src/app/api/microdao/[slug]/rooms/route.ts
Normal file
42
apps/web/src/app/api/microdao/[slug]/rooms/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const CITY_API_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || "http://daarion-city-service:7001";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/microdao/[slug]/rooms
|
||||||
|
* Get all rooms for a MicroDAO
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { slug } = await context.params;
|
||||||
|
|
||||||
|
const response = await fetch(`${CITY_API_URL}/city/microdao/${slug}/rooms`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error("Failed to get microdao rooms:", response.status, text);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to get MicroDAO rooms", detail: text },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting microdao rooms:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,16 +2,24 @@
|
|||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMicrodaoDetail } from "@/hooks/useMicrodao";
|
import { useMicrodaoDetail, useMicrodaoRooms } from "@/hooks/useMicrodao";
|
||||||
import { DISTRICT_COLORS } from "@/lib/microdao";
|
import { DISTRICT_COLORS } from "@/lib/microdao";
|
||||||
import { MicrodaoVisibilityCard } from "@/components/microdao/MicrodaoVisibilityCard";
|
import { MicrodaoVisibilityCard } from "@/components/microdao/MicrodaoVisibilityCard";
|
||||||
|
import { MicrodaoRoomsSection } from "@/components/microdao/MicrodaoRoomsSection";
|
||||||
|
import { MicrodaoRoomsAdminPanel } from "@/components/microdao/MicrodaoRoomsAdminPanel";
|
||||||
import { ChevronLeft, Users, MessageSquare, Crown, Building2, Globe, Lock, Layers, BarChart3, Bot, MessageCircle } from "lucide-react";
|
import { ChevronLeft, Users, MessageSquare, Crown, Building2, Globe, Lock, Layers, BarChart3, Bot, MessageCircle } from "lucide-react";
|
||||||
import { CityChatWidget } from "@/components/city/CityChatWidget";
|
import { CityChatWidget } from "@/components/city/CityChatWidget";
|
||||||
|
|
||||||
export default function MicrodaoDetailPage() {
|
export default function MicrodaoDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const slug = params?.slug as string;
|
const slug = params?.slug as string;
|
||||||
const { microdao, isLoading, error } = useMicrodaoDetail(slug);
|
const { microdao, isLoading, error, mutate: refreshMicrodao } = useMicrodaoDetail(slug);
|
||||||
|
const { rooms, mutate: refreshRooms } = useMicrodaoRooms(slug);
|
||||||
|
|
||||||
|
const handleRoomUpdated = () => {
|
||||||
|
refreshRooms();
|
||||||
|
refreshMicrodao();
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -373,35 +381,21 @@ export default function MicrodaoDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Public Chat Room */}
|
{/* Orchestrator Room Management Panel */}
|
||||||
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
|
||||||
<MessageCircle className="w-5 h-5 text-purple-400" />
|
|
||||||
Публічний чат MicroDAO
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{microdao.primary_city_room ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
Matrix-чат у кімнаті: <span className="text-purple-400">{microdao.primary_city_room.name}</span>
|
|
||||||
</p>
|
|
||||||
{orchestrator && (
|
{orchestrator && (
|
||||||
<p className="text-xs text-slate-500">
|
<MicrodaoRoomsAdminPanel
|
||||||
Оркестратор: <Link href={`/agents/${orchestrator.agent_id}`} className="text-cyan-400 hover:underline">{orchestrator.display_name}</Link>
|
microdaoSlug={slug}
|
||||||
</p>
|
rooms={rooms.length > 0 ? rooms : (microdao.rooms || [])}
|
||||||
|
canManage={true} // TODO: check if current user is orchestrator
|
||||||
|
onRoomUpdated={handleRoomUpdated}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<CityChatWidget roomSlug={microdao.primary_city_room.slug} />
|
|
||||||
</div>
|
{/* Multi-Room Section with Chats */}
|
||||||
) : (
|
<MicrodaoRoomsSection
|
||||||
<div className="text-center py-8 text-slate-500">
|
rooms={rooms.length > 0 ? rooms : (microdao.rooms || [])}
|
||||||
<MessageCircle className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
primaryRoomSlug={microdao.primary_city_room?.slug}
|
||||||
<p>Для цього MicroDAO ще не налаштована публічна кімната.</p>
|
/>
|
||||||
<p className="text-sm mt-2 text-slate-600">
|
|
||||||
Налаштуйте primary room у City Service, щоб увімкнути чат.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Visibility Settings (only for orchestrator) */}
|
{/* Visibility Settings (only for orchestrator) */}
|
||||||
{orchestrator && (
|
{orchestrator && (
|
||||||
|
|||||||
309
apps/web/src/components/microdao/MicrodaoRoomsAdminPanel.tsx
Normal file
309
apps/web/src/components/microdao/MicrodaoRoomsAdminPanel.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Settings, Plus, Star, Eye, EyeOff, ArrowUpDown, Check, X, Loader2 } from "lucide-react";
|
||||||
|
import { CityRoomSummary, MicrodaoRoomUpdate, AttachExistingRoomRequest } from "@/lib/types/microdao";
|
||||||
|
|
||||||
|
interface MicrodaoRoomsAdminPanelProps {
|
||||||
|
microdaoSlug: string;
|
||||||
|
rooms: CityRoomSummary[];
|
||||||
|
canManage: boolean;
|
||||||
|
onRoomUpdated?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: "primary", label: "Основна (Primary)" },
|
||||||
|
{ value: "lobby", label: "Лобі" },
|
||||||
|
{ value: "team", label: "Командна" },
|
||||||
|
{ value: "research", label: "Дослідницька" },
|
||||||
|
{ value: "security", label: "Безпека" },
|
||||||
|
{ value: "governance", label: "Управління" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MicrodaoRoomsAdminPanel({
|
||||||
|
microdaoSlug,
|
||||||
|
rooms,
|
||||||
|
canManage,
|
||||||
|
onRoomUpdated,
|
||||||
|
}: MicrodaoRoomsAdminPanelProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Attach room form
|
||||||
|
const [showAttachForm, setShowAttachForm] = useState(false);
|
||||||
|
const [attachRoomId, setAttachRoomId] = useState("");
|
||||||
|
const [attachRole, setAttachRole] = useState("");
|
||||||
|
const [attachSortOrder, setAttachSortOrder] = useState(100);
|
||||||
|
|
||||||
|
if (!canManage) return null;
|
||||||
|
|
||||||
|
const handleSetPrimary = async (roomId: string) => {
|
||||||
|
setSaving(roomId);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/microdao/${microdaoSlug}/rooms/${roomId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ set_primary: true } as MicrodaoRoomUpdate),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to set primary room");
|
||||||
|
}
|
||||||
|
|
||||||
|
onRoomUpdated?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Error");
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateRoom = async (roomId: string, update: MicrodaoRoomUpdate) => {
|
||||||
|
setSaving(roomId);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/microdao/${microdaoSlug}/rooms/${roomId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(update),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to update room");
|
||||||
|
}
|
||||||
|
|
||||||
|
onRoomUpdated?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Error");
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachRoom = async () => {
|
||||||
|
if (!attachRoomId.trim()) {
|
||||||
|
setError("Введіть ID кімнати");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving("attach");
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: AttachExistingRoomRequest = {
|
||||||
|
room_id: attachRoomId.trim(),
|
||||||
|
room_role: attachRole || null,
|
||||||
|
is_public: true,
|
||||||
|
sort_order: attachSortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`/api/microdao/${microdaoSlug}/rooms/attach-existing`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.detail || "Failed to attach room");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setAttachRoomId("");
|
||||||
|
setAttachRole("");
|
||||||
|
setAttachSortOrder(100);
|
||||||
|
setShowAttachForm(false);
|
||||||
|
onRoomUpdated?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Error");
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-amber-500/5 border border-amber-500/30 rounded-xl p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-slate-100 font-semibold flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4 text-amber-400" />
|
||||||
|
Керування кімнатами MicroDAO
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
className="text-xs px-3 py-1.5 border border-slate-600 rounded-lg text-slate-300 hover:bg-slate-700/50 transition-colors"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{isOpen ? "Сховати" : "Показати"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Тут ви можете привʼязати існуючі кімнати міста до цього MicroDAO,
|
||||||
|
змінювати їх ролі, видимість та порядок сортування.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rooms list with controls */}
|
||||||
|
{rooms.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs text-slate-400 uppercase tracking-wide">Поточні кімнати</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<div
|
||||||
|
key={room.id}
|
||||||
|
className="bg-slate-900/50 border border-slate-700/30 rounded-lg p-3 flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-200 truncate">{room.name}</span>
|
||||||
|
{room.room_role === "primary" && (
|
||||||
|
<Star className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 flex items-center gap-2">
|
||||||
|
<span>{room.slug}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Sort: {room.sort_order}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Role selector */}
|
||||||
|
<select
|
||||||
|
className="text-xs bg-slate-800 border border-slate-600 rounded px-2 py-1 text-slate-300"
|
||||||
|
value={room.room_role || ""}
|
||||||
|
onChange={(e) => handleUpdateRoom(room.id, { room_role: e.target.value || null })}
|
||||||
|
disabled={saving === room.id}
|
||||||
|
>
|
||||||
|
<option value="">Без ролі</option>
|
||||||
|
{ROLE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Visibility toggle */}
|
||||||
|
<button
|
||||||
|
className={`p-1.5 rounded ${room.is_public ? "text-green-400" : "text-slate-500"} hover:bg-slate-700/50`}
|
||||||
|
onClick={() => handleUpdateRoom(room.id, { is_public: !room.is_public })}
|
||||||
|
disabled={saving === room.id}
|
||||||
|
title={room.is_public ? "Публічна" : "Приватна"}
|
||||||
|
>
|
||||||
|
{room.is_public ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Set primary button */}
|
||||||
|
{room.room_role !== "primary" && (
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded text-slate-400 hover:text-amber-400 hover:bg-slate-700/50"
|
||||||
|
onClick={() => handleSetPrimary(room.id)}
|
||||||
|
disabled={saving === room.id}
|
||||||
|
title="Зробити основною"
|
||||||
|
>
|
||||||
|
{saving === room.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attach existing room form */}
|
||||||
|
<div className="border-t border-slate-700/50 pt-4">
|
||||||
|
{!showAttachForm ? (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 text-sm text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||||
|
onClick={() => setShowAttachForm(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Привʼязати існуючу кімнату
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 bg-slate-900/50 border border-slate-700/30 rounded-lg p-4">
|
||||||
|
<div className="text-sm text-slate-300 font-medium">Привʼязати кімнату</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 block mb-1">ID кімнати</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={attachRoomId}
|
||||||
|
onChange={(e) => setAttachRoomId(e.target.value)}
|
||||||
|
placeholder="room_city_..."
|
||||||
|
className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-sm text-slate-200 placeholder-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 block mb-1">Роль</label>
|
||||||
|
<select
|
||||||
|
value={attachRole}
|
||||||
|
onChange={(e) => setAttachRole(e.target.value)}
|
||||||
|
className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-sm text-slate-200"
|
||||||
|
>
|
||||||
|
<option value="">Без ролі</option>
|
||||||
|
{ROLE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-500">Порядок:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={attachSortOrder}
|
||||||
|
onChange={(e) => setAttachSortOrder(parseInt(e.target.value) || 100)}
|
||||||
|
className="w-20 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm text-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-sm text-slate-400 hover:text-slate-200 transition-colors"
|
||||||
|
onClick={() => setShowAttachForm(false)}
|
||||||
|
>
|
||||||
|
Скасувати
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-sm bg-cyan-500 hover:bg-cyan-400 text-white rounded transition-colors flex items-center gap-1"
|
||||||
|
onClick={handleAttachRoom}
|
||||||
|
disabled={saving === "attach"}
|
||||||
|
>
|
||||||
|
{saving === "attach" ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Привʼязати
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
143
apps/web/src/components/microdao/MicrodaoRoomsSection.tsx
Normal file
143
apps/web/src/components/microdao/MicrodaoRoomsSection.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { MessageCircle, Home, Users, FlaskConical, Shield, Vote, Hash } from "lucide-react";
|
||||||
|
import { CityRoomSummary } from "@/lib/types/microdao";
|
||||||
|
import { CityChatWidget } from "@/components/city/CityChatWidget";
|
||||||
|
|
||||||
|
interface MicrodaoRoomsSectionProps {
|
||||||
|
rooms: CityRoomSummary[];
|
||||||
|
primaryRoomSlug?: string | null;
|
||||||
|
showAllChats?: boolean; // If true, show chat widgets for all rooms
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
primary: "Основна кімната",
|
||||||
|
lobby: "Лобі",
|
||||||
|
team: "Командна кімната",
|
||||||
|
research: "Дослідницька лабораторія",
|
||||||
|
security: "Безпека",
|
||||||
|
governance: "Управління",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
primary: <Home className="w-4 h-4" />,
|
||||||
|
lobby: <MessageCircle className="w-4 h-4" />,
|
||||||
|
team: <Users className="w-4 h-4" />,
|
||||||
|
research: <FlaskConical className="w-4 h-4" />,
|
||||||
|
security: <Shield className="w-4 h-4" />,
|
||||||
|
governance: <Vote className="w-4 h-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
|
primary: "text-cyan-400 bg-cyan-500/10 border-cyan-500/30",
|
||||||
|
lobby: "text-green-400 bg-green-500/10 border-green-500/30",
|
||||||
|
team: "text-blue-400 bg-blue-500/10 border-blue-500/30",
|
||||||
|
research: "text-purple-400 bg-purple-500/10 border-purple-500/30",
|
||||||
|
security: "text-red-400 bg-red-500/10 border-red-500/30",
|
||||||
|
governance: "text-amber-400 bg-amber-500/10 border-amber-500/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MicrodaoRoomsSection({
|
||||||
|
rooms,
|
||||||
|
primaryRoomSlug,
|
||||||
|
showAllChats = false
|
||||||
|
}: MicrodaoRoomsSectionProps) {
|
||||||
|
if (!rooms || rooms.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||||
|
<MessageCircle className="w-5 h-5 text-cyan-400" />
|
||||||
|
Кімнати MicroDAO
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Для цього MicroDAO ще не налаштовані кімнати міста.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find primary room
|
||||||
|
const primary = rooms.find(r => r.slug === primaryRoomSlug)
|
||||||
|
?? rooms.find(r => r.room_role === 'primary')
|
||||||
|
?? rooms[0];
|
||||||
|
|
||||||
|
const others = rooms.filter(r => r.id !== primary.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||||
|
<MessageCircle className="w-5 h-5 text-cyan-400" />
|
||||||
|
Кімнати MicroDAO
|
||||||
|
<span className="text-sm font-normal text-slate-500">({rooms.length})</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Primary room with inline chat */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg border ${ROLE_COLORS[primary.room_role || 'primary'] || ROLE_COLORS.primary}`}>
|
||||||
|
{ROLE_ICONS[primary.room_role || 'primary'] || <Hash className="w-4 h-4" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-medium text-slate-100">{primary.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{ROLE_LABELS[primary.room_role || 'primary'] || primary.room_role || 'Кімната'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/city/${primary.slug}`}
|
||||||
|
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||||
|
>
|
||||||
|
Відкрити окремо →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CityChatWidget roomSlug={primary.slug} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Other rooms */}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-slate-400 font-medium">Інші кімнати</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{others.map(room => (
|
||||||
|
<div
|
||||||
|
key={room.id}
|
||||||
|
className="bg-slate-900/50 border border-slate-700/30 rounded-xl p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`p-1.5 rounded-lg border ${ROLE_COLORS[room.room_role || ''] || 'text-slate-400 bg-slate-500/10 border-slate-500/30'}`}>
|
||||||
|
{ROLE_ICONS[room.room_role || ''] || <Hash className="w-3.5 h-3.5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-slate-200">{room.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{ROLE_LABELS[room.room_role || ''] || room.room_role || 'Кімната'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/city/${room.slug}`}
|
||||||
|
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||||
|
>
|
||||||
|
Відкрити →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAllChats && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<CityChatWidget roomSlug={room.slug} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import type { MicrodaoSummary, MicrodaoDetail } from '@/lib/types/microdao';
|
import type { MicrodaoSummary, MicrodaoDetail, MicrodaoRoomsList, CityRoomSummary } from '@/lib/types/microdao';
|
||||||
|
|
||||||
interface UseMicrodaoListOptions {
|
interface UseMicrodaoListOptions {
|
||||||
district?: string;
|
district?: string;
|
||||||
@@ -138,3 +138,80 @@ export function useMicrodaoDetail(
|
|||||||
mutate: fetchData,
|
mutate: fetchData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useMicrodaoRooms - fetch all rooms for a MicroDAO
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface UseMicrodaoRoomsOptions {
|
||||||
|
refreshInterval?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseMicrodaoRoomsResult {
|
||||||
|
rooms: CityRoomSummary[];
|
||||||
|
microdaoId: string | null;
|
||||||
|
microdaoSlug: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
mutate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMicrodaoRooms(
|
||||||
|
slug: string | undefined,
|
||||||
|
options: UseMicrodaoRoomsOptions = {}
|
||||||
|
): UseMicrodaoRoomsResult {
|
||||||
|
const { refreshInterval = 60000, enabled = true } = options;
|
||||||
|
|
||||||
|
const [rooms, setRooms] = useState<CityRoomSummary[]>([]);
|
||||||
|
const [microdaoId, setMicrodaoId] = useState<string | null>(null);
|
||||||
|
const [microdaoSlug, setMicrodaoSlug] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!enabled || !slug) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/microdao/${encodeURIComponent(slug)}/rooms`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to fetch MicroDAO rooms');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: MicrodaoRoomsList = await res.json();
|
||||||
|
setRooms(data.rooms || []);
|
||||||
|
setMicrodaoId(data.microdao_id);
|
||||||
|
setMicrodaoSlug(data.microdao_slug);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Failed to fetch'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [slug, enabled]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || refreshInterval <= 0) return;
|
||||||
|
|
||||||
|
const interval = setInterval(fetchData, refreshInterval);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchData, refreshInterval, enabled]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rooms,
|
||||||
|
microdaoId,
|
||||||
|
microdaoSlug,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate: fetchData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export interface MicrodaoCitizenView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// City Room Summary (for chat embedding)
|
// City Room Summary (for chat embedding and multi-room support)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export interface CityRoomSummary {
|
export interface CityRoomSummary {
|
||||||
@@ -81,6 +81,39 @@ export interface CityRoomSummary {
|
|||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
matrix_room_id?: string | null;
|
matrix_room_id?: string | null;
|
||||||
|
microdao_id?: string | null;
|
||||||
|
microdao_slug?: string | null;
|
||||||
|
room_role?: string | null; // 'primary', 'lobby', 'team', 'research', 'security', 'governance'
|
||||||
|
is_public?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MicroDAO Rooms List (for /microdao/[slug]/rooms)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface MicrodaoRoomsList {
|
||||||
|
microdao_id: string;
|
||||||
|
microdao_slug: string;
|
||||||
|
rooms: CityRoomSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MicroDAO Room Update (for management)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface MicrodaoRoomUpdate {
|
||||||
|
room_role?: string | null;
|
||||||
|
is_public?: boolean | null;
|
||||||
|
sort_order?: number | null;
|
||||||
|
set_primary?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachExistingRoomRequest {
|
||||||
|
room_id: string;
|
||||||
|
room_role?: string | null;
|
||||||
|
is_public?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -114,8 +147,9 @@ export interface MicrodaoDetail {
|
|||||||
channels: MicrodaoChannelView[];
|
channels: MicrodaoChannelView[];
|
||||||
public_citizens: MicrodaoCitizenView[];
|
public_citizens: MicrodaoCitizenView[];
|
||||||
|
|
||||||
// Primary city room for chat
|
// Multi-room support
|
||||||
primary_city_room?: CityRoomSummary | null;
|
primary_city_room?: CityRoomSummary | null;
|
||||||
|
rooms?: CityRoomSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
201
docs/internal/architecture/MICRODAO_MULTI_ROOM_BACKEND.md
Normal file
201
docs/internal/architecture/MICRODAO_MULTI_ROOM_BACKEND.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# MicroDAO Multi-Room Architecture
|
||||||
|
|
||||||
|
**Дата:** 28 листопада 2025
|
||||||
|
**Статус:** ✅ Реалізовано (TASK 034-036)
|
||||||
|
|
||||||
|
## Огляд
|
||||||
|
|
||||||
|
Кожен MicroDAO може мати кілька внутрішніх кімнат (Matrix/City rooms), а не лише одну primary room. Це дозволяє організувати різні простори для команди: лобі, командні кімнати, дослідницькі лабораторії, безпека, управління тощо.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Схема БД
|
||||||
|
|
||||||
|
### Розширення `city_rooms`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE city_rooms
|
||||||
|
ADD COLUMN IF NOT EXISTS microdao_id uuid,
|
||||||
|
ADD COLUMN IF NOT EXISTS room_role text,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 100;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_city_rooms_microdao_id ON city_rooms(microdao_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_city_rooms_room_role ON city_rooms(room_role);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поля
|
||||||
|
|
||||||
|
| Поле | Тип | Опис |
|
||||||
|
|------|-----|------|
|
||||||
|
| `microdao_id` | UUID | Посилання на MicroDAO, якому належить кімната |
|
||||||
|
| `room_role` | TEXT | Роль кімнати: `primary`, `lobby`, `team`, `research`, `security`, `governance` |
|
||||||
|
| `is_public` | BOOLEAN | Чи видима кімната для не-членів MicroDAO |
|
||||||
|
| `sort_order` | INTEGER | Порядок відображення (менше = вище) |
|
||||||
|
|
||||||
|
### Ролі кімнат
|
||||||
|
|
||||||
|
| Роль | Опис |
|
||||||
|
|------|------|
|
||||||
|
| `primary` | Основна кімната MicroDAO (показується першою, використовується для чату за замовчуванням) |
|
||||||
|
| `lobby` | Лобі для зустрічей та привітань |
|
||||||
|
| `team` | Командна кімната для внутрішньої комунікації |
|
||||||
|
| `research` | Дослідницька лабораторія |
|
||||||
|
| `security` | Кімната безпеки |
|
||||||
|
| `governance` | Кімната управління та голосування |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Backend API
|
||||||
|
|
||||||
|
### Моделі
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CityRoomSummary(BaseModel):
|
||||||
|
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
|
||||||
|
is_public: bool = True
|
||||||
|
sort_order: int = 100
|
||||||
|
|
||||||
|
class MicrodaoRoomsList(BaseModel):
|
||||||
|
microdao_id: str
|
||||||
|
microdao_slug: str
|
||||||
|
rooms: List[CityRoomSummary]
|
||||||
|
|
||||||
|
class MicrodaoRoomUpdate(BaseModel):
|
||||||
|
room_role: Optional[str] = None
|
||||||
|
is_public: Optional[bool] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
set_primary: Optional[bool] = None
|
||||||
|
|
||||||
|
class AttachExistingRoomRequest(BaseModel):
|
||||||
|
room_id: str
|
||||||
|
room_role: Optional[str] = None
|
||||||
|
is_public: bool = True
|
||||||
|
sort_order: int = 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Метод | URL | Опис |
|
||||||
|
|-------|-----|------|
|
||||||
|
| GET | `/city/microdao/{slug}/rooms` | Отримати всі кімнати MicroDAO |
|
||||||
|
| POST | `/city/microdao/{slug}/rooms/attach-existing` | Прив'язати існуючу кімнату |
|
||||||
|
| PATCH | `/city/microdao/{slug}/rooms/{room_id}` | Оновити налаштування кімнати |
|
||||||
|
|
||||||
|
### Приклади
|
||||||
|
|
||||||
|
#### GET /city/microdao/daarion-dao/rooms
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"microdao_id": "uuid-here",
|
||||||
|
"microdao_slug": "daarion-dao",
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "room_leadership_hall",
|
||||||
|
"slug": "leadership-hall",
|
||||||
|
"name": "Leadership Hall",
|
||||||
|
"room_role": "primary",
|
||||||
|
"is_public": true,
|
||||||
|
"sort_order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room_system_control",
|
||||||
|
"slug": "system-control",
|
||||||
|
"name": "System Control",
|
||||||
|
"room_role": "governance",
|
||||||
|
"is_public": true,
|
||||||
|
"sort_order": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PATCH /city/microdao/daarion-dao/rooms/room_system_control
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"set_primary": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frontend
|
||||||
|
|
||||||
|
### Типи
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CityRoomSummary {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
matrix_room_id?: string | null;
|
||||||
|
microdao_id?: string | null;
|
||||||
|
microdao_slug?: string | null;
|
||||||
|
room_role?: string | null;
|
||||||
|
is_public?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MicrodaoRoomsList {
|
||||||
|
microdao_id: string;
|
||||||
|
microdao_slug: string;
|
||||||
|
rooms: CityRoomSummary[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
- `useMicrodaoRooms(slug)` — отримати кімнати MicroDAO
|
||||||
|
|
||||||
|
### Компоненти
|
||||||
|
|
||||||
|
- `MicrodaoRoomsSection` — відображення кімнат з чатами
|
||||||
|
- `MicrodaoRoomsAdminPanel` — панель керування кімнатами (для оркестратора)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UI Flow
|
||||||
|
|
||||||
|
1. **Сторінка MicroDAO** (`/microdao/[slug]`):
|
||||||
|
- Показує всі кімнати через `MicrodaoRoomsSection`
|
||||||
|
- Primary room — з вбудованим чатом
|
||||||
|
- Інші кімнати — як картки з посиланнями
|
||||||
|
|
||||||
|
2. **Панель адміністратора** (`MicrodaoRoomsAdminPanel`):
|
||||||
|
- Видима тільки для оркестратора
|
||||||
|
- Дозволяє:
|
||||||
|
- Змінювати роль кімнати
|
||||||
|
- Встановлювати primary
|
||||||
|
- Змінювати видимість
|
||||||
|
- Прив'язувати існуючі кімнати
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Міграції
|
||||||
|
|
||||||
|
- `migrations/031_microdao_multi_room.sql` — додає поля та індекси
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Пов'язані файли
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `services/city-service/models_city.py`
|
||||||
|
- `services/city-service/repo_city.py`
|
||||||
|
- `services/city-service/routes_city.py`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `apps/web/src/lib/types/microdao.ts`
|
||||||
|
- `apps/web/src/hooks/useMicrodao.ts`
|
||||||
|
- `apps/web/src/components/microdao/MicrodaoRoomsSection.tsx`
|
||||||
|
- `apps/web/src/components/microdao/MicrodaoRoomsAdminPanel.tsx`
|
||||||
|
- `apps/web/src/app/api/microdao/[slug]/rooms/route.ts`
|
||||||
|
|
||||||
95
migrations/031_microdao_multi_room.sql
Normal file
95
migrations/031_microdao_multi_room.sql
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
-- Migration 031: MicroDAO Multi-Room Support
|
||||||
|
-- Дозволяє кожному MicroDAO мати кілька кімнат
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Extend city_rooms for MicroDAO mapping
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE city_rooms
|
||||||
|
ADD COLUMN IF NOT EXISTS microdao_id uuid,
|
||||||
|
ADD COLUMN IF NOT EXISTS room_role text, -- 'primary', 'lobby', 'team', 'research', 'security', 'governance'
|
||||||
|
ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 100;
|
||||||
|
|
||||||
|
-- Indexes for filtering by microdao
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_city_rooms_microdao_id ON city_rooms(microdao_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_city_rooms_room_role ON city_rooms(room_role);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_city_rooms_sort_order ON city_rooms(sort_order);
|
||||||
|
|
||||||
|
-- Add comments
|
||||||
|
COMMENT ON COLUMN city_rooms.microdao_id IS 'Reference to microdao that owns this room';
|
||||||
|
COMMENT ON COLUMN city_rooms.room_role IS 'Role of room within MicroDAO: primary, lobby, team, research, security, governance';
|
||||||
|
COMMENT ON COLUMN city_rooms.is_public IS 'Whether room is visible to non-members';
|
||||||
|
COMMENT ON COLUMN city_rooms.sort_order IS 'Order for display (lower = first)';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Backfill: Link existing rooms to MicroDAOs based on naming conventions
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- DAARION DAO - leadership & system rooms
|
||||||
|
UPDATE city_rooms
|
||||||
|
SET microdao_id = (SELECT id FROM microdaos WHERE slug = 'daarion-dao' LIMIT 1),
|
||||||
|
room_role = 'primary',
|
||||||
|
sort_order = 0
|
||||||
|
WHERE slug = 'leadership-hall'
|
||||||
|
AND microdao_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE city_rooms
|
||||||
|
SET microdao_id = (SELECT id FROM microdaos WHERE slug = 'daarion-dao' LIMIT 1),
|
||||||
|
room_role = 'governance',
|
||||||
|
sort_order = 10
|
||||||
|
WHERE slug = 'system-control'
|
||||||
|
AND microdao_id IS NULL;
|
||||||
|
|
||||||
|
-- Engineering rooms -> Developer Hub MicroDAO
|
||||||
|
UPDATE city_rooms
|
||||||
|
SET microdao_id = (SELECT id FROM microdaos WHERE slug = 'developer-hub' LIMIT 1),
|
||||||
|
room_role = 'primary',
|
||||||
|
sort_order = 0
|
||||||
|
WHERE slug = 'engineering-lab'
|
||||||
|
AND microdao_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE city_rooms
|
||||||
|
SET microdao_id = (SELECT id FROM microdaos WHERE slug = 'developer-hub' LIMIT 1),
|
||||||
|
room_role = 'research',
|
||||||
|
sort_order = 10
|
||||||
|
WHERE slug = 'rnd-lab'
|
||||||
|
AND microdao_id IS NULL;
|
||||||
|
|
||||||
|
-- Security rooms -> Security MicroDAO (if exists)
|
||||||
|
UPDATE city_rooms
|
||||||
|
SET microdao_id = (SELECT id FROM microdaos WHERE slug LIKE '%security%' OR slug LIKE '%clan%' LIMIT 1),
|
||||||
|
room_role = 'primary',
|
||||||
|
sort_order = 0
|
||||||
|
WHERE slug = 'security-bunker'
|
||||||
|
AND microdao_id IS NULL;
|
||||||
|
|
||||||
|
-- Web3 rooms -> DAO-related MicroDAO
|
||||||
|
UPDATE city_rooms
|
||||||
|
SET microdao_id = (SELECT id FROM microdaos WHERE slug = 'daarion-dao' LIMIT 1),
|
||||||
|
room_role = 'team',
|
||||||
|
sort_order = 20
|
||||||
|
WHERE slug = 'web3-district'
|
||||||
|
AND microdao_id IS NULL;
|
||||||
|
|
||||||
|
-- Finance rooms
|
||||||
|
UPDATE city_rooms
|
||||||
|
SET microdao_id = (SELECT id FROM microdaos WHERE slug = 'daarion-dao' LIMIT 1),
|
||||||
|
room_role = 'team',
|
||||||
|
sort_order = 30
|
||||||
|
WHERE slug = 'finance-office'
|
||||||
|
AND microdao_id IS NULL;
|
||||||
|
|
||||||
|
-- Marketing rooms
|
||||||
|
UPDATE city_rooms
|
||||||
|
SET microdao_id = (SELECT id FROM microdaos WHERE slug = 'daarion-dao' LIMIT 1),
|
||||||
|
room_role = 'team',
|
||||||
|
sort_order = 40
|
||||||
|
WHERE slug = 'marketing-hub'
|
||||||
|
AND microdao_id IS NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Done
|
||||||
|
-- =============================================================================
|
||||||
|
SELECT 'Migration 031 completed: MicroDAO Multi-Room Support' as result;
|
||||||
|
|
||||||
@@ -409,11 +409,39 @@ class MicrodaoAgentView(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CityRoomSummary(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
|
id: str
|
||||||
slug: str
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
matrix_room_id: Optional[str] = None
|
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):
|
class MicrodaoDetail(BaseModel):
|
||||||
@@ -442,6 +470,9 @@ class MicrodaoDetail(BaseModel):
|
|||||||
logo_url: Optional[str] = None
|
logo_url: Optional[str] = None
|
||||||
agents: List[MicrodaoAgentView] = []
|
agents: List[MicrodaoAgentView] = []
|
||||||
channels: List[MicrodaoChannelView] = []
|
channels: List[MicrodaoChannelView] = []
|
||||||
|
|
||||||
|
# Multi-room support
|
||||||
|
rooms: List[CityRoomSummary] = []
|
||||||
public_citizens: List[MicrodaoCitizenView] = []
|
public_citizens: List[MicrodaoCitizenView] = []
|
||||||
|
|
||||||
# Primary city room for chat
|
# 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]:
|
async def get_microdao_primary_room(microdao_id: str) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Отримати основну кімнату MicroDAO для чату.
|
Отримати основну кімнату MicroDAO для чату.
|
||||||
Пріоритет: primary room → перша публічна кімната → будь-яка кімната.
|
Пріоритет: room_role='primary' → найнижчий sort_order → перша кімната.
|
||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
|
|
||||||
@@ -1742,15 +1742,17 @@ async def get_microdao_primary_room(microdao_id: str) -> Optional[dict]:
|
|||||||
cr.id,
|
cr.id,
|
||||||
cr.slug,
|
cr.slug,
|
||||||
cr.name,
|
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
|
FROM city_rooms cr
|
||||||
WHERE cr.microdao_id = $1
|
WHERE cr.microdao_id = $1
|
||||||
AND cr.is_active = true
|
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN cr.room_type = 'primary' THEN 0
|
CASE WHEN cr.room_role = 'primary' THEN 0 ELSE 1 END,
|
||||||
WHEN cr.room_type = 'public' THEN 1
|
cr.sort_order ASC,
|
||||||
ELSE 2 END,
|
cr.name ASC
|
||||||
cr.created_at
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -1760,7 +1762,195 @@ async def get_microdao_primary_room(microdao_id: str) -> Optional[dict]:
|
|||||||
"id": str(row["id"]),
|
"id": str(row["id"]),
|
||||||
"slug": row["slug"],
|
"slug": row["slug"],
|
||||||
"name": row["name"],
|
"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
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1510,17 +1510,28 @@ async def get_microdao_by_slug(slug: str):
|
|||||||
is_platform=child.get("is_platform", False)
|
is_platform=child.get("is_platform", False)
|
||||||
))
|
))
|
||||||
|
|
||||||
# Get primary city room for MicroDAO
|
# Get all rooms for MicroDAO (multi-room support)
|
||||||
primary_city_room = await repo_city.get_microdao_primary_room(dao["id"])
|
all_rooms = await repo_city.get_microdao_rooms(dao["id"])
|
||||||
primary_room_summary = None
|
rooms_list = [
|
||||||
if primary_city_room:
|
CityRoomSummary(
|
||||||
from models_city import CityRoomSummary
|
id=room["id"],
|
||||||
primary_room_summary = CityRoomSummary(
|
slug=room["slug"],
|
||||||
id=primary_city_room["id"],
|
name=room["name"],
|
||||||
slug=primary_city_room["slug"],
|
matrix_room_id=room.get("matrix_room_id"),
|
||||||
name=primary_city_room["name"],
|
microdao_id=room.get("microdao_id"),
|
||||||
matrix_room_id=primary_city_room.get("matrix_room_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(
|
return MicrodaoDetail(
|
||||||
id=dao["id"],
|
id=dao["id"],
|
||||||
@@ -1540,7 +1551,8 @@ async def get_microdao_by_slug(slug: str):
|
|||||||
agents=agents,
|
agents=agents,
|
||||||
channels=channels,
|
channels=channels,
|
||||||
public_citizens=public_citizens,
|
public_citizens=public_citizens,
|
||||||
primary_city_room=primary_room_summary
|
primary_city_room=primary_room_summary,
|
||||||
|
rooms=rooms_list
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -1552,6 +1564,143 @@ async def get_microdao_by_slug(slug: str):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to get microdao")
|
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)
|
# MicroDAO Visibility & Creation (Task 029)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user