feat: Add MicroDAO Dashboard with activity feed and statistics

- Add microdao_activity table for news/updates/events
- Add statistics columns to microdaos table
- Implement dashboard API endpoints
- Create UI components (HeaderCard, ActivitySection, TeamSection)
- Add seed data for DAARION DAO
- Update backend models and repositories
- Add frontend types and API client
This commit is contained in:
Apple
2025-12-02 06:37:16 -08:00
parent 95c9a17a7a
commit ace183e136
15 changed files with 686 additions and 9 deletions

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
const CITY_API_BASE_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || "http://daarion-city-service:7001";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const { searchParams } = new URL(req.url);
const limit = searchParams.get("limit") || "20";
try {
const res = await fetch(
`${CITY_API_BASE_URL}/city/microdao/${encodeURIComponent(slug)}/activity?limit=${limit}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
cache: "no-store",
}
);
const data = await res.json().catch(() => null);
return NextResponse.json(data, { status: res.status });
} catch (error) {
console.error("MicroDAO activity proxy error:", error);
return NextResponse.json(
{ error: "Failed to fetch MicroDAO activity" },
{ status: 502 }
);
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const body = await req.json();
try {
const res = await fetch(
`${CITY_API_BASE_URL}/city/microdao/${encodeURIComponent(slug)}/activity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
const data = await res.json().catch(() => null);
return NextResponse.json(data, { status: res.status });
} catch (error) {
console.error("MicroDAO activity create proxy error:", error);
return NextResponse.json(
{ error: "Failed to create MicroDAO activity" },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
const CITY_API_BASE_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || "http://daarion-city-service:7001";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
try {
const res = await fetch(
`${CITY_API_BASE_URL}/city/microdao/${encodeURIComponent(slug)}/dashboard`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
cache: "no-store",
}
);
const data = await res.json().catch(() => null);
return NextResponse.json(data, { status: res.status });
} catch (error) {
console.error("MicroDAO dashboard proxy error:", error);
return NextResponse.json(
{ error: "Failed to fetch MicroDAO dashboard" },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,107 @@
"use client";
import { MicrodaoActivity, MicrodaoSummary } from "@/lib/types/microdao";
interface MicrodaoActivitySectionProps {
activity: MicrodaoActivity[];
microdao: MicrodaoSummary;
}
export function MicrodaoActivitySection({ activity, microdao }: MicrodaoActivitySectionProps) {
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "щойно";
if (diffMins < 60) return `${diffMins} хв тому`;
if (diffHours < 24) return `${diffHours} год тому`;
if (diffDays < 7) return `${diffDays} дн тому`;
return date.toLocaleDateString("uk-UA", { day: "numeric", month: "short" });
} catch {
return dateString;
}
};
const getKindLabel = (kind: string) => {
switch (kind) {
case "post":
return "Новина";
case "event":
return "Подія";
case "update":
return "Оновлення";
default:
return kind;
}
};
const getKindColor = (kind: string) => {
switch (kind) {
case "post":
return "bg-blue-500/10 text-blue-300 border-blue-500/30";
case "event":
return "bg-purple-500/10 text-purple-300 border-purple-500/30";
case "update":
return "bg-green-500/10 text-green-300 border-green-500/30";
default:
return "bg-slate-500/10 text-slate-300 border-slate-500/30";
}
};
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">
<span className="w-1 h-6 bg-cyan-400 rounded-full" />
Новини {microdao.name}
</h2>
{activity.length === 0 ? (
<div className="text-sm text-slate-500 py-8 text-center">
Поки що немає новин
</div>
) : (
<div className="space-y-4">
{activity.map((item) => (
<div
key={item.id}
className="bg-slate-900/50 border border-slate-700/30 rounded-lg p-4 hover:border-slate-600/50 transition-colors"
>
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-[10px] font-medium border ${getKindColor(item.kind)}`}>
{getKindLabel(item.kind)}
</span>
{item.title && (
<h3 className="text-sm font-semibold text-slate-200">
{item.title}
</h3>
)}
</div>
<span className="text-xs text-slate-500 whitespace-nowrap">
{formatDate(item.created_at)}
</span>
</div>
<p className="text-sm text-slate-300 leading-relaxed line-clamp-3">
{item.body}
</p>
{item.author_name && (
<div className="mt-2 text-xs text-slate-500">
Автор: {item.author_name}
</div>
)}
</div>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import Link from "next/link";
import { MessageCircle, Plus, Users, MessageSquare, Bot } from "lucide-react";
import { MicrodaoDashboard } from "@/lib/types/microdao";
import { normalizeAssetUrl } from "@/lib/utils/assetUrl";
import { Button } from "@/components/ui/button";
interface MicrodaoHeaderCardProps {
dashboard: MicrodaoDashboard;
}
export function MicrodaoHeaderCard({ dashboard }: MicrodaoHeaderCardProps) {
const { microdao, stats } = dashboard;
return (
<section
className="rounded-3xl border border-white/10 bg-gradient-to-br from-sky-950/50 via-slate-900 to-black p-6 md:p-8 space-y-6 relative overflow-hidden shadow-2xl shadow-sky-900/10"
style={microdao.banner_url ? {
backgroundImage: `linear-gradient(to bottom, rgba(15, 23, 42, 0.85), rgba(15, 23, 42, 0.95)), url(${normalizeAssetUrl(microdao.banner_url)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
} : undefined}
>
{/* Background pattern */}
{!microdao.banner_url && (
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] opacity-20" />
)}
<div className="relative z-10 flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
<div className="space-y-5 max-w-3xl">
{/* Badges */}
<div className="flex flex-wrap items-center gap-2">
<span className={`px-2.5 py-0.5 rounded-full text-[11px] uppercase tracking-wider font-semibold border ${
microdao.is_platform
? "border-amber-500/40 text-amber-300 bg-amber-500/10"
: "border-cyan-400/40 text-cyan-300 bg-cyan-500/10"
}`}>
{microdao.is_platform ? "Platform District" : "MicroDAO"}
</span>
{microdao.district && (
<span className="px-2.5 py-0.5 rounded-full text-[11px] uppercase tracking-wider font-medium border border-white/10 text-white/60 bg-white/5">
{microdao.district}
</span>
)}
<span className={`px-2.5 py-0.5 rounded-full text-[11px] font-medium border flex items-center gap-1.5 ${
microdao.is_active
? "border-emerald-500/30 text-emerald-400 bg-emerald-500/5"
: "border-slate-600 text-slate-400 bg-slate-800"
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${microdao.is_active ? 'bg-emerald-400' : 'bg-slate-500'}`} />
{microdao.is_active ? "Active" : "Inactive"}
</span>
</div>
{/* Title & Description with Logo */}
<div className="space-y-3">
<div className="flex items-center gap-4">
{/* Logo */}
{normalizeAssetUrl(microdao.logo_url) && (
<img
src={normalizeAssetUrl(microdao.logo_url)!}
alt={microdao.name}
className="w-16 h-16 md:w-20 md:h-20 rounded-2xl object-cover bg-slate-700/50 border border-white/10 shadow-lg"
/>
)}
<h1 className="text-3xl md:text-5xl font-bold text-white tracking-tight leading-tight">
{microdao.name}
</h1>
</div>
{microdao.description && (
<p className="text-base md:text-lg text-slate-300 leading-relaxed max-w-2xl">
{microdao.description}
</p>
)}
</div>
{/* Key Stats */}
<div className="flex flex-wrap gap-3 pt-2">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
<MessageSquare className="w-4 h-4 text-white/60" />
<span className="text-sm text-white/80">{stats.rooms_count} Кімнат</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
<Users className="w-4 h-4 text-white/60" />
<span className="text-sm text-white/80">{stats.citizens_count} Громадян</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
<Bot className="w-4 h-4 text-white/60" />
<span className="text-sm text-white/80">{stats.agents_count} Агентів</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-3 pt-2">
<Link href="/city?room=city-lobby">
<Button
variant="default"
className="bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 text-cyan-300"
>
<MessageCircle className="w-4 h-4 mr-2" />
Поспілкуватися з DAARWIZZ
</Button>
</Link>
<a href="#microdao-rooms">
<Button
variant="outline"
className="bg-white/5 hover:bg-white/10 border-white/20 text-white/80"
>
<Plus className="w-4 h-4 mr-2" />
Створити кімнату
</Button>
</a>
</div>
</div>
{/* Logo (right side) */}
<div className="hidden md:flex flex-col gap-3">
<div className="w-24 h-24 rounded-2xl bg-slate-800 border border-white/10 flex items-center justify-center overflow-hidden shadow-xl">
{normalizeAssetUrl(microdao.logo_url) ? (
<img
src={normalizeAssetUrl(microdao.logo_url)!}
alt={microdao.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-10 h-10 text-slate-600" />
)}
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { MicrodaoSummary } from "@/lib/types/microdao";
import { FolderKanban } from "lucide-react";
interface MicrodaoProjectsSectionProps {
microdao: MicrodaoSummary;
}
export function MicrodaoProjectsSection({ microdao }: MicrodaoProjectsSectionProps) {
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">
<FolderKanban className="w-5 h-5 text-cyan-400" />
Проєкти DAO
</h2>
<div className="text-sm text-slate-500 py-8 text-center">
Скоро тут будуть проєкти MicroDAO
</div>
</section>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { MicrodaoSummary } from "@/lib/types/microdao";
import { CheckSquare } from "lucide-react";
interface MicrodaoTasksSectionProps {
microdao: MicrodaoSummary;
}
export function MicrodaoTasksSection({ microdao }: MicrodaoTasksSectionProps) {
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">
<CheckSquare className="w-5 h-5 text-cyan-400" />
Задачі DAO
</h2>
<div className="text-sm text-slate-500 py-8 text-center">
Скоро тут будуть задачі MicroDAO
</div>
</section>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { PublicCitizenSummary } from "@/lib/types/microdao";
import { normalizeAssetUrl } from "@/lib/utils/assetUrl";
import { Users, ArrowRight } from "lucide-react";
interface MicrodaoTeamSectionProps {
citizens: PublicCitizenSummary[];
microdaoSlug?: string;
}
export function MicrodaoTeamSection({ citizens, microdaoSlug }: MicrodaoTeamSectionProps) {
return (
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
<Users className="w-5 h-5 text-cyan-400" />
Команда
<span className="text-sm font-normal text-slate-500">({citizens.length})</span>
</h2>
{microdaoSlug && (
<Link
href={`/citizens${microdaoSlug ? `?microdao=${microdaoSlug}` : ''}`}
className="text-xs text-cyan-400 hover:text-cyan-300 flex items-center gap-1"
>
Всі громадяни
<ArrowRight className="w-3 h-3" />
</Link>
)}
</div>
{citizens.length === 0 ? (
<div className="text-sm text-slate-500 py-4 text-center">
Немає громадян у цьому MicroDAO
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{citizens.slice(0, 6).map((citizen) => (
<Link
key={citizen.slug || citizen.display_name}
href={citizen.slug ? `/citizens/${citizen.slug}` : '#'}
className="bg-slate-900/50 border border-slate-700/30 rounded-lg p-3 hover:border-cyan-500/40 transition-colors flex items-center gap-3"
>
{citizen.avatar_url ? (
<img
src={normalizeAssetUrl(citizen.avatar_url)!}
alt={citizen.display_name}
className="w-10 h-10 rounded-lg object-cover bg-slate-700/50"
/>
) : (
<div className="w-10 h-10 rounded-lg bg-slate-700/50 flex items-center justify-center">
<Users className="w-5 h-5 text-slate-500" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">
{citizen.display_name}
</p>
{citizen.public_title && (
<p className="text-xs text-slate-500 truncate">
{citizen.public_title}
</p>
)}
{citizen.district && (
<p className="text-[10px] text-slate-600 mt-1">
{citizen.district}
</p>
)}
</div>
</Link>
))}
</div>
)}
</section>
);
}

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { MicrodaoSummary, MicrodaoDetail, MicrodaoRoomsList, CityRoomSummary } from '@/lib/types/microdao'; import type { MicrodaoSummary, MicrodaoDetail, MicrodaoRoomsList, CityRoomSummary, MicrodaoDashboard } from '@/lib/types/microdao';
import { fetchMicrodaoDashboard } from '@/lib/api/microdao';
interface UseMicrodaoListOptions { interface UseMicrodaoListOptions {
district?: string; district?: string;
@@ -302,3 +303,60 @@ export function useMicrodaoAgents(
mutate: fetchData, mutate: fetchData,
}; };
} }
// =============================================================================
// MicroDAO Dashboard Hook (TASK_PHASE_MICRODAO_DASHBOARD_v1)
// =============================================================================
interface UseMicrodaoDashboardOptions {
refreshInterval?: number;
enabled?: boolean;
}
interface UseMicrodaoDashboardResult {
dashboard: MicrodaoDashboard | null;
isLoading: boolean;
error: Error | null;
mutate: () => Promise<void>;
}
export function useMicrodaoDashboard(
slug: string | undefined,
options: UseMicrodaoDashboardOptions = {}
): UseMicrodaoDashboardResult {
const { refreshInterval = 60000, enabled = true } = options;
const [dashboard, setDashboard] = useState<MicrodaoDashboard | 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 data = await fetchMicrodaoDashboard(slug);
setDashboard(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch dashboard'));
} finally {
setIsLoading(false);
}
}, [slug, enabled]);
useEffect(() => {
fetchData();
if (refreshInterval > 0) {
const interval = setInterval(fetchData, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchData, refreshInterval]);
const mutate = useCallback(async () => {
await fetchData();
}, [fetchData]);
return { dashboard, isLoading, error, mutate };
}

View File

@@ -2,7 +2,13 @@
* MicroDAO API Client (Task 029) * MicroDAO API Client (Task 029)
*/ */
import { MicrodaoOption, CityRoomSummary } from "@/lib/types/microdao"; import {
MicrodaoOption,
CityRoomSummary,
MicrodaoDashboard,
MicrodaoActivity,
CreateMicrodaoActivity
} from "@/lib/types/microdao";
// ============================================================================= // =============================================================================
// Types // Types

View File

@@ -177,3 +177,47 @@ export interface AgentMicrodaoMembership {
is_core: boolean; is_core: boolean;
} }
// =============================================================================
// MicroDAO Activity & Dashboard (TASK_PHASE_MICRODAO_DASHBOARD_v1)
// =============================================================================
export type MicrodaoActivityKind = "post" | "event" | "update";
export interface MicrodaoActivity {
id: string;
microdao_slug: string;
kind: MicrodaoActivityKind;
title?: string | null;
body: string;
author_agent_id?: string | null;
author_name?: string | null;
created_at: string;
}
export interface CreateMicrodaoActivity {
kind: MicrodaoActivityKind;
title?: string | null;
body: string;
author_agent_id?: string | null;
author_name?: string | null;
}
export interface MicrodaoStats {
rooms_count: number;
citizens_count: number;
agents_count: number;
last_update_at?: string | null;
}
export interface MicrodaoDashboard {
microdao: MicrodaoSummary;
stats: MicrodaoStats;
recent_activity: MicrodaoActivity[];
rooms: CityRoomSummary[];
citizens: PublicCitizenSummary[];
}
// Re-export PublicCitizenSummary from citizens types
import type { PublicCitizenSummary } from './citizens';
export type { PublicCitizenSummary };

View File

@@ -0,0 +1,20 @@
-- Seed data for DAARION MicroDAO activity feed
-- Run this after migrations 044 and 045 are applied
INSERT INTO microdao_activity (microdao_slug, kind, title, body, author_name)
VALUES
('daarion', 'post', 'Launch of DAARION City Lobby', 'Відкрили головний публічний чат DAARION City з агентом DAARWIZZ. Тепер всі громадяни можуть спілкуватися в реальному часі.', 'DAARWIZZ'),
('daarion', 'update', 'NODE2 DAGI Stack Online', 'На НОДА2 розгорнуто DAGI Router та 8 моделей Swapper. Система працює стабільно, всі агенти доступні.', 'Helix'),
('daarion', 'post', 'Energy Union MicroDAO created', 'Запустили платформу Energy Union для оптимізації енергетичних систем. Новий MicroDAO готовий до роботи.', 'Solarius'),
('daarion', 'event', 'City Infrastructure Update', 'Оновлено інфраструктуру міста: додано підтримку мультимодальних агентів, покращено маршрутизацію повідомлень.', 'System'),
('daarion', 'post', 'New Citizens Welcome', 'Вітаємо нових громадян DAARION! Система активно розвивається, дякуємо за участь.', 'DAARWIZZ'),
('daarion', 'update', 'Matrix Gateway Integration', 'Повністю інтегровано Matrix Gateway для децентралізованого обміну повідомленнями між кімнатами.', 'Helix'),
('daarion', 'post', 'MicroDAO Dashboard Released', 'Запущено новий дашборд для MicroDAO з метриками, активністю та управлінням командою.', 'System'),
('daarion', 'event', 'Node Registry Self-Healing', 'Активовано систему самовідновлення для Node Registry. Всі ноди моніторяться автоматично.', 'System'),
('daarion', 'update', 'Swapper Models Updated', 'Оновлено моделі в Swapper: додано підтримку нових мовних моделей та оптимізовано використання VRAM.', 'Helix'),
('daarion', 'post', 'Community Growth', 'DAARION City зростає! Кількість активних агентів та громадян збільшилася вдвічі за останній місяць.', 'DAARWIZZ');
-- Optional: Link some key agents to DAARION if they exist
-- UPDATE agents SET home_microdao_id = (SELECT id FROM city_microdao WHERE slug = 'daarion')
-- WHERE slug IN ('daarwizz', 'helix', 'solarius', 'yaromir', 'monitor');

View File

@@ -0,0 +1,22 @@
-- 044_microdao_activity.sql
-- Migration: Create microdao_activity table for news/updates/events feed
CREATE TABLE IF NOT EXISTS microdao_activity (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
microdao_slug TEXT NOT NULL REFERENCES microdaos(slug) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('post', 'event', 'update')),
title TEXT,
body TEXT NOT NULL,
author_agent_id TEXT NULL,
author_name TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_microdao_activity_microdao_created_at
ON microdao_activity (microdao_slug, created_at DESC);
COMMENT ON TABLE microdao_activity IS 'Activity feed for MicroDAO: posts, events, updates';
COMMENT ON COLUMN microdao_activity.kind IS 'Type: post (news), event (announcement), update (status change)';
COMMENT ON COLUMN microdao_activity.author_agent_id IS 'Optional reference to agent who created this activity';
COMMENT ON COLUMN microdao_activity.author_name IS 'Fallback author name if agent_id is not set';

View File

@@ -0,0 +1,14 @@
-- 045_microdao_stats.sql
-- Migration: Add statistics columns to microdaos table
ALTER TABLE microdaos
ADD COLUMN IF NOT EXISTS citizens_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS rooms_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS agents_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS last_update_at TIMESTAMPTZ;
COMMENT ON COLUMN microdaos.citizens_count IS 'Cached count of citizens (public agents) linked to this MicroDAO';
COMMENT ON COLUMN microdaos.rooms_count IS 'Cached count of rooms belonging to this MicroDAO';
COMMENT ON COLUMN microdaos.agents_count IS 'Cached count of agents (all) linked to this MicroDAO';
COMMENT ON COLUMN microdaos.last_update_at IS 'Last time statistics were updated';

View File

@@ -4248,8 +4248,13 @@ async def get_microdao_dashboard(slug: str) -> dict:
# Конвертувати citizens в PublicCitizenSummary # Конвертувати citizens в PublicCitizenSummary
citizen_summaries = [] citizen_summaries = []
for citizen in citizens: for citizen in citizens:
# Переконатися що slug не None
slug = citizen.get("slug")
if not slug:
continue # Пропустити громадян без slug
citizen_summaries.append({ citizen_summaries.append({
"slug": citizen.get("slug"), "slug": slug,
"display_name": citizen["display_name"], "display_name": citizen["display_name"],
"public_title": citizen.get("public_title"), "public_title": citizen.get("public_title"),
"public_tagline": citizen.get("public_tagline"), "public_tagline": citizen.get("public_tagline"),
@@ -4257,7 +4262,7 @@ async def get_microdao_dashboard(slug: str) -> dict:
"kind": citizen.get("kind"), "kind": citizen.get("kind"),
"district": citizen.get("district"), "district": citizen.get("district"),
"primary_room_slug": citizen.get("primary_room_slug"), "primary_room_slug": citizen.get("primary_room_slug"),
"public_skills": citizen.get("public_skills", []), "public_skills": list(citizen.get("public_skills", [])) if citizen.get("public_skills") else [],
"online_status": citizen.get("status", "unknown"), "online_status": citizen.get("status", "unknown"),
"status": citizen.get("status"), "status": citizen.get("status"),
"node_id": citizen.get("node_id"), "node_id": citizen.get("node_id"),
@@ -4268,15 +4273,20 @@ async def get_microdao_dashboard(slug: str) -> dict:
# Конвертувати activity в MicrodaoActivity # Конвертувати activity в MicrodaoActivity
activity_list = [] activity_list = []
for act in activity: for act in activity:
# Конвертувати UUID в string, якщо потрібно
author_id = act.get("author_agent_id")
if author_id:
author_id = str(author_id) if not isinstance(author_id, str) else author_id
activity_list.append({ activity_list.append({
"id": str(act["id"]), "id": str(act["id"]),
"microdao_slug": act["microdao_slug"], "microdao_slug": act["microdao_slug"],
"kind": act["kind"], "kind": act["kind"],
"title": act.get("title"), "title": act.get("title"),
"body": act["body"], "body": act["body"],
"author_agent_id": str(act["author_agent_id"]) if act.get("author_agent_id") else None, "author_agent_id": author_id,
"author_name": act.get("author_name"), "author_name": act.get("author_name"),
"created_at": act["created_at"] "created_at": act["created_at"].isoformat() if hasattr(act["created_at"], "isoformat") else str(act["created_at"])
}) })
# Створити MicrodaoSummary # Створити MicrodaoSummary
@@ -4305,11 +4315,17 @@ async def get_microdao_dashboard(slug: str) -> dict:
} }
# Створити stats # Створити stats
last_update = microdao.get("updated_at")
if last_update and hasattr(last_update, "isoformat"):
last_update = last_update.isoformat()
elif last_update:
last_update = str(last_update)
stats = { stats = {
"rooms_count": rooms_count, "rooms_count": rooms_count,
"citizens_count": citizens_count, "citizens_count": citizens_count,
"agents_count": agents_count, "agents_count": agents_count,
"last_update_at": microdao.get("updated_at") "last_update_at": last_update
} }
return { return {

View File

@@ -4792,12 +4792,47 @@ async def trigger_node_self_healing(node_id: str):
async def api_get_microdao_dashboard(slug: str): async def api_get_microdao_dashboard(slug: str):
"""Отримати повний дашборд для MicroDAO""" """Отримати повний дашборд для MicroDAO"""
try: try:
dashboard = await repo_city.get_microdao_dashboard(slug) dashboard_dict = await repo_city.get_microdao_dashboard(slug)
# Конвертувати dict в Pydantic моделі
# MicrodaoSummary вже правильно сформований
# Stats потрібно конвертувати
stats = dashboard_dict["stats"]
if stats.get("last_update_at"):
try:
if isinstance(stats["last_update_at"], str):
stats["last_update_at"] = datetime.fromisoformat(stats["last_update_at"].replace("Z", "+00:00"))
elif hasattr(stats["last_update_at"], "isoformat"):
pass # Вже datetime
except Exception:
stats["last_update_at"] = None
# Activity потрібно конвертувати
activity_list = []
for act in dashboard_dict["recent_activity"]:
act_dict = dict(act)
if act_dict.get("created_at"):
try:
if isinstance(act_dict["created_at"], str):
act_dict["created_at"] = datetime.fromisoformat(act_dict["created_at"].replace("Z", "+00:00"))
except Exception:
pass
activity_list.append(MicrodaoActivity(**act_dict))
# Створити повний об'єкт
dashboard = MicrodaoDashboard(
microdao=MicrodaoSummary(**dashboard_dict["microdao"]),
stats=MicrodaoStats(**stats),
recent_activity=activity_list,
rooms=[CityRoomSummary(**r) for r in dashboard_dict["rooms"]],
citizens=[PublicCitizenSummary(**c) for c in dashboard_dict["citizens"]]
)
return dashboard return dashboard
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to get microdao dashboard for {slug}: {e}") logger.error(f"Failed to get microdao dashboard for {slug}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to load dashboard") raise HTTPException(status_code=500, detail="Failed to load dashboard")