feat(governance): Migrate Governance Engine to Next.js (apps/web)

BREAKING: Replace old MicroDAO voting with Agent Governance Engine

## New Files
- apps/web/src/lib/types/governance.ts
- apps/web/src/lib/api/governance.ts
- apps/web/src/lib/api/audit.ts
- apps/web/src/lib/api/incidents.ts
- apps/web/src/components/governance/GovernanceLevelBadge.tsx
- apps/web/src/components/governance/ReportButton.tsx
- apps/web/src/components/governance/CityGovernancePanel.tsx
- apps/web/src/components/governance/AuditDashboard.tsx
- apps/web/src/components/governance/IncidentsList.tsx
- apps/web/src/app/audit/page.tsx
- apps/web/src/app/incidents/page.tsx

## Updated Files
- apps/web/src/app/governance/page.tsx - New City Governance UI
- apps/web/src/components/Navigation.tsx - Shield icon for Governance

## Task
docs/tasks/TASK_PHASE_GOVERNANCE_MIGRATION_NEXTJS.md
This commit is contained in:
Apple
2025-11-29 16:41:28 -08:00
parent 2008332ce1
commit ec9ff3e633
27 changed files with 2042 additions and 25605 deletions

View File

@@ -0,0 +1,15 @@
import { AuditDashboard } from '@/components/governance/AuditDashboard';
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export default function AuditPage() {
return (
<div className="min-h-screen px-4 py-8">
<div className="max-w-6xl mx-auto">
<AuditDashboard />
</div>
</div>
);
}

View File

@@ -1,204 +1,14 @@
import Link from 'next/link'
import { Wallet, Users, Vote, FileText, TrendingUp, Shield, ArrowRight } from 'lucide-react'
import { api, MicroDAO } from '@/lib/api'
import { cn } from '@/lib/utils'
import { CityGovernancePanel } from '@/components/governance/CityGovernancePanel';
// Force dynamic rendering
export const dynamic = 'force-dynamic'
async function getMicroDAOs(): Promise<MicroDAO[]> {
try {
return await api.getMicroDAOs()
} catch (error) {
console.error('Failed to fetch MicroDAOs:', error)
return []
}
}
export default async function GovernancePage() {
const daos = await getMicroDAOs()
export const dynamic = 'force-dynamic';
export default function GovernancePage() {
return (
<div className="min-h-screen px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/20">
<Wallet className="w-8 h-8 text-amber-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Governance</h1>
<p className="text-slate-400">MicroDAO управління та голосування</p>
</div>
</div>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<StatCard icon={Users} label="MicroDAOs" value={daos.length.toString()} color="amber" />
<StatCard icon={Vote} label="Активних пропозицій" value="0" color="cyan" />
<StatCard icon={FileText} label="Всього пропозицій" value="0" color="violet" />
<StatCard icon={TrendingUp} label="Участь" value="0%" color="emerald" />
</div>
{/* MicroDAOs List */}
<div className="mb-12">
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-400" />
Ваші MicroDAO
</h2>
{daos.length === 0 ? (
<div className="glass-panel p-12 text-center">
<Wallet className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">
MicroDAO не знайдено
</h3>
<p className="text-slate-400 mb-6">
Ви ще не є учасником жодного MicroDAO.
</p>
<button className="px-6 py-3 bg-gradient-to-r from-amber-500 to-orange-600 rounded-xl font-medium text-white hover:from-amber-400 hover:to-orange-500 transition-all">
Створити MicroDAO
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{daos.map((dao) => (
<DAOCard key={dao.id} dao={dao} />
))}
</div>
)}
</div>
{/* Quick Actions */}
<div>
<h2 className="text-xl font-semibold text-white mb-4">Швидкі дії</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<ActionCard
icon={Vote}
title="Голосувати"
description="Переглянути активні пропозиції та проголосувати"
href="/governance/proposals"
/>
<ActionCard
icon={FileText}
title="Створити пропозицію"
description="Запропонувати зміни для вашого MicroDAO"
href="/governance/create-proposal"
/>
<ActionCard
icon={Users}
title="Учасники"
description="Переглянути членів та їх ролі"
href="/governance/members"
/>
</div>
</div>
<CityGovernancePanel />
</div>
</div>
)
);
}
function StatCard({
icon: Icon,
label,
value,
color
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: string
color: 'amber' | 'cyan' | 'violet' | 'emerald'
}) {
const colorClasses = {
amber: 'text-amber-400',
cyan: 'text-cyan-400',
violet: 'text-violet-400',
emerald: 'text-emerald-400'
}
return (
<div className="glass-panel p-4">
<Icon className={cn('w-5 h-5 mb-2', colorClasses[color])} />
<div className="text-2xl font-bold text-white">{value}</div>
<div className="text-xs text-slate-400">{label}</div>
</div>
)
}
function DAOCard({ dao }: { dao: MicroDAO }) {
return (
<Link
href={`/governance/${dao.id}`}
className="glass-panel-hover p-6 group block"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500/30 to-orange-600/30 flex items-center justify-center">
<Shield className="w-6 h-6 text-amber-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white group-hover:text-amber-400 transition-colors">
{dao.name}
</h3>
<p className="text-sm text-slate-400">{dao.slug}</p>
</div>
</div>
<span className={cn(
'px-2 py-0.5 text-xs rounded-full',
dao.is_active
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-slate-700/50 text-slate-400'
)}>
{dao.is_active ? 'Активний' : 'Неактивний'}
</span>
</div>
<p className="text-sm text-slate-400 mb-4 line-clamp-2">
{dao.description || 'Без опису'}
</p>
<div className="flex items-center justify-between pt-4 border-t border-white/5">
<div className="flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
0 учасників
</span>
<span className="flex items-center gap-1">
<Vote className="w-3 h-3" />
0 пропозицій
</span>
</div>
<ArrowRight className="w-5 h-5 text-slate-500 group-hover:text-amber-400 group-hover:translate-x-1 transition-all" />
</div>
</Link>
)
}
function ActionCard({
icon: Icon,
title,
description,
href
}: {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
href: string
}) {
return (
<Link
href={href}
className="glass-panel p-5 hover:bg-white/5 transition-colors group"
>
<Icon className="w-8 h-8 text-amber-400 mb-3 group-hover:scale-110 transition-transform" />
<h3 className="font-semibold text-white mb-1">{title}</h3>
<p className="text-sm text-slate-400">{description}</p>
</Link>
)
}

View File

@@ -0,0 +1,15 @@
import { IncidentsList } from '@/components/governance/IncidentsList';
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export default function IncidentsPage() {
return (
<div className="min-h-screen px-4 py-8">
<div className="max-w-6xl mx-auto">
<IncidentsList />
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useState } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet, LogOut, Loader2, Server, Users, Network } from 'lucide-react'
import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet, LogOut, Loader2, Server, Users, Network, Shield } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/context/AuthContext'
@@ -13,7 +13,7 @@ const navItems = [
{ href: '/citizens', label: 'Громадяни', icon: Users },
{ href: '/agents', label: 'Агенти', icon: Bot },
{ href: '/microdao', label: 'MicroDAO', icon: Network },
{ href: '/governance', label: 'DAO', icon: Wallet },
{ href: '/governance', label: 'Governance', icon: Shield },
{ href: '/secondme', label: 'Second Me', icon: User },
{ href: '/nodes', label: 'Ноди', icon: Server },
]

View File

@@ -0,0 +1,188 @@
'use client';
import { useState, useEffect } from 'react';
import { FileText, Calendar, User, Target, Filter, Loader2, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { auditApi } from '@/lib/api/audit';
import type { GovernanceEvent, GovernanceEventType, AuditStats } from '@/lib/types/governance';
const EVENT_TYPE_LABELS: Partial<Record<GovernanceEventType, string>> = {
'agent.promoted': 'Агент підвищений',
'agent.demoted': 'Агент понижений',
'agent.revoked': 'Агент заблокований',
'agent.reinstated': 'Агент відновлений',
'incident.created': 'Інцидент створено',
'incident.resolved': 'Інцидент вирішено',
'microdao.created': 'MicroDAO створено',
'node.registered': 'Ноду зареєстровано',
'room.created': 'Кімнату створено',
};
const EVENT_TYPE_COLORS: Partial<Record<GovernanceEventType, string>> = {
'agent.promoted': 'text-green-400 bg-green-500/20',
'agent.demoted': 'text-orange-400 bg-orange-500/20',
'agent.revoked': 'text-red-400 bg-red-500/20',
'agent.reinstated': 'text-blue-400 bg-blue-500/20',
'incident.created': 'text-yellow-400 bg-yellow-500/20',
'incident.resolved': 'text-cyan-400 bg-cyan-500/20',
'microdao.created': 'text-purple-400 bg-purple-500/20',
'node.registered': 'text-pink-400 bg-pink-500/20',
'room.created': 'text-amber-400 bg-amber-500/20',
};
export function AuditDashboard() {
const [events, setEvents] = useState<GovernanceEvent[]>([]);
const [stats, setStats] = useState<AuditStats | null>(null);
const [loading, setLoading] = useState(true);
const [eventTypeFilter, setEventTypeFilter] = useState<string>('');
const loadData = async () => {
setLoading(true);
try {
const [eventsData, statsData] = await Promise.all([
auditApi.getAuditEvents({
limit: 50,
eventType: eventTypeFilter as GovernanceEventType || undefined,
}),
auditApi.getAuditStats(),
]);
setEvents(eventsData);
setStats(statsData);
} catch (error) {
console.error('Failed to load audit data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, [eventTypeFilter]);
const formatDate = (date: string) => {
return new Date(date).toLocaleString('uk-UA', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl bg-gradient-to-br from-cyan-500/20 to-blue-600/20">
<FileText className="w-8 h-8 text-cyan-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Audit Dashboard</h1>
<p className="text-slate-400">Журнал подій governance</p>
</div>
</div>
<button
onClick={loadData}
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
disabled={loading}
>
<RefreshCw className={cn("w-5 h-5 text-slate-400", loading && "animate-spin")} />
</button>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="glass-panel p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.totalEvents}</div>
<div className="text-xs text-slate-400">Всього подій</div>
</div>
<div className="glass-panel p-4 text-center">
<div className="text-2xl font-bold text-white">
{Object.keys(stats.eventsByType).length}
</div>
<div className="text-xs text-slate-400">Типів подій</div>
</div>
<div className="glass-panel p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.topActors.length}</div>
<div className="text-xs text-slate-400">Активних акторів</div>
</div>
<div className="glass-panel p-4 text-center">
<div className="text-2xl font-bold text-white">
{stats.eventsByDay.slice(-7).reduce((sum, d) => sum + d.count, 0)}
</div>
<div className="text-xs text-slate-400">За 7 днів</div>
</div>
</div>
)}
{/* Filter */}
<div className="flex items-center gap-3">
<Filter className="w-4 h-4 text-slate-400" />
<select
value={eventTypeFilter}
onChange={(e) => setEventTypeFilter(e.target.value)}
className="bg-slate-800/50 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
>
<option value="">Всі типи подій</option>
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => (
<option key={type} value={type}>{label}</option>
))}
</select>
</div>
{/* Events List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
</div>
) : events.length === 0 ? (
<div className="glass-panel p-12 text-center">
<FileText className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Немає подій</h3>
<p className="text-slate-400">Журнал подій порожній</p>
</div>
) : (
<div className="space-y-3">
{events.map((event) => (
<div key={event.id} className="glass-panel p-4">
<div className="flex items-start gap-4">
{/* Event Type Badge */}
<span className={cn(
'px-2 py-1 text-xs rounded-lg shrink-0',
EVENT_TYPE_COLORS[event.eventType] || 'text-slate-400 bg-slate-500/20'
)}>
{EVENT_TYPE_LABELS[event.eventType] || event.eventType}
</span>
{/* Event Details */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-4 text-sm text-slate-400 mb-1">
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
{event.actorId}
</span>
<span className="flex items-center gap-1">
<Target className="w-3 h-3" />
{event.targetId}
</span>
</div>
<div className="text-xs text-slate-500">
{event.scope} {event.subject}
</div>
</div>
{/* Timestamp */}
<div className="text-xs text-slate-500 flex items-center gap-1 shrink-0">
<Calendar className="w-3 h-3" />
{formatDate(event.createdAt)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import {
Landmark, Shield, Users, AlertTriangle, FileText,
ChevronRight, Loader2, RefreshCw
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { GovernanceLevelBadge } from './GovernanceLevelBadge';
import { governanceApi } from '@/lib/api/governance';
import { incidentsApi } from '@/lib/api/incidents';
import type { GovernanceAgent, Incident } from '@/lib/types/governance';
export function CityGovernancePanel() {
const [cityAgents, setCityAgents] = useState<GovernanceAgent[]>([]);
const [districtLeads, setDistrictLeads] = useState<GovernanceAgent[]>([]);
const [openIncidents, setOpenIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadData = async () => {
setLoading(true);
setError(null);
try {
const [agents, leads, incidents] = await Promise.all([
governanceApi.getCityAgents().catch(() => []),
governanceApi.getDistrictLeadAgents().catch(() => []),
incidentsApi.getIncidents({
escalationLevel: 'city',
status: 'open',
limit: 5
}).catch(() => []),
]);
setCityAgents(agents);
setDistrictLeads(leads);
setOpenIncidents(incidents);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-amber-400 animate-spin" />
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl bg-gradient-to-br from-red-500/20 to-orange-600/20">
<Landmark className="w-8 h-8 text-red-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">City Governance</h1>
<p className="text-slate-400">DAARION.city управління</p>
</div>
</div>
<button
onClick={loadData}
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
title="Оновити"
>
<RefreshCw className="w-5 h-5 text-slate-400" />
</button>
</div>
{error && (
<div className="p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-400">
{error}
</div>
)}
{/* City Agents Grid */}
<div>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-red-400" />
City Governance Agents
</h2>
{cityAgents.length === 0 ? (
<div className="glass-panel p-8 text-center">
<Shield className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<p className="text-slate-400">Немає city governance агентів</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{cityAgents.map((agent) => (
<Link
key={agent.id}
href={`/agents/${agent.id}`}
className="glass-panel p-4 hover:bg-white/5 transition-colors group"
>
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-red-500/30 to-orange-600/30 flex items-center justify-center text-white text-lg font-bold">
{agent.avatarUrl ? (
<img src={agent.avatarUrl} alt={agent.displayName} className="w-full h-full rounded-xl object-cover" />
) : (
agent.displayName.charAt(0).toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white truncate group-hover:text-amber-400 transition-colors">
{agent.displayName}
</h3>
<GovernanceLevelBadge level={agent.govLevel} status={agent.status} size="sm" />
</div>
</div>
</Link>
))}
</div>
)}
</div>
{/* District Leads */}
<div>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Users className="w-5 h-5 text-pink-400" />
District Lead Agents
</h2>
{districtLeads.length === 0 ? (
<div className="glass-panel p-6 text-center">
<Users className="w-10 h-10 text-slate-600 mx-auto mb-2" />
<p className="text-slate-400 text-sm">Немає district lead агентів</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{districtLeads.map((agent) => (
<Link
key={agent.id}
href={`/governance/district/${agent.homeMicrodaoId || agent.id}`}
className="glass-panel p-4 hover:bg-white/5 transition-colors group flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-pink-500/30 to-purple-600/30 flex items-center justify-center text-white font-bold">
{agent.displayName.charAt(0).toUpperCase()}
</div>
<div>
<h3 className="font-medium text-white group-hover:text-pink-400 transition-colors">
{agent.displayName}
</h3>
{agent.homeMicrodaoName && (
<p className="text-xs text-slate-400">{agent.homeMicrodaoName}</p>
)}
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-500 group-hover:text-pink-400 transition-colors" />
</Link>
))}
</div>
)}
</div>
{/* City-Level Incidents */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-400" />
City-Level Incidents
</h2>
<Link
href="/incidents"
className="text-sm text-amber-400 hover:text-amber-300 flex items-center gap-1"
>
Всі інциденти
<ChevronRight className="w-4 h-4" />
</Link>
</div>
{openIncidents.length === 0 ? (
<div className="glass-panel p-6 text-center">
<AlertTriangle className="w-10 h-10 text-slate-600 mx-auto mb-2" />
<p className="text-slate-400 text-sm">Немає відкритих інцидентів рівня City</p>
</div>
) : (
<div className="space-y-3">
{openIncidents.map((incident) => (
<Link
key={incident.id}
href={`/incidents?id=${incident.id}`}
className="glass-panel p-4 hover:bg-white/5 transition-colors block"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-white truncate">{incident.title}</h3>
<p className="text-sm text-slate-400 line-clamp-1">
{incident.description || 'Без опису'}
</p>
</div>
<span className={cn(
'px-2 py-0.5 text-xs rounded-full shrink-0',
incident.priority === 'critical' && 'bg-red-500/20 text-red-400',
incident.priority === 'high' && 'bg-orange-500/20 text-orange-400',
incident.priority === 'medium' && 'bg-yellow-500/20 text-yellow-400',
incident.priority === 'low' && 'bg-slate-500/20 text-slate-400'
)}>
{incident.priority}
</span>
</div>
</Link>
))}
</div>
)}
</div>
{/* Quick Links */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Link
href="/audit"
className="glass-panel p-5 hover:bg-white/5 transition-colors group"
>
<FileText className="w-8 h-8 text-cyan-400 mb-3 group-hover:scale-110 transition-transform" />
<h3 className="font-semibold text-white mb-1">Audit Dashboard</h3>
<p className="text-sm text-slate-400">Переглянути журнал подій governance</p>
</Link>
<Link
href="/incidents"
className="glass-panel p-5 hover:bg-white/5 transition-colors group"
>
<AlertTriangle className="w-8 h-8 text-amber-400 mb-3 group-hover:scale-110 transition-transform" />
<h3 className="font-semibold text-white mb-1">Incidents</h3>
<p className="text-sm text-slate-400">Управління інцидентами та скаргами</p>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { Shield, User, Users, Briefcase, Star, Crown, Building2, Landmark } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AgentGovLevel, AgentStatus } from '@/lib/types/governance';
import { GOV_LEVEL_LABELS, AGENT_STATUS_LABELS } from '@/lib/types/governance';
interface GovernanceLevelBadgeProps {
level: AgentGovLevel;
status?: AgentStatus;
showLabel?: boolean;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const LEVEL_CONFIG: Record<AgentGovLevel, {
icon: React.ComponentType<{ className?: string }>;
bg: string;
text: string;
border: string;
}> = {
guest: {
icon: User,
bg: 'bg-slate-500/20',
text: 'text-slate-400',
border: 'border-slate-500/30',
},
personal: {
icon: User,
bg: 'bg-blue-500/20',
text: 'text-blue-400',
border: 'border-blue-500/30',
},
member: {
icon: Users,
bg: 'bg-green-500/20',
text: 'text-green-400',
border: 'border-green-500/30',
},
worker: {
icon: Briefcase,
bg: 'bg-yellow-500/20',
text: 'text-yellow-400',
border: 'border-yellow-500/30',
},
core_team: {
icon: Star,
bg: 'bg-orange-500/20',
text: 'text-orange-400',
border: 'border-orange-500/30',
},
orchestrator: {
icon: Crown,
bg: 'bg-purple-500/20',
text: 'text-purple-400',
border: 'border-purple-500/30',
},
district_lead: {
icon: Building2,
bg: 'bg-pink-500/20',
text: 'text-pink-400',
border: 'border-pink-500/30',
},
city_governance: {
icon: Landmark,
bg: 'bg-red-500/20',
text: 'text-red-400',
border: 'border-red-500/30',
},
};
const SIZE_CLASSES = {
sm: {
container: 'px-2 py-0.5 text-xs gap-1',
icon: 'w-3 h-3',
},
md: {
container: 'px-3 py-1 text-sm gap-1.5',
icon: 'w-4 h-4',
},
lg: {
container: 'px-4 py-2 text-base gap-2',
icon: 'w-5 h-5',
},
};
export function GovernanceLevelBadge({
level,
status = 'active',
showLabel = true,
size = 'md',
className,
}: GovernanceLevelBadgeProps) {
const config = LEVEL_CONFIG[level];
const sizeClasses = SIZE_CLASSES[size];
const Icon = config.icon;
const isInactive = status !== 'active';
return (
<div className={cn('flex items-center gap-2', className)}>
{/* Level Badge */}
<span
className={cn(
'inline-flex items-center rounded-full border font-medium',
sizeClasses.container,
isInactive ? 'bg-slate-800/50 text-slate-500 border-slate-700' : config.bg,
isInactive ? '' : config.text,
isInactive ? '' : config.border
)}
>
<Icon className={cn(sizeClasses.icon, isInactive && 'opacity-50')} />
{showLabel && (
<span className={isInactive ? 'line-through' : ''}>
{GOV_LEVEL_LABELS[level]}
</span>
)}
</span>
{/* Status Badge (if not active) */}
{isInactive && (
<span
className={cn(
'inline-flex items-center rounded-full text-xs px-2 py-0.5',
status === 'suspended' && 'bg-yellow-500/20 text-yellow-400',
status === 'revoked' && 'bg-red-500/20 text-red-400'
)}
>
{AGENT_STATUS_LABELS[status]}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import { useState, useEffect } from 'react';
import {
AlertTriangle, Clock, CheckCircle2, XCircle,
Filter, Loader2, RefreshCw, ChevronDown, ChevronUp,
ArrowUpRight, User
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { incidentsApi, type IncidentsFilter } from '@/lib/api/incidents';
import type {
Incident, IncidentStatus, IncidentPriority, EscalationLevel
} from '@/lib/types/governance';
import {
INCIDENT_STATUS_LABELS,
INCIDENT_PRIORITY_LABELS,
ESCALATION_LABELS,
} from '@/lib/types/governance';
const STATUS_ICONS: Record<IncidentStatus, React.ComponentType<{ className?: string }>> = {
open: AlertTriangle,
in_progress: Clock,
resolved: CheckCircle2,
closed: XCircle,
};
const STATUS_COLORS: Record<IncidentStatus, string> = {
open: 'text-yellow-400 bg-yellow-500/20',
in_progress: 'text-blue-400 bg-blue-500/20',
resolved: 'text-green-400 bg-green-500/20',
closed: 'text-slate-400 bg-slate-500/20',
};
const PRIORITY_COLORS: Record<IncidentPriority, string> = {
low: 'text-slate-400 bg-slate-500/20',
medium: 'text-yellow-400 bg-yellow-500/20',
high: 'text-orange-400 bg-orange-500/20',
critical: 'text-red-400 bg-red-500/20',
};
export function IncidentsList() {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [filter, setFilter] = useState<IncidentsFilter>({
limit: 50,
});
const loadData = async () => {
setLoading(true);
try {
const data = await incidentsApi.getIncidents(filter);
setIncidents(data);
} catch (error) {
console.error('Failed to load incidents:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, [filter]);
const formatDate = (date: string) => {
return new Date(date).toLocaleString('uk-UA', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const handleEscalate = async (incident: Incident) => {
const nextLevel: EscalationLevel =
incident.escalationLevel === 'microdao' ? 'district' :
incident.escalationLevel === 'district' ? 'city' : 'city';
try {
await incidentsApi.escalateIncident(
incident.id,
nextLevel,
'dais-demo-user', // TODO: real auth
'Ескалація з UI'
);
loadData();
} catch (error) {
console.error('Failed to escalate:', error);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/20">
<AlertTriangle className="w-8 h-8 text-amber-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Incidents</h1>
<p className="text-slate-400">Управління інцидентами</p>
</div>
</div>
<button
onClick={loadData}
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
disabled={loading}
>
<RefreshCw className={cn("w-5 h-5 text-slate-400", loading && "animate-spin")} />
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-slate-400" />
<select
value={filter.status || ''}
onChange={(e) => setFilter(prev => ({
...prev,
status: e.target.value as IncidentStatus || undefined
}))}
className="bg-slate-800/50 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:border-amber-500 focus:outline-none"
>
<option value="">Всі статуси</option>
{Object.entries(INCIDENT_STATUS_LABELS).map(([status, label]) => (
<option key={status} value={status}>{label}</option>
))}
</select>
</div>
<select
value={filter.priority || ''}
onChange={(e) => setFilter(prev => ({
...prev,
priority: e.target.value as IncidentPriority || undefined
}))}
className="bg-slate-800/50 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:border-amber-500 focus:outline-none"
>
<option value="">Всі пріоритети</option>
{Object.entries(INCIDENT_PRIORITY_LABELS).map(([priority, label]) => (
<option key={priority} value={priority}>{label}</option>
))}
</select>
<select
value={filter.escalationLevel || ''}
onChange={(e) => setFilter(prev => ({
...prev,
escalationLevel: e.target.value as EscalationLevel || undefined
}))}
className="bg-slate-800/50 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:border-amber-500 focus:outline-none"
>
<option value="">Всі рівні</option>
{Object.entries(ESCALATION_LABELS).map(([level, label]) => (
<option key={level} value={level}>{label}</option>
))}
</select>
</div>
{/* Incidents List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-amber-400 animate-spin" />
</div>
) : incidents.length === 0 ? (
<div className="glass-panel p-12 text-center">
<AlertTriangle className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Немає інцидентів</h3>
<p className="text-slate-400">Список інцидентів порожній</p>
</div>
) : (
<div className="space-y-3">
{incidents.map((incident) => {
const StatusIcon = STATUS_ICONS[incident.status];
const isExpanded = expandedId === incident.id;
return (
<div key={incident.id} className="glass-panel overflow-hidden">
{/* Main Row */}
<div
className="p-4 cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => setExpandedId(isExpanded ? null : incident.id)}
>
<div className="flex items-start gap-4">
{/* Status Icon */}
<div className={cn('p-2 rounded-lg', STATUS_COLORS[incident.status])}>
<StatusIcon className="w-5 h-5" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-semibold text-white truncate">{incident.title}</h3>
<span className={cn(
'px-2 py-0.5 text-xs rounded-full shrink-0',
PRIORITY_COLORS[incident.priority]
)}>
{INCIDENT_PRIORITY_LABELS[incident.priority]}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-slate-400">
<span>{incident.targetScopeType}: {incident.targetScopeId}</span>
<span className="flex items-center gap-1">
<ArrowUpRight className="w-3 h-3" />
{ESCALATION_LABELS[incident.escalationLevel]}
</span>
</div>
</div>
{/* Expand */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-slate-500">{formatDate(incident.createdAt)}</span>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-slate-400" />
) : (
<ChevronDown className="w-5 h-5 text-slate-400" />
)}
</div>
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="px-4 pb-4 pt-2 border-t border-white/5">
{incident.description && (
<p className="text-sm text-slate-300 mb-4">{incident.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-slate-400 mb-4">
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
Створив: {incident.createdByDaisId}
</span>
{incident.assignedToDaisId && (
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
Призначено: {incident.assignedToDaisId}
</span>
)}
</div>
{incident.resolution && (
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg mb-4">
<p className="text-sm text-green-400">
<strong>Рішення:</strong> {incident.resolution}
</p>
</div>
)}
{/* Actions */}
{incident.status === 'open' && incident.escalationLevel !== 'city' && (
<div className="flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
handleEscalate(incident);
}}
className="px-4 py-2 bg-amber-500/20 text-amber-400 rounded-lg hover:bg-amber-500/30 transition-colors text-sm"
>
Ескалювати до {
incident.escalationLevel === 'microdao' ? 'District' : 'City'
}
</button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,226 @@
'use client';
import { useState } from 'react';
import { AlertTriangle, X, Send, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { incidentsApi } from '@/lib/api/incidents';
import type { TargetScopeType, IncidentPriority } from '@/lib/types/governance';
import { INCIDENT_PRIORITY_LABELS } from '@/lib/types/governance';
interface ReportButtonProps {
targetScopeType: TargetScopeType;
targetScopeId: string;
actorDaisId: string;
variant?: 'icon' | 'button' | 'text';
className?: string;
}
export function ReportButton({
targetScopeType,
targetScopeId,
actorDaisId,
variant = 'button',
className,
}: ReportButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<IncidentPriority>('medium');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
setError('Введіть заголовок');
return;
}
setIsSubmitting(true);
setError(null);
try {
await incidentsApi.createIncident({
createdByDaisId: actorDaisId,
targetScopeType,
targetScopeId,
priority,
title: title.trim(),
description: description.trim() || undefined,
});
setSuccess(true);
setTimeout(() => {
setIsOpen(false);
setTitle('');
setDescription('');
setPriority('medium');
setSuccess(false);
}, 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Помилка створення інциденту');
} finally {
setIsSubmitting(false);
}
};
const buttonContent = {
icon: <AlertTriangle className="w-4 h-4" />,
button: (
<>
<AlertTriangle className="w-4 h-4" />
<span>Поскаржитись</span>
</>
),
text: <span className="text-red-400 hover:text-red-300">Поскаржитись</span>,
};
const buttonClasses = {
icon: 'p-2 rounded-lg hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-colors',
button: 'flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors text-sm',
text: 'text-sm hover:underline',
};
return (
<>
<button
onClick={() => setIsOpen(true)}
className={cn(buttonClasses[variant], className)}
title="Створити скаргу"
>
{buttonContent[variant]}
</button>
{/* Modal */}
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md glass-panel p-6 rounded-2xl">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-red-500/20">
<AlertTriangle className="w-5 h-5 text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Створити інцидент</h3>
<p className="text-xs text-slate-400">
{targetScopeType}: {targetScopeId}
</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="p-1 text-slate-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{success ? (
<div className="text-center py-8">
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
<Send className="w-8 h-8 text-green-400" />
</div>
<h4 className="text-lg font-medium text-white mb-2">Інцидент створено!</h4>
<p className="text-sm text-slate-400">Ваша скарга буде розглянута модераторами</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm text-slate-400 mb-1">Заголовок *</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Коротко опишіть проблему"
className="w-full px-4 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder:text-slate-500 focus:border-red-500 focus:outline-none"
maxLength={100}
/>
</div>
{/* Priority */}
<div>
<label className="block text-sm text-slate-400 mb-1">Пріоритет</label>
<div className="flex gap-2">
{(['low', 'medium', 'high', 'critical'] as IncidentPriority[]).map((p) => (
<button
key={p}
type="button"
onClick={() => setPriority(p)}
className={cn(
'flex-1 py-2 text-xs rounded-lg border transition-colors',
priority === p
? p === 'critical'
? 'bg-red-500/30 border-red-500 text-red-400'
: p === 'high'
? 'bg-orange-500/30 border-orange-500 text-orange-400'
: p === 'medium'
? 'bg-yellow-500/30 border-yellow-500 text-yellow-400'
: 'bg-slate-500/30 border-slate-500 text-slate-400'
: 'bg-slate-800/50 border-slate-700 text-slate-500 hover:border-slate-600'
)}
>
{INCIDENT_PRIORITY_LABELS[p]}
</button>
))}
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm text-slate-400 mb-1">Опис (опціонально)</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Детальний опис ситуації..."
rows={3}
className="w-full px-4 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder:text-slate-500 focus:border-red-500 focus:outline-none resize-none"
/>
</div>
{/* Error */}
{error && (
<div className="p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setIsOpen(false)}
className="flex-1 py-2 rounded-lg border border-slate-700 text-slate-400 hover:bg-slate-800 transition-colors"
>
Скасувати
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Відправка...
</>
) : (
<>
<Send className="w-4 h-4" />
Відправити
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,11 @@
/**
* Governance Components
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
*/
export { GovernanceLevelBadge } from './GovernanceLevelBadge';
export { ReportButton } from './ReportButton';
export { CityGovernancePanel } from './CityGovernancePanel';
export { AuditDashboard } from './AuditDashboard';
export { IncidentsList } from './IncidentsList';

View File

@@ -0,0 +1,98 @@
/**
* Audit API Client for DAARION.city
* Based on: backend/http/audit.routes.ts
*/
import type {
GovernanceEvent,
AuditEventFilter,
AuditStats,
GovernanceScope,
} from '../types/governance';
// API base URL
const getApiBase = () => {
if (typeof window === 'undefined') {
return process.env.INTERNAL_API_URL || 'http://daarion-city-service:7001';
}
return '';
};
const API_BASE = getApiBase();
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${API_BASE}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
// ============================================================================
// AUDIT EVENTS
// ============================================================================
export async function getAuditEvents(filter?: AuditEventFilter): Promise<GovernanceEvent[]> {
const params = new URLSearchParams();
if (filter?.eventType) params.set('eventType', filter.eventType);
if (filter?.actorId) params.set('actorId', filter.actorId);
if (filter?.targetId) params.set('targetId', filter.targetId);
if (filter?.scope) params.set('scope', filter.scope);
if (filter?.createdAtFrom) params.set('createdAtFrom', filter.createdAtFrom);
if (filter?.createdAtTo) params.set('createdAtTo', filter.createdAtTo);
if (filter?.limit) params.set('limit', filter.limit.toString());
if (filter?.offset) params.set('offset', filter.offset.toString());
const queryString = params.toString();
const endpoint = queryString ? `/api/v1/audit/events?${queryString}` : '/api/v1/audit/events';
return fetchApi(endpoint);
}
export async function getAuditEventById(id: string): Promise<GovernanceEvent> {
return fetchApi(`/api/v1/audit/events/${id}`);
}
export async function getAuditEventsByActor(actorId: string, limit = 50): Promise<GovernanceEvent[]> {
return fetchApi(`/api/v1/audit/actor/${actorId}?limit=${limit}`);
}
export async function getAuditEventsByTarget(targetId: string, limit = 50): Promise<GovernanceEvent[]> {
return fetchApi(`/api/v1/audit/target/${targetId}?limit=${limit}`);
}
export async function getAuditStats(scope?: GovernanceScope): Promise<AuditStats> {
const endpoint = scope ? `/api/v1/audit/stats?scope=${scope}` : '/api/v1/audit/stats';
return fetchApi(endpoint);
}
export async function getEntityHistory(
entityType: string,
entityId: string,
limit = 50
): Promise<GovernanceEvent[]> {
return fetchApi(`/api/v1/audit/entity/${entityType}/${entityId}?limit=${limit}`);
}
// Export as namespace
export const auditApi = {
getAuditEvents,
getAuditEventById,
getAuditEventsByActor,
getAuditEventsByTarget,
getAuditStats,
getEntityHistory,
};

View File

@@ -0,0 +1,177 @@
/**
* Governance API Client for DAARION.city
* Based on: backend/http/governance.routes.ts
*/
import type {
AgentGovLevel,
GovernanceScope,
AgentRolesResponse,
GovernanceAgent,
GovernancePower,
RevocationType,
} from '../types/governance';
// API base URL for governance endpoints
const getApiBase = () => {
if (typeof window === 'undefined') {
return process.env.INTERNAL_API_URL || 'http://daarion-city-service:7001';
}
return '';
};
const API_BASE = getApiBase();
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${API_BASE}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
// ============================================================================
// AGENT GOVERNANCE
// ============================================================================
export async function promoteAgent(
actorId: string,
targetId: string,
newLevel: AgentGovLevel,
scope: GovernanceScope,
reason?: string
): Promise<{ success: boolean; message: string }> {
return fetchApi('/api/v1/governance/agent/promote', {
method: 'POST',
body: JSON.stringify({ actorId, targetId, newLevel, scope, reason }),
});
}
export async function demoteAgent(
actorId: string,
targetId: string,
newLevel: AgentGovLevel,
scope: GovernanceScope,
reason?: string
): Promise<{ success: boolean; message: string }> {
return fetchApi('/api/v1/governance/agent/demote', {
method: 'POST',
body: JSON.stringify({ actorId, targetId, newLevel, scope, reason }),
});
}
export async function revokeAgent(
actorId: string,
targetId: string,
reason: string,
scope: GovernanceScope,
revocationType: RevocationType = 'soft'
): Promise<{ success: boolean; message: string }> {
return fetchApi('/api/v1/governance/agent/revoke', {
method: 'POST',
body: JSON.stringify({ actorId, targetId, reason, scope, revocationType }),
});
}
export async function suspendAgent(
actorId: string,
targetId: string,
reason: string,
scope: GovernanceScope
): Promise<{ success: boolean; message: string }> {
return fetchApi('/api/v1/governance/agent/suspend', {
method: 'POST',
body: JSON.stringify({ actorId, targetId, reason, scope }),
});
}
export async function reinstateAgent(
actorId: string,
targetId: string,
scope: GovernanceScope
): Promise<{ success: boolean; message: string }> {
return fetchApi('/api/v1/governance/agent/reinstate', {
method: 'POST',
body: JSON.stringify({ actorId, targetId, scope }),
});
}
export async function getAgentRoles(agentId: string): Promise<AgentRolesResponse> {
return fetchApi(`/api/v1/governance/agent/${agentId}/roles`);
}
export async function getAgentPermissions(agentId: string): Promise<GovernancePower[]> {
return fetchApi(`/api/v1/governance/agent/${agentId}/permissions`);
}
export async function checkPermission(
actorId: string,
action: string,
targetType: string,
targetId: string
): Promise<{ allowed: boolean; reason?: string }> {
return fetchApi('/api/v1/governance/check', {
method: 'POST',
body: JSON.stringify({ actorId, action, targetType, targetId }),
});
}
// ============================================================================
// CITY GOVERNANCE AGENTS
// ============================================================================
export async function getCityAgents(): Promise<GovernanceAgent[]> {
return fetchApi('/api/v1/governance/agents/city');
}
export async function getDistrictLeadAgents(): Promise<GovernanceAgent[]> {
return fetchApi('/api/v1/governance/agents/district-leads');
}
export async function getAgentsByLevel(level: AgentGovLevel): Promise<GovernanceAgent[]> {
return fetchApi(`/api/v1/governance/agents/by-level/${level}`);
}
// ============================================================================
// DAIS KEYS
// ============================================================================
export async function revokeDaisKey(
daisId: string,
keyId: string,
reason: string,
revokedBy: string
): Promise<{ success: boolean }> {
return fetchApi('/api/v1/governance/dais/keys/revoke', {
method: 'POST',
body: JSON.stringify({ daisId, keyId, reason, revokedBy }),
});
}
// Export all functions as a namespace
export const governanceApi = {
promoteAgent,
demoteAgent,
revokeAgent,
suspendAgent,
reinstateAgent,
getAgentRoles,
getAgentPermissions,
checkPermission,
getCityAgents,
getDistrictLeadAgents,
getAgentsByLevel,
revokeDaisKey,
};

View File

@@ -0,0 +1,161 @@
/**
* Incidents API Client for DAARION.city
* Based on: backend/http/incidents.routes.ts
*/
import type {
Incident,
IncidentHistory,
IncidentStatus,
IncidentPriority,
EscalationLevel,
TargetScopeType,
CreateIncidentRequest,
} from '../types/governance';
// API base URL
const getApiBase = () => {
if (typeof window === 'undefined') {
return process.env.INTERNAL_API_URL || 'http://daarion-city-service:7001';
}
return '';
};
const API_BASE = getApiBase();
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${API_BASE}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
// ============================================================================
// INCIDENTS CRUD
// ============================================================================
export async function createIncident(data: CreateIncidentRequest): Promise<Incident> {
return fetchApi('/api/v1/incidents', {
method: 'POST',
body: JSON.stringify(data),
});
}
export interface IncidentsFilter {
status?: IncidentStatus;
priority?: IncidentPriority;
escalationLevel?: EscalationLevel;
targetScopeType?: TargetScopeType;
targetScopeId?: string;
assignedTo?: string;
limit?: number;
offset?: number;
}
export async function getIncidents(filter?: IncidentsFilter): Promise<Incident[]> {
const params = new URLSearchParams();
if (filter?.status) params.set('status', filter.status);
if (filter?.priority) params.set('priority', filter.priority);
if (filter?.escalationLevel) params.set('escalationLevel', filter.escalationLevel);
if (filter?.targetScopeType) params.set('targetScopeType', filter.targetScopeType);
if (filter?.targetScopeId) params.set('targetScopeId', filter.targetScopeId);
if (filter?.assignedTo) params.set('assignedTo', filter.assignedTo);
if (filter?.limit) params.set('limit', filter.limit.toString());
if (filter?.offset) params.set('offset', filter.offset.toString());
const queryString = params.toString();
const endpoint = queryString ? `/api/v1/incidents?${queryString}` : '/api/v1/incidents';
return fetchApi(endpoint);
}
export async function getIncidentById(id: string): Promise<Incident> {
return fetchApi(`/api/v1/incidents/${id}`);
}
export async function getIncidentHistory(id: string): Promise<IncidentHistory[]> {
return fetchApi(`/api/v1/incidents/${id}/history`);
}
// ============================================================================
// INCIDENT ACTIONS
// ============================================================================
export async function assignIncident(
id: string,
assignedToDaisId: string,
actorDaisId: string
): Promise<Incident> {
return fetchApi(`/api/v1/incidents/${id}/assign`, {
method: 'POST',
body: JSON.stringify({ assignedToDaisId, actorDaisId }),
});
}
export async function escalateIncident(
id: string,
newLevel: EscalationLevel,
actorDaisId: string,
reason?: string
): Promise<Incident> {
return fetchApi(`/api/v1/incidents/${id}/escalate`, {
method: 'POST',
body: JSON.stringify({ newLevel, actorDaisId, reason }),
});
}
export async function resolveIncident(
id: string,
resolution: string,
actorDaisId: string
): Promise<Incident> {
return fetchApi(`/api/v1/incidents/${id}/resolve`, {
method: 'POST',
body: JSON.stringify({ resolution, actorDaisId }),
});
}
export async function closeIncident(id: string, actorDaisId: string): Promise<Incident> {
return fetchApi(`/api/v1/incidents/${id}/close`, {
method: 'POST',
body: JSON.stringify({ actorDaisId }),
});
}
export async function addIncidentComment(
id: string,
comment: string,
actorDaisId: string
): Promise<IncidentHistory> {
return fetchApi(`/api/v1/incidents/${id}/comment`, {
method: 'POST',
body: JSON.stringify({ comment, actorDaisId }),
});
}
// Export as namespace
export const incidentsApi = {
createIncident,
getIncidents,
getIncidentById,
getIncidentHistory,
assignIncident,
escalateIncident,
resolveIncident,
closeIncident,
addIncidentComment,
};

View File

@@ -0,0 +1,302 @@
/**
* Governance Types for DAARION.city
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
*/
// ============================================================================
// GOVERNANCE LEVELS (0-7)
// ============================================================================
export const AGENT_LEVELS = {
GUEST: 0,
PERSONAL: 1,
MEMBER: 2,
WORKER: 3,
CORE_TEAM: 4,
ORCHESTRATOR: 5,
DISTRICT_LEAD: 6,
CITY_GOVERNANCE: 7,
} as const;
export type AgentLevelNum = typeof AGENT_LEVELS[keyof typeof AGENT_LEVELS];
export type AgentGovLevel =
| 'guest'
| 'personal'
| 'member'
| 'worker'
| 'core_team'
| 'orchestrator'
| 'district_lead'
| 'city_governance';
export const GOV_LEVEL_LABELS: Record<AgentGovLevel, string> = {
guest: 'Guest',
personal: 'Personal',
member: 'Member',
worker: 'Worker',
core_team: 'Core Team',
orchestrator: 'Orchestrator',
district_lead: 'District Lead',
city_governance: 'City Governance',
};
export const GOV_LEVEL_COLORS: Record<AgentGovLevel, string> = {
guest: 'slate',
personal: 'blue',
member: 'green',
worker: 'yellow',
core_team: 'orange',
orchestrator: 'purple',
district_lead: 'pink',
city_governance: 'red',
};
// ============================================================================
// AGENT STATUS
// ============================================================================
export type AgentStatus = 'active' | 'suspended' | 'revoked';
export const AGENT_STATUS_LABELS: Record<AgentStatus, string> = {
active: 'Активний',
suspended: 'Призупинено',
revoked: 'Заблоковано',
};
// ============================================================================
// GOVERNANCE POWERS
// ============================================================================
export type GovernancePower =
| 'administrative'
| 'moderation'
| 'execution'
| 'infrastructure'
| 'identity'
| 'protocol'
| 'district';
export const POWER_LABELS: Record<GovernancePower, string> = {
administrative: 'Адміністрування',
moderation: 'Модерація',
execution: 'Виконання',
infrastructure: 'Інфраструктура',
identity: 'Ідентичність',
protocol: 'Протокол',
district: 'Район',
};
// ============================================================================
// GOVERNANCE SCOPE
// ============================================================================
export type GovernanceScope = 'city' | `district:${string}` | `microdao:${string}` | `node:${string}`;
// ============================================================================
// REVOCATION
// ============================================================================
export type RevocationType = 'soft' | 'hard' | 'shadow';
export const REVOCATION_LABELS: Record<RevocationType, string> = {
soft: 'Тимчасове',
hard: 'Постійне',
shadow: 'Тіньове',
};
export interface AgentRevocation {
id: string;
agentId: string;
daisId?: string;
revokedBy: string;
revocationType: RevocationType;
reason: string;
scope: GovernanceScope;
keysInvalidated: boolean;
walletDisabled: boolean;
roomAccessRevoked: boolean;
nodePrivilegesRemoved: boolean;
assignmentsTerminated: boolean;
reversible: boolean;
reversedAt?: string;
reversedBy?: string;
createdAt: string;
}
// ============================================================================
// INCIDENTS
// ============================================================================
export type IncidentStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
export type IncidentPriority = 'low' | 'medium' | 'high' | 'critical';
export type EscalationLevel = 'microdao' | 'district' | 'city';
export type TargetScopeType = 'city' | 'district' | 'microdao' | 'room' | 'node' | 'agent';
export const INCIDENT_STATUS_LABELS: Record<IncidentStatus, string> = {
open: 'Відкрито',
in_progress: 'В роботі',
resolved: 'Вирішено',
closed: 'Закрито',
};
export const INCIDENT_PRIORITY_LABELS: Record<IncidentPriority, string> = {
low: 'Низький',
medium: 'Середній',
high: 'Високий',
critical: 'Критичний',
};
export const INCIDENT_PRIORITY_COLORS: Record<IncidentPriority, string> = {
low: 'slate',
medium: 'yellow',
high: 'orange',
critical: 'red',
};
export const ESCALATION_LABELS: Record<EscalationLevel, string> = {
microdao: 'MicroDAO',
district: 'District',
city: 'City',
};
export interface Incident {
id: string;
createdByDaisId: string;
targetScopeType: TargetScopeType;
targetScopeId: string;
status: IncidentStatus;
priority: IncidentPriority;
assignedToDaisId?: string;
escalationLevel: EscalationLevel;
title: string;
description?: string;
resolution?: string;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
resolvedAt?: string;
closedAt?: string;
}
export interface IncidentHistory {
id: string;
incidentId: string;
action: 'created' | 'assigned' | 'escalated' | 'resolved' | 'closed' | 'comment';
actorDaisId: string;
oldValue?: Record<string, unknown>;
newValue?: Record<string, unknown>;
comment?: string;
createdAt: string;
}
// ============================================================================
// AUDIT EVENTS
// ============================================================================
export type GovernanceEventType =
| 'agent.promoted'
| 'agent.demoted'
| 'agent.revoked'
| 'agent.reinstated'
| 'agent.assigned'
| 'agent.unassigned'
| 'permission.granted'
| 'permission.revoked'
| 'incident.created'
| 'incident.assigned'
| 'incident.escalated'
| 'incident.resolved'
| 'incident.closed'
| 'microdao.created'
| 'district.created'
| 'node.registered'
| 'room.created'
| 'room.published_to_city';
export interface GovernanceEvent {
id: string;
eventType: GovernanceEventType;
subject: string;
actorId: string;
targetId: string;
scope: GovernanceScope;
payload: Record<string, unknown>;
version: string;
status: 'pending' | 'published' | 'failed';
createdAt: string;
publishedAt?: string;
}
// ============================================================================
// API REQUESTS/RESPONSES
// ============================================================================
export interface AgentRolesResponse {
level: AgentGovLevel;
status: AgentStatus;
powers: GovernancePower[];
assignments: Array<{
microdaoId: string;
role: string;
scope: string;
}>;
}
export interface PromoteAgentRequest {
actorId: string;
targetId: string;
newLevel: AgentGovLevel;
scope: GovernanceScope;
reason?: string;
}
export interface RevokeAgentRequest {
actorId: string;
targetId: string;
reason: string;
scope: GovernanceScope;
revocationType?: RevocationType;
}
export interface CreateIncidentRequest {
createdByDaisId: string;
targetScopeType: TargetScopeType;
targetScopeId: string;
priority?: IncidentPriority;
title: string;
description?: string;
}
export interface AuditEventFilter {
eventType?: GovernanceEventType;
actorId?: string;
targetId?: string;
scope?: GovernanceScope;
createdAtFrom?: string;
createdAtTo?: string;
limit?: number;
offset?: number;
}
export interface AuditStats {
totalEvents: number;
eventsByType: Record<string, number>;
eventsByDay: Array<{ date: string; count: number }>;
topActors: Array<{ actorId: string; count: number }>;
}
// ============================================================================
// GOVERNANCE AGENT (для City Governance Panel)
// ============================================================================
export interface GovernanceAgent {
id: string;
displayName: string;
avatarUrl?: string;
govLevel: AgentGovLevel;
status: AgentStatus;
homeMicrodaoId?: string;
homeMicrodaoName?: string;
}

View File

@@ -14,3 +14,6 @@ export * from './microdao';
// Node types
export * from './nodes';
// Governance types
export * from './governance';