Merge M3: seat config + BYOK
Encrypted owner-only API configs (AES-256-GCM, key never returned), model adapters with a connection test, the Agent bound to a seat (skills, autonomy dial, model config, docs) that flips a seat to AI, and the seat-configurator UI. Verified: build green, ArchitectureTests 8/8, IntegrationTests 27/27, client build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,8 @@ dotnet_diagnostic.CA2007.severity = none
|
|||||||
# CA1848 / CA1873: LoggerMessage-delegate perf rules — opt-in perf, not worth enforcing in V1.
|
# CA1848 / CA1873: LoggerMessage-delegate perf rules — opt-in perf, not worth enforcing in V1.
|
||||||
dotnet_diagnostic.CA1848.severity = none
|
dotnet_diagnostic.CA1848.severity = none
|
||||||
dotnet_diagnostic.CA1873.severity = none
|
dotnet_diagnostic.CA1873.severity = none
|
||||||
|
# CA1031: a model/test boundary intentionally catches broadly to report any failure as a result.
|
||||||
|
dotnet_diagnostic.CA1031.severity = none
|
||||||
|
|
||||||
# EF Core migrations are tool-generated — don't style-police them.
|
# EF Core migrations are tool-generated — don't style-police them.
|
||||||
[**/Migrations/*.cs]
|
[**/Migrations/*.cs]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router'
|
|||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { BoardPage } from '@/pages/BoardPage'
|
import { BoardPage } from '@/pages/BoardPage'
|
||||||
import { LoginPage } from '@/pages/LoginPage'
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
|
import { SeatsPage } from '@/pages/SeatsPage'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -12,6 +13,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} />
|
<Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||||
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
|
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
|
||||||
|
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Inbox, type LucideIcon, LayoutDashboard, LogOut, Network } from 'lucide-react'
|
import { Link, useLocation } from 'react-router'
|
||||||
|
import { Bot, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -25,8 +26,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<Separator className="bg-sidebar-border" />
|
<Separator className="bg-sidebar-border" />
|
||||||
|
|
||||||
<nav className="flex flex-1 flex-col gap-1 p-3">
|
<nav className="flex flex-1 flex-col gap-1 p-3">
|
||||||
<NavItem icon={LayoutDashboard} label="Board" active />
|
<NavItem icon={LayoutDashboard} label="Board" to="/" />
|
||||||
<NavItem icon={Inbox} label="Cartable" />
|
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
||||||
|
<NavItem icon={Inbox} label="Cartable" muted />
|
||||||
<NavItem icon={Network} label="Org chart" muted />
|
<NavItem icon={Network} label="Org chart" muted />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -54,25 +56,38 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
function NavItem({
|
function NavItem({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
active,
|
to,
|
||||||
muted,
|
muted,
|
||||||
}: {
|
}: {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
label: string
|
label: string
|
||||||
active?: boolean
|
to?: string
|
||||||
muted?: boolean
|
muted?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
const location = useLocation()
|
||||||
<span
|
const active = to ? location.pathname === to : false
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm',
|
const className = cn(
|
||||||
active ? 'bg-sidebar-accent font-medium text-sidebar-accent-foreground' : 'text-sidebar-foreground/80',
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm',
|
||||||
muted && 'opacity-50',
|
active ? 'bg-sidebar-accent font-medium text-sidebar-accent-foreground' : 'text-sidebar-foreground/80',
|
||||||
)}
|
muted ? 'opacity-50' : 'hover:bg-sidebar-accent/60',
|
||||||
>
|
)
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
{label}
|
{label}
|
||||||
{muted && <span className="ml-auto text-[10px] uppercase tracking-wide opacity-70">soon</span>}
|
{muted && <span className="ml-auto text-[10px] uppercase tracking-wide opacity-70">soon</span>}
|
||||||
</span>
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!to || muted) {
|
||||||
|
return <span className={className}>{content}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={to} className={className}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { KeyRound, Plus, Bot, Wand2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { AppShell } from '@/components/AppShell'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
|
interface Team {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Seat {
|
||||||
|
id: string
|
||||||
|
teamId: string
|
||||||
|
roleName: string
|
||||||
|
state: string
|
||||||
|
agentId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
skillKey: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
monogram?: string | null
|
||||||
|
autonomy: string
|
||||||
|
apiConfigId: string
|
||||||
|
skillKeys: string[]
|
||||||
|
docs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTONOMY = [
|
||||||
|
{ value: 'DraftOnly', label: 'Draft', on: 'bg-slate-600 text-white' },
|
||||||
|
{ value: 'Gated', label: 'Gated', on: 'bg-indigo-600 text-white' },
|
||||||
|
{ value: 'Autonomous', label: 'Auto', on: 'bg-teal-600 text-white' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function SeatsPage() {
|
||||||
|
const organizationId = useAuth((s) => s.organizationId)
|
||||||
|
|
||||||
|
const [teams, setTeams] = useState<Team[]>([])
|
||||||
|
const [teamId, setTeamId] = useState<string | null>(null)
|
||||||
|
const [configs, setConfigs] = useState<ApiConfig[]>([])
|
||||||
|
const [seats, setSeats] = useState<Seat[]>([])
|
||||||
|
const [skills, setSkills] = useState<Skill[]>([])
|
||||||
|
|
||||||
|
const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' })
|
||||||
|
const [newSeat, setNewSeat] = useState('')
|
||||||
|
const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
|
||||||
|
const [agent, setAgent] = useState({
|
||||||
|
name: '',
|
||||||
|
monogram: '',
|
||||||
|
autonomy: 'Gated',
|
||||||
|
apiConfigId: '',
|
||||||
|
skillKeys: [] as string[],
|
||||||
|
docs: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = useCallback(async (action: () => Promise<unknown>) => {
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadConfigs = useCallback(async () => {
|
||||||
|
if (!organizationId) return
|
||||||
|
setConfigs(await api.get<ApiConfig[]>(`/api/integrations/api-configs?organizationId=${organizationId}`))
|
||||||
|
}, [organizationId])
|
||||||
|
|
||||||
|
const loadSeats = useCallback(async (id: string) => {
|
||||||
|
setSeats(await api.get<Seat[]>(`/api/orgboard/seats?teamId=${id}`))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!organizationId) return
|
||||||
|
void run(async () => {
|
||||||
|
setTeams(await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`))
|
||||||
|
setSkills(await api.get<Skill[]>('/api/skills/'))
|
||||||
|
await loadConfigs()
|
||||||
|
})
|
||||||
|
}, [organizationId, loadConfigs, run])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (teamId) void run(() => loadSeats(teamId))
|
||||||
|
}, [teamId, loadSeats, run])
|
||||||
|
|
||||||
|
const createConfig = () =>
|
||||||
|
run(async () => {
|
||||||
|
await api.post('/api/integrations/api-configs', { organizationId, ...cfg })
|
||||||
|
setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' })
|
||||||
|
await loadConfigs()
|
||||||
|
toast.success('API config saved (key encrypted).')
|
||||||
|
})
|
||||||
|
|
||||||
|
const testConfig = (id: string) =>
|
||||||
|
run(async () => {
|
||||||
|
const result = await api.post<{ success: boolean; error?: string; latencyMs: number }>(
|
||||||
|
`/api/integrations/api-configs/${id}/test`,
|
||||||
|
)
|
||||||
|
result.success
|
||||||
|
? toast.success(`Test call succeeded (${result.latencyMs} ms).`)
|
||||||
|
: toast.error(`Test failed: ${result.error}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSeat = () =>
|
||||||
|
run(async () => {
|
||||||
|
if (!teamId) return
|
||||||
|
await api.post('/api/orgboard/seats', { teamId, roleName: newSeat })
|
||||||
|
setNewSeat('')
|
||||||
|
await loadSeats(teamId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectSeat = (seat: Seat) =>
|
||||||
|
run(async () => {
|
||||||
|
setSelectedSeat(seat.id)
|
||||||
|
const existing = seat.agentId
|
||||||
|
? await api.get<Agent>(`/api/orgboard/seats/${seat.id}/agent`).catch(() => null)
|
||||||
|
: null
|
||||||
|
setAgent(
|
||||||
|
existing
|
||||||
|
? {
|
||||||
|
name: existing.name,
|
||||||
|
monogram: existing.monogram ?? '',
|
||||||
|
autonomy: existing.autonomy,
|
||||||
|
apiConfigId: existing.apiConfigId,
|
||||||
|
skillKeys: existing.skillKeys,
|
||||||
|
docs: existing.docs.join(', '),
|
||||||
|
}
|
||||||
|
: { name: '', monogram: '', autonomy: 'Gated', apiConfigId: configs[0]?.id ?? '', skillKeys: [], docs: '' },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveAgent = () =>
|
||||||
|
run(async () => {
|
||||||
|
if (!selectedSeat) return
|
||||||
|
await api.post(`/api/orgboard/seats/${selectedSeat}/agent`, {
|
||||||
|
name: agent.name,
|
||||||
|
monogram: agent.monogram || null,
|
||||||
|
autonomy: agent.autonomy,
|
||||||
|
apiConfigId: agent.apiConfigId,
|
||||||
|
skillKeys: agent.skillKeys,
|
||||||
|
docs: agent.docs ? agent.docs.split(',').map((d) => d.trim()).filter(Boolean) : [],
|
||||||
|
})
|
||||||
|
if (teamId) await loadSeats(teamId)
|
||||||
|
toast.success(`${agent.name || 'Agent'} configured — seat is now AI.`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleSkill = (key: string) =>
|
||||||
|
setAgent((a) => ({
|
||||||
|
...a,
|
||||||
|
skillKeys: a.skillKeys.includes(key) ? a.skillKeys.filter((k) => k !== key) : [...a.skillKeys, key],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const selected = useMemo(() => seats.find((s) => s.id === selectedSeat) ?? null, [seats, selectedSeat])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">AI seats</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Connect a model (BYOK) and staff a seat with an AI agent.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<KeyRound className="size-4" /> Model connections (BYOK)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Keys are encrypted server-side and never shown again after saving.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<Field label="Name">
|
||||||
|
<Input value={cfg.name} onChange={(e) => setCfg({ ...cfg, name: e.target.value })} className="w-40" placeholder="Vertex-Pro" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Provider">
|
||||||
|
<Select value={cfg.provider} onValueChange={(v) => setCfg({ ...cfg, provider: v })}>
|
||||||
|
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{['stub', 'openai', 'anthropic', 'vertex', 'ollama'].map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>{p}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Model">
|
||||||
|
<Input value={cfg.model} onChange={(e) => setCfg({ ...cfg, model: e.target.value })} className="w-40" />
|
||||||
|
</Field>
|
||||||
|
<Field label="API key">
|
||||||
|
<Input type="password" value={cfg.apiKey} onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" />
|
||||||
|
</Field>
|
||||||
|
<Button onClick={createConfig}><Plus data-icon="inline-start" />Add</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{configs.map((c) => (
|
||||||
|
<div key={c.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
<span className="text-muted-foreground">{c.provider} · {c.model}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => testConfig(c.id)}>Test</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{configs.length === 0 && <p className="text-sm text-muted-foreground">No model connections yet.</p>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Team</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap items-end gap-3">
|
||||||
|
<Field label="Team">
|
||||||
|
<Select value={teamId ?? ''} onValueChange={(v) => setTeamId(v || null)}>
|
||||||
|
<SelectTrigger className="w-56"><SelectValue placeholder="Select a team" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{teams.map((t) => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
{teamId && (
|
||||||
|
<Field label="New seat (role)">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={newSeat} onChange={(e) => setNewSeat(e.target.value)} className="w-48" placeholder="Product Owner" />
|
||||||
|
<Button onClick={createSeat}><Plus data-icon="inline-start" />Create</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{teamId && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Seats</CardTitle>
|
||||||
|
<CardDescription>Pick a seat to configure its agent.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
{seats.map((seat) => (
|
||||||
|
<button
|
||||||
|
key={seat.id}
|
||||||
|
onClick={() => selectSeat(seat)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm',
|
||||||
|
selectedSeat === seat.id && 'border-indigo-500 ring-1 ring-indigo-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{seat.roleName}</span>
|
||||||
|
<Badge variant={seat.state === 'Ai' ? 'default' : 'secondary'}>{seat.state}</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{seats.length === 0 && <p className="text-sm text-muted-foreground">No seats yet.</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Bot className="size-4" /> Agent
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{selected ? `Configure “${selected.roleName}”` : 'Select a seat on the left.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{selected && (
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<Field label="Name">
|
||||||
|
<Input value={agent.name} onChange={(e) => setAgent({ ...agent, name: e.target.value })} className="w-40" placeholder="Aria" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Monogram">
|
||||||
|
<Input value={agent.monogram} onChange={(e) => setAgent({ ...agent, monogram: e.target.value })} className="w-20" placeholder="AR" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Autonomy</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{AUTONOMY.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.value}
|
||||||
|
onClick={() => setAgent({ ...agent, autonomy: a.value })}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border px-3 py-1.5 text-sm',
|
||||||
|
agent.autonomy === a.value ? a.on : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Model connection">
|
||||||
|
<Select value={agent.apiConfigId} onValueChange={(v) => setAgent({ ...agent, apiConfigId: v })}>
|
||||||
|
<SelectTrigger className="w-64"><SelectValue placeholder="Pick a connection" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{configs.map((c) => <SelectItem key={c.id} value={c.id}>{c.name} · {c.model}</SelectItem>)}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Skills</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)}>
|
||||||
|
<Badge variant={agent.skillKeys.includes(skill.skillKey) ? 'default' : 'outline'}>
|
||||||
|
{skill.name}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{skills.length === 0 && <p className="text-sm text-muted-foreground">No skills indexed yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Docs (comma-separated)">
|
||||||
|
<Input value={agent.docs} onChange={(e) => setAgent({ ...agent, docs: e.target.value })} placeholder="product-docs, house-style" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button onClick={saveAgent} className="self-start">
|
||||||
|
<Wand2 data-icon="inline-start" />
|
||||||
|
Save agent
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
"Audience": "teamup",
|
"Audience": "teamup",
|
||||||
"ExpiryMinutes": 480
|
"ExpiryMinutes": 480
|
||||||
},
|
},
|
||||||
|
"Encryption": {
|
||||||
|
"MasterKey": "dev-only-teamup-master-secret-change-in-production"
|
||||||
|
},
|
||||||
"OpenTelemetry": {
|
"OpenTelemetry": {
|
||||||
"OtlpEndpoint": ""
|
"OtlpEndpoint": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"Audience": "teamup",
|
"Audience": "teamup",
|
||||||
"ExpiryMinutes": 480
|
"ExpiryMinutes": 480
|
||||||
},
|
},
|
||||||
|
"Encryption": {
|
||||||
|
"MasterKey": "dev-only-teamup-master-secret-change-in-production"
|
||||||
|
},
|
||||||
"OpenTelemetry": {
|
"OpenTelemetry": {
|
||||||
"OtlpEndpoint": ""
|
"OtlpEndpoint": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeamUp.Modules.Integrations.Persistence;
|
||||||
|
using TeamUp.Modules.Integrations.Security;
|
||||||
|
using TeamUp.SharedKernel.Ai;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Ai;
|
||||||
|
|
||||||
|
/// <summary>Resolves a BYOK config and decrypts its key — server-side only.</summary>
|
||||||
|
internal sealed class ApiConfigResolver(IntegrationsDbContext db, ISecretProtector protector) : IApiConfigResolver
|
||||||
|
{
|
||||||
|
public async Task<ResolvedApiConfig?> ResolveAsync(Guid apiConfigId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var config = await db.ApiConfigs.FirstOrDefaultAsync(c => c.Id == apiConfigId, cancellationToken);
|
||||||
|
return config is null
|
||||||
|
? null
|
||||||
|
: new ResolvedApiConfig(
|
||||||
|
config.Id, config.Name, config.Provider, config.Model, config.Endpoint,
|
||||||
|
protector.Unprotect(config.EncryptedKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using TeamUp.SharedKernel.Ai;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Ai;
|
||||||
|
|
||||||
|
/// <summary>No-network adapter for the "stub"/"echo" provider — used by tests and dogfood without keys.</summary>
|
||||||
|
internal sealed class StubModelClient : IModelClient
|
||||||
|
{
|
||||||
|
public Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(new ModelCompletion(
|
||||||
|
Success: true,
|
||||||
|
Text: $"[stub {request.Provider}/{request.Model}] {request.Prompt}",
|
||||||
|
Error: null,
|
||||||
|
LatencyMs: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenAI-compatible /v1/chat/completions adapter (OpenAI, Ollama, vLLM, and OpenAI-compatible
|
||||||
|
/// gateways). Returns a failed completion rather than throwing, so the connection test can report it.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClient
|
||||||
|
{
|
||||||
|
public async Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var baseUrl = (request.Endpoint ?? "https://api.openai.com").TrimEnd('/');
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/v1/chat/completions");
|
||||||
|
if (!string.IsNullOrEmpty(request.ApiKey))
|
||||||
|
{
|
||||||
|
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", request.ApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
model = request.Model,
|
||||||
|
max_tokens = request.MaxTokens,
|
||||||
|
messages = new[] { new { role = "user", content = request.Prompt } },
|
||||||
|
});
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, cancellationToken);
|
||||||
|
stopwatch.Stop();
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new ModelCompletion(false, null, $"HTTP {(int)response.StatusCode}", stopwatch.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken);
|
||||||
|
var text = doc.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString();
|
||||||
|
return new ModelCompletion(true, text, null, stopwatch.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
return new ModelCompletion(false, null, ex.Message, stopwatch.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Routes a request to the adapter for its provider.</summary>
|
||||||
|
internal sealed class ModelClientRouter(StubModelClient stub, OpenAiCompatibleModelClient openAi) : IModelClient
|
||||||
|
{
|
||||||
|
public Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) =>
|
||||||
|
request.Provider.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"stub" or "echo" or "test" => stub.CompleteAsync(request, cancellationToken),
|
||||||
|
_ => openAi.CompleteAsync(request, cancellationToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using TeamUp.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A BYOK model configuration (a named provider+model with an encrypted key), owned at the org
|
||||||
|
/// scope. Owner-only to create/test/delete; the key is encrypted at rest and never returned to a
|
||||||
|
/// client after save — team owners assign a config by id without ever seeing the key.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ApiConfig : Entity
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; private set; }
|
||||||
|
public string Name { get; private set; } = null!;
|
||||||
|
public string Provider { get; private set; } = null!;
|
||||||
|
public string Model { get; private set; } = null!;
|
||||||
|
public string? Endpoint { get; private set; }
|
||||||
|
public string EncryptedKey { get; private set; } = null!;
|
||||||
|
public Guid CreatedByMemberId { get; private set; }
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||||
|
|
||||||
|
private ApiConfig()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiConfig(
|
||||||
|
Guid organizationId,
|
||||||
|
string name,
|
||||||
|
string provider,
|
||||||
|
string model,
|
||||||
|
string? endpoint,
|
||||||
|
string encryptedKey,
|
||||||
|
Guid createdByMemberId,
|
||||||
|
DateTimeOffset createdAtUtc)
|
||||||
|
{
|
||||||
|
OrganizationId = organizationId;
|
||||||
|
Name = name;
|
||||||
|
Provider = provider;
|
||||||
|
Model = model;
|
||||||
|
Endpoint = endpoint;
|
||||||
|
EncryptedKey = encryptedKey;
|
||||||
|
CreatedByMemberId = createdByMemberId;
|
||||||
|
CreatedAtUtc = createdAtUtc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TeamUp.Modules.Integrations.Endpoints;
|
||||||
|
|
||||||
|
internal sealed record CreateApiConfigRequest(
|
||||||
|
Guid OrganizationId,
|
||||||
|
string Name,
|
||||||
|
string Provider,
|
||||||
|
string Model,
|
||||||
|
string? Endpoint,
|
||||||
|
string ApiKey);
|
||||||
|
|
||||||
|
/// <summary>Public view of a config — never includes the key.</summary>
|
||||||
|
internal sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint);
|
||||||
|
|
||||||
|
internal sealed record TestResultDto(bool Success, string? Error, long LatencyMs, string? Sample);
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeamUp.Modules.Integrations.Domain;
|
||||||
|
using TeamUp.Modules.Integrations.Persistence;
|
||||||
|
using TeamUp.Modules.Integrations.Security;
|
||||||
|
using TeamUp.SharedKernel.Access;
|
||||||
|
using TeamUp.SharedKernel.Ai;
|
||||||
|
using TeamUp.SharedKernel.Modularity;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Endpoints;
|
||||||
|
|
||||||
|
internal static class IntegrationsEndpoints
|
||||||
|
{
|
||||||
|
public static void Map(IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
var group = endpoints.MapGroup("/api/integrations").WithTags("Integrations");
|
||||||
|
|
||||||
|
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("integrations")));
|
||||||
|
group.MapPost("/api-configs", CreateApiConfig).RequireAuthorization();
|
||||||
|
group.MapGet("/api-configs", ListApiConfigs).RequireAuthorization();
|
||||||
|
group.MapPost("/api-configs/{id:guid}/test", TestApiConfig).RequireAuthorization();
|
||||||
|
group.MapDelete("/api-configs/{id:guid}", DeleteApiConfig).RequireAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner-only. Encrypts the key; the response never includes it.
|
||||||
|
private static async Task<IResult> CreateApiConfig(
|
||||||
|
CreateApiConfigRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
|
IntegrationsDbContext db, ISecretProtector protector, TimeProvider clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!permissions.Has(Capability.ManageApiKeys, ScopeRef.Org(request.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Provider)
|
||||||
|
|| string.IsNullOrWhiteSpace(request.Model) || string.IsNullOrWhiteSpace(request.ApiKey))
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Name, provider, model and apiKey are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = new ApiConfig(
|
||||||
|
request.OrganizationId, request.Name.Trim(), request.Provider.Trim(), request.Model.Trim(),
|
||||||
|
request.Endpoint, protector.Protect(request.ApiKey), user.MemberId, clock.GetUtcNow());
|
||||||
|
|
||||||
|
db.ApiConfigs.Add(config);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(ToDto(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team owners may list (to assign) — without ever seeing the key.
|
||||||
|
private static async Task<IResult> ListApiConfigs(
|
||||||
|
Guid organizationId, IPermissionService permissions, IntegrationsDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Org(organizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var configs = await db.ApiConfigs
|
||||||
|
.Where(c => c.OrganizationId == organizationId)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.Select(c => new ApiConfigDto(c.Id, c.Name, c.Provider, c.Model, c.Endpoint))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(configs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner-only. Resolves + decrypts server-side, makes a tiny model call, returns the outcome.
|
||||||
|
private static async Task<IResult> TestApiConfig(
|
||||||
|
Guid id, IPermissionService permissions, IntegrationsDbContext db,
|
||||||
|
IApiConfigResolver resolver, IModelClient model, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var config = await db.ApiConfigs.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.ManageApiKeys, ScopeRef.Org(config.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved = await resolver.ResolveAsync(id, ct);
|
||||||
|
if (resolved is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var completion = await model.CompleteAsync(
|
||||||
|
new ModelRequest(resolved.Provider, resolved.Model, resolved.ApiKey, resolved.Endpoint, "ping", MaxTokens: 16), ct);
|
||||||
|
|
||||||
|
var sample = completion.Text is { Length: > 0 } text ? text[..Math.Min(text.Length, 80)] : null;
|
||||||
|
return Results.Ok(new TestResultDto(completion.Success, completion.Error, completion.LatencyMs, sample));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteApiConfig(
|
||||||
|
Guid id, IPermissionService permissions, IntegrationsDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var config = await db.ApiConfigs.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.ManageApiKeys, ScopeRef.Org(config.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ApiConfigs.Remove(config);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiConfigDto ToDto(ApiConfig config) =>
|
||||||
|
new(config.Id, config.Name, config.Provider, config.Model, config.Endpoint);
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using TeamUp.Modules.Integrations.Ai;
|
||||||
|
using TeamUp.Modules.Integrations.Endpoints;
|
||||||
using TeamUp.Modules.Integrations.Git;
|
using TeamUp.Modules.Integrations.Git;
|
||||||
|
using TeamUp.Modules.Integrations.Persistence;
|
||||||
|
using TeamUp.Modules.Integrations.Security;
|
||||||
|
using TeamUp.SharedKernel.Ai;
|
||||||
using TeamUp.SharedKernel.Git;
|
using TeamUp.SharedKernel.Git;
|
||||||
using TeamUp.SharedKernel.Modularity;
|
using TeamUp.SharedKernel.Modularity;
|
||||||
|
using TeamUp.SharedKernel.Persistence;
|
||||||
|
|
||||||
namespace TeamUp.Modules.Integrations;
|
namespace TeamUp.Modules.Integrations;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BYOK API configs, the Git connection, the encrypted-credential store. In M2 it provides the
|
/// BYOK API configs (encrypted, owner-only), the model-client adapters, and the Git connection.
|
||||||
/// <see cref="IGitProvider"/> (filesystem for dogfood, Gitea over REST). BYOK lands in M3.
|
/// Encryption keys are owner-only and server-side; the decrypted key never leaves the server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IntegrationsModule : IModule
|
public sealed class IntegrationsModule : IModule
|
||||||
{
|
{
|
||||||
@@ -19,10 +25,26 @@ public sealed class IntegrationsModule : IModule
|
|||||||
|
|
||||||
public void Register(IServiceCollection services, IConfiguration configuration)
|
public void Register(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.Configure<GitSourceOptions>(configuration.GetSection(GitSourceOptions.SectionName));
|
var connectionString = configuration.GetConnectionString("Postgres")
|
||||||
var options = configuration.GetSection(GitSourceOptions.SectionName).Get<GitSourceOptions>() ?? new GitSourceOptions();
|
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
|
||||||
|
|
||||||
if (string.Equals(options.Provider, "gitea", StringComparison.OrdinalIgnoreCase))
|
// BYOK credential store + encryption.
|
||||||
|
services.AddDbContext<IntegrationsDbContext>(options => options.UseNpgsql(connectionString));
|
||||||
|
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<IntegrationsDbContext>());
|
||||||
|
services.Configure<EncryptionOptions>(configuration.GetSection(EncryptionOptions.SectionName));
|
||||||
|
services.AddSingleton<ISecretProtector, AesGcmSecretProtector>();
|
||||||
|
services.AddScoped<IApiConfigResolver, ApiConfigResolver>();
|
||||||
|
services.TryAddSingleton(TimeProvider.System);
|
||||||
|
|
||||||
|
// Model clients: a router over per-provider adapters.
|
||||||
|
services.AddSingleton<StubModelClient>();
|
||||||
|
services.AddHttpClient<OpenAiCompatibleModelClient>();
|
||||||
|
services.AddScoped<IModelClient, ModelClientRouter>();
|
||||||
|
|
||||||
|
// Git source (M2) — filesystem for dogfood, Gitea over REST when configured.
|
||||||
|
services.Configure<GitSourceOptions>(configuration.GetSection(GitSourceOptions.SectionName));
|
||||||
|
var gitOptions = configuration.GetSection(GitSourceOptions.SectionName).Get<GitSourceOptions>() ?? new GitSourceOptions();
|
||||||
|
if (string.Equals(gitOptions.Provider, "gitea", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
services.AddHttpClient<IGitProvider, GiteaGitProvider>();
|
services.AddHttpClient<IGitProvider, GiteaGitProvider>();
|
||||||
}
|
}
|
||||||
@@ -32,10 +54,5 @@ public sealed class IntegrationsModule : IModule
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
public void MapEndpoints(IEndpointRouteBuilder endpoints) => IntegrationsEndpoints.Map(endpoints);
|
||||||
{
|
|
||||||
endpoints.MapGroup($"/api/{Name}")
|
|
||||||
.WithTags("Integrations")
|
|
||||||
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeamUp.Modules.Integrations.Domain;
|
||||||
|
using TeamUp.SharedKernel.Persistence;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Persistence;
|
||||||
|
|
||||||
|
internal sealed class IntegrationsDbContext(DbContextOptions<IntegrationsDbContext> options)
|
||||||
|
: DbContext(options), IModuleDbContext
|
||||||
|
{
|
||||||
|
public DbSet<ApiConfig> ApiConfigs => Set<ApiConfig>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasDefaultSchema("integrations");
|
||||||
|
|
||||||
|
modelBuilder.Entity<ApiConfig>(config =>
|
||||||
|
{
|
||||||
|
config.ToTable("api_configs");
|
||||||
|
config.HasKey(c => c.Id);
|
||||||
|
config.Property(c => c.Name).HasMaxLength(120).IsRequired();
|
||||||
|
config.Property(c => c.Provider).HasMaxLength(60).IsRequired();
|
||||||
|
config.Property(c => c.Model).HasMaxLength(120).IsRequired();
|
||||||
|
config.Property(c => c.Endpoint).HasMaxLength(500);
|
||||||
|
config.Property(c => c.EncryptedKey).IsRequired();
|
||||||
|
config.HasIndex(c => c.OrganizationId);
|
||||||
|
config.HasIndex(c => new { c.OrganizationId, c.Name }).IsUnique();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Persistence;
|
||||||
|
|
||||||
|
/// <summary>Design-time factory so `dotnet ef` can build the internal context without a host.</summary>
|
||||||
|
internal sealed class IntegrationsDbContextFactory : IDesignTimeDbContextFactory<IntegrationsDbContext>
|
||||||
|
{
|
||||||
|
public IntegrationsDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var connectionString =
|
||||||
|
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
|
||||||
|
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<IntegrationsDbContext>()
|
||||||
|
.UseNpgsql(connectionString)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new IntegrationsDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using TeamUp.Modules.Integrations.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(IntegrationsDbContext))]
|
||||||
|
[Migration("20260609194740_InitialIntegrations")]
|
||||||
|
partial class InitialIntegrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("integrations")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.Integrations.Domain.ApiConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptedKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Endpoint")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Provider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("api_configs", "integrations");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialIntegrations : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.EnsureSchema(
|
||||||
|
name: "integrations");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "api_configs",
|
||||||
|
schema: "integrations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||||
|
Provider = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
|
||||||
|
Model = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||||
|
Endpoint = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
EncryptedKey = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CreatedByMemberId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_api_configs", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_api_configs_OrganizationId",
|
||||||
|
schema: "integrations",
|
||||||
|
table: "api_configs",
|
||||||
|
column: "OrganizationId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_api_configs_OrganizationId_Name",
|
||||||
|
schema: "integrations",
|
||||||
|
table: "api_configs",
|
||||||
|
columns: new[] { "OrganizationId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "api_configs",
|
||||||
|
schema: "integrations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using TeamUp.Modules.Integrations.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(IntegrationsDbContext))]
|
||||||
|
partial class IntegrationsDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("integrations")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.Integrations.Domain.ApiConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptedKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Endpoint")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Provider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("api_configs", "integrations");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Integrations.Security;
|
||||||
|
|
||||||
|
internal sealed class EncryptionOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Encryption";
|
||||||
|
|
||||||
|
/// <summary>Deployment master secret. A 32-byte AES key is derived from it (SHA-256).</summary>
|
||||||
|
public string MasterKey { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ISecretProtector
|
||||||
|
{
|
||||||
|
string Protect(string plaintext);
|
||||||
|
|
||||||
|
string Unprotect(string protectedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AES-256-GCM authenticated encryption with a key derived from the deployment master secret.
|
||||||
|
/// Output blob = nonce(12) ‖ tag(16) ‖ ciphertext, base64-encoded.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AesGcmSecretProtector : ISecretProtector
|
||||||
|
{
|
||||||
|
private const int NonceSize = 12;
|
||||||
|
private const int TagSize = 16;
|
||||||
|
private readonly byte[] _key;
|
||||||
|
|
||||||
|
public AesGcmSecretProtector(IOptions<EncryptionOptions> options)
|
||||||
|
{
|
||||||
|
var masterKey = options.Value.MasterKey;
|
||||||
|
if (string.IsNullOrWhiteSpace(masterKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Missing 'Encryption:MasterKey'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_key = SHA256.HashData(Encoding.UTF8.GetBytes(masterKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Protect(string plaintext)
|
||||||
|
{
|
||||||
|
var plain = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
|
||||||
|
var cipher = new byte[plain.Length];
|
||||||
|
var tag = new byte[TagSize];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSize);
|
||||||
|
aes.Encrypt(nonce, plain, cipher, tag);
|
||||||
|
|
||||||
|
var blob = new byte[NonceSize + TagSize + cipher.Length];
|
||||||
|
Buffer.BlockCopy(nonce, 0, blob, 0, NonceSize);
|
||||||
|
Buffer.BlockCopy(tag, 0, blob, NonceSize, TagSize);
|
||||||
|
Buffer.BlockCopy(cipher, 0, blob, NonceSize + TagSize, cipher.Length);
|
||||||
|
return Convert.ToBase64String(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Unprotect(string protectedValue)
|
||||||
|
{
|
||||||
|
var blob = Convert.FromBase64String(protectedValue);
|
||||||
|
var nonce = blob.AsSpan(0, NonceSize);
|
||||||
|
var tag = blob.AsSpan(NonceSize, TagSize);
|
||||||
|
var cipher = blob.AsSpan(NonceSize + TagSize);
|
||||||
|
var plain = new byte[cipher.Length];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSize);
|
||||||
|
aes.Decrypt(nonce, cipher, tag, plain);
|
||||||
|
return Encoding.UTF8.GetString(plain);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
|
<!-- BYOK API configs, the Git connection, the encrypted-credential store (M3). References
|
||||||
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
|
SharedKernel only. The Git provider uses framework HttpClient; the BYOK store uses EF Core.
|
||||||
gains an (internal) DbContext and validators. It must never reference another module.
|
Model calls go through thin HTTP adapters (no Microsoft.Extensions.AI dependency yet). -->
|
||||||
NOTE: the AI model-client packages (Microsoft.Extensions.AI, ONNX) are deferred to M3-M4;
|
|
||||||
this module exposes only seam interfaces in V1, no concrete model client. -->
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using TeamUp.SharedKernel.Access;
|
||||||
|
using TeamUp.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The AI staffing an open seat: identity (name, monogram) + matched skill atoms + autonomy +
|
||||||
|
/// the model config + docs. References Skills by key and the BYOK ApiConfig by id — never reaches
|
||||||
|
/// into those modules' tables. One agent per seat.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class Agent : Entity
|
||||||
|
{
|
||||||
|
public Guid SeatId { get; private set; }
|
||||||
|
public string Name { get; private set; } = null!;
|
||||||
|
public string? Monogram { get; private set; }
|
||||||
|
public Autonomy Autonomy { get; private set; }
|
||||||
|
public Guid ApiConfigId { get; private set; }
|
||||||
|
public Guid? FallbackApiConfigId { get; private set; }
|
||||||
|
public List<string> SkillKeys { get; private set; } = [];
|
||||||
|
public List<string> Docs { get; private set; } = [];
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||||
|
public DateTimeOffset UpdatedAtUtc { get; private set; }
|
||||||
|
|
||||||
|
private Agent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Agent(Guid seatId, DateTimeOffset createdAtUtc)
|
||||||
|
{
|
||||||
|
SeatId = seatId;
|
||||||
|
CreatedAtUtc = createdAtUtc;
|
||||||
|
UpdatedAtUtc = createdAtUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(
|
||||||
|
string name,
|
||||||
|
string? monogram,
|
||||||
|
Autonomy autonomy,
|
||||||
|
Guid apiConfigId,
|
||||||
|
Guid? fallbackApiConfigId,
|
||||||
|
List<string> skillKeys,
|
||||||
|
List<string> docs,
|
||||||
|
DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Monogram = monogram;
|
||||||
|
Autonomy = autonomy;
|
||||||
|
ApiConfigId = apiConfigId;
|
||||||
|
FallbackApiConfigId = fallbackApiConfigId;
|
||||||
|
SkillKeys = skillKeys;
|
||||||
|
Docs = docs;
|
||||||
|
UpdatedAtUtc = nowUtc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,4 +38,11 @@ internal sealed class Seat : Entity
|
|||||||
AgentId = null;
|
AgentId = null;
|
||||||
State = SeatState.Open;
|
State = SeatState.Open;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AssignAgent(Guid agentId)
|
||||||
|
{
|
||||||
|
AgentId = agentId;
|
||||||
|
MemberId = null;
|
||||||
|
State = SeatState.Ai;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using TeamUp.Modules.OrgBoard.Domain;
|
using TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
using TeamUp.SharedKernel.Access;
|
||||||
|
|
||||||
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
||||||
|
|
||||||
@@ -30,3 +31,27 @@ internal sealed record TaskResponse(
|
|||||||
internal sealed record BoardColumn(string Status, IReadOnlyList<TaskResponse> Items);
|
internal sealed record BoardColumn(string Status, IReadOnlyList<TaskResponse> Items);
|
||||||
|
|
||||||
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> Columns);
|
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> Columns);
|
||||||
|
|
||||||
|
internal sealed record CreateSeatRequest(Guid TeamId, string RoleName);
|
||||||
|
|
||||||
|
internal sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
|
||||||
|
|
||||||
|
internal sealed record ConfigureAgentRequest(
|
||||||
|
string Name,
|
||||||
|
string? Monogram,
|
||||||
|
Autonomy Autonomy,
|
||||||
|
Guid ApiConfigId,
|
||||||
|
Guid? FallbackApiConfigId,
|
||||||
|
List<string> SkillKeys,
|
||||||
|
List<string> Docs);
|
||||||
|
|
||||||
|
internal sealed record AgentResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid SeatId,
|
||||||
|
string Name,
|
||||||
|
string? Monogram,
|
||||||
|
string Autonomy,
|
||||||
|
Guid ApiConfigId,
|
||||||
|
Guid? FallbackApiConfigId,
|
||||||
|
List<string> SkillKeys,
|
||||||
|
List<string> Docs);
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ internal static class OrgBoardEndpoints
|
|||||||
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
|
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
|
||||||
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
|
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
|
||||||
group.MapGet("/cartable", Cartable).RequireAuthorization();
|
group.MapGet("/cartable", Cartable).RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapPost("/seats", CreateSeat).RequireAuthorization();
|
||||||
|
group.MapGet("/seats", ListSeats).RequireAuthorization();
|
||||||
|
group.MapPost("/seats/{id:guid}/agent", ConfigureAgent).RequireAuthorization();
|
||||||
|
group.MapGet("/seats/{id:guid}/agent", GetAgent).RequireAuthorization();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TaskResponse ToResponse(WorkItem item) => new(
|
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||||
@@ -225,4 +230,127 @@ internal static class OrgBoardEndpoints
|
|||||||
|
|
||||||
return (item, team, null);
|
return (item, team, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SeatResponse ToSeat(Seat seat) =>
|
||||||
|
new(seat.Id, seat.TeamId, seat.RoleName, seat.State.ToString(), seat.MemberId, seat.AgentId);
|
||||||
|
|
||||||
|
private static AgentResponse ToAgent(Agent agent) => new(
|
||||||
|
agent.Id, agent.SeatId, agent.Name, agent.Monogram, agent.Autonomy.ToString(),
|
||||||
|
agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.Docs);
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateSeat(
|
||||||
|
CreateSeatRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
|
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
|
||||||
|
if (team is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound("Team not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.RoleName))
|
||||||
|
{
|
||||||
|
return Results.BadRequest("RoleName is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var seat = new Seat(team.Id, request.RoleName.Trim(), SeatState.Open, clock.GetUtcNow());
|
||||||
|
db.Seats.Add(seat);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(new AuditEvent("seat.created", "Seat", seat.Id, user.MemberId, request.RoleName), ct);
|
||||||
|
return Results.Ok(ToSeat(seat));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListSeats(
|
||||||
|
Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == teamId, ct);
|
||||||
|
if (team is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound("Team not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var seats = await db.Seats.Where(s => s.TeamId == teamId).OrderBy(s => s.CreatedAtUtc).ToListAsync(ct);
|
||||||
|
return Results.Ok(seats.Select(ToSeat).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ConfigureAgent(
|
||||||
|
Guid id, ConfigureAgentRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
|
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (seat is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound("Seat not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct);
|
||||||
|
if (team is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound("Team not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name) || request.ApiConfigId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Name and apiConfigId are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct);
|
||||||
|
var isNew = agent is null;
|
||||||
|
agent ??= new Agent(seat.Id, now);
|
||||||
|
agent.Configure(
|
||||||
|
request.Name.Trim(), request.Monogram, request.Autonomy, request.ApiConfigId,
|
||||||
|
request.FallbackApiConfigId, request.SkillKeys ?? [], request.Docs ?? [], now);
|
||||||
|
|
||||||
|
if (isNew)
|
||||||
|
{
|
||||||
|
db.Agents.Add(agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
seat.AssignAgent(agent.Id);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(new AuditEvent("agent.configured", "Agent", agent.Id, user.MemberId, agent.Name), ct);
|
||||||
|
return Results.Ok(ToAgent(agent));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetAgent(
|
||||||
|
Guid id, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (seat is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound("Seat not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct);
|
||||||
|
if (team is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound("Team not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct);
|
||||||
|
return agent is null
|
||||||
|
? Results.NotFound("Seat has no agent configured.")
|
||||||
|
: Results.Ok(ToAgent(agent));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+217
@@ -0,0 +1,217 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using TeamUp.Modules.OrgBoard.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(OrgBoardDbContext))]
|
||||||
|
[Migration("20260609200923_AddAgents")]
|
||||||
|
partial class AddAgents
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("orgboard")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApiConfigId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Autonomy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Docs")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FallbackApiConfigId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Monogram")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SeatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("SkillKeys")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeatId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("agents", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("organizations", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AgentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("RoleName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeamId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId");
|
||||||
|
|
||||||
|
b.ToTable("seats", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
|
b.ToTable("teams", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssigneeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssigneeKind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeamId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId");
|
||||||
|
|
||||||
|
b.HasIndex("AssigneeKind", "AssigneeId");
|
||||||
|
|
||||||
|
b.ToTable("work_items", "orgboard");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAgents : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "agents",
|
||||||
|
schema: "orgboard",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
SeatId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||||
|
Monogram = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
|
||||||
|
Autonomy = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
ApiConfigId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FallbackApiConfigId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
SkillKeys = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||||
|
Docs = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||||
|
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_agents", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_agents_SeatId",
|
||||||
|
schema: "orgboard",
|
||||||
|
table: "agents",
|
||||||
|
column: "SeatId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "agents",
|
||||||
|
schema: "orgboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -1,5 +1,6 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
@@ -23,6 +24,57 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApiConfigId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Autonomy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Docs")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FallbackApiConfigId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Monogram")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SeatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("SkillKeys")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeatId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("agents", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
|||||||
public DbSet<Organization> Organizations => Set<Organization>();
|
public DbSet<Organization> Organizations => Set<Organization>();
|
||||||
public DbSet<Team> Teams => Set<Team>();
|
public DbSet<Team> Teams => Set<Team>();
|
||||||
public DbSet<Seat> Seats => Set<Seat>();
|
public DbSet<Seat> Seats => Set<Seat>();
|
||||||
|
public DbSet<Agent> Agents => Set<Agent>();
|
||||||
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
@@ -40,6 +41,16 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
|||||||
seat.HasIndex(s => s.TeamId);
|
seat.HasIndex(s => s.TeamId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Agent>(agent =>
|
||||||
|
{
|
||||||
|
agent.ToTable("agents");
|
||||||
|
agent.HasKey(a => a.Id);
|
||||||
|
agent.Property(a => a.Name).HasMaxLength(120).IsRequired();
|
||||||
|
agent.Property(a => a.Monogram).HasMaxLength(8);
|
||||||
|
agent.Property(a => a.Autonomy).HasConversion<string>().HasMaxLength(20);
|
||||||
|
agent.HasIndex(a => a.SeatId).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<WorkItem>(workItem =>
|
modelBuilder.Entity<WorkItem>(workItem =>
|
||||||
{
|
{
|
||||||
workItem.ToTable("work_items");
|
workItem.ToTable("work_items");
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace TeamUp.SharedKernel.Access;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The per-seat autonomy dial, set by the team owner. The action gate (M5) compares it to an
|
||||||
|
/// action's risk to decide execute-vs-hold. Stored on the Agent (M3); evaluated in Governance.
|
||||||
|
/// </summary>
|
||||||
|
public enum Autonomy
|
||||||
|
{
|
||||||
|
DraftOnly,
|
||||||
|
Gated,
|
||||||
|
Autonomous,
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace TeamUp.SharedKernel.Ai;
|
||||||
|
|
||||||
|
/// <summary>Non-sensitive BYOK config info (no key) — safe to list/return to clients.</summary>
|
||||||
|
public sealed record ApiConfigSummary(Guid Id, string Name, string Provider, string Model);
|
||||||
|
|
||||||
|
/// <summary>A resolved config including the decrypted key. Server-side only — never serialized to a client.</summary>
|
||||||
|
public sealed record ResolvedApiConfig(Guid Id, string Name, string Provider, string Model, string? Endpoint, string ApiKey);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a BYOK config (decrypting the key) for server-side use — the M3 connection test and the
|
||||||
|
/// M4 assembler. Implemented by Integrations; the decrypted key never leaves the server.
|
||||||
|
/// </summary>
|
||||||
|
public interface IApiConfigResolver
|
||||||
|
{
|
||||||
|
Task<ResolvedApiConfig?> ResolveAsync(Guid apiConfigId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace TeamUp.SharedKernel.Ai;
|
||||||
|
|
||||||
|
/// <summary>One model invocation. The key is passed explicitly (BYOK, server-side only).</summary>
|
||||||
|
public sealed record ModelRequest(
|
||||||
|
string Provider,
|
||||||
|
string Model,
|
||||||
|
string ApiKey,
|
||||||
|
string? Endpoint,
|
||||||
|
string Prompt,
|
||||||
|
int MaxTokens = 256);
|
||||||
|
|
||||||
|
public sealed record ModelCompletion(bool Success, string? Text, string? Error, long LatencyMs);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provider-agnostic model client. Implemented in Integrations (a router over per-provider HTTP
|
||||||
|
/// adapters). Used by the M3 BYOK test call and the M4 assembler. BYOK — never resells tokens.
|
||||||
|
/// </summary>
|
||||||
|
public interface IModelClient
|
||||||
|
{
|
||||||
|
Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- Analyzer rules that fight idiomatic test code:
|
<!-- Analyzer rules that fight idiomatic test code:
|
||||||
CA1707 underscored test names · CA1711 xUnit [CollectionDefinition] "Collection" suffix
|
CA1707 underscored test names · CA1711 xUnit [CollectionDefinition] "Collection" suffix
|
||||||
· xUnit1051 TestContext cancellation token (not needed for these short tests). -->
|
· xUnit1051 TestContext cancellation token (not needed for these short tests). -->
|
||||||
<NoWarn>$(NoWarn);CA1707;CA1711;xUnit1051</NoWarn>
|
<NoWarn>$(NoWarn);CA1707;CA1711;CA1861;xUnit1051</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeamUp.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M3 BYOK acceptance: an owner adds an API config (key encrypted, never returned by any endpoint),
|
||||||
|
/// a connection test succeeds, and a non-owner Member cannot create or list configs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ByokTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||||
|
{
|
||||||
|
private const string SecretKey = "sk-teamup-test-deadbeef-do-not-leak";
|
||||||
|
|
||||||
|
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||||
|
|
||||||
|
private sealed record AuthResponse(string Token, Guid MemberId);
|
||||||
|
|
||||||
|
private sealed record InviteResponse(Guid InvitationId, string Token);
|
||||||
|
|
||||||
|
private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint);
|
||||||
|
|
||||||
|
private sealed record TestResultDto(bool Success, string? Error, long LatencyMs, string? Sample);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Owner_adds_config_key_never_returned_test_succeeds_member_forbidden()
|
||||||
|
{
|
||||||
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
||||||
|
using var anon = factory.CreateClient();
|
||||||
|
|
||||||
|
var owner = await Bootstrap(anon);
|
||||||
|
using var ownerClient = Authed(factory, owner.Token);
|
||||||
|
|
||||||
|
// Owner creates a config (stub provider, no network). The key must NOT appear in the response.
|
||||||
|
var createResponse = await ownerClient.PostAsJsonAsync("/api/integrations/api-configs", new
|
||||||
|
{
|
||||||
|
organizationId = owner.OrganizationId,
|
||||||
|
name = "Stub-Pro",
|
||||||
|
provider = "stub",
|
||||||
|
model = "test-model",
|
||||||
|
apiKey = SecretKey,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
|
||||||
|
Assert.DoesNotContain(SecretKey, await createResponse.Content.ReadAsStringAsync());
|
||||||
|
var config = await createResponse.Content.ReadFromJsonAsync<ApiConfigDto>();
|
||||||
|
Assert.NotNull(config);
|
||||||
|
Assert.Equal("stub", config!.Provider);
|
||||||
|
|
||||||
|
// Listing returns the config but never the key.
|
||||||
|
var listResponse = await ownerClient.GetAsync($"/api/integrations/api-configs?organizationId={owner.OrganizationId}");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||||
|
var listBody = await listResponse.Content.ReadAsStringAsync();
|
||||||
|
Assert.DoesNotContain(SecretKey, listBody);
|
||||||
|
Assert.Contains(config.Id.ToString(), listBody);
|
||||||
|
|
||||||
|
// The connection test succeeds (stub uses the decrypted key server-side; never echoes it).
|
||||||
|
var test = await ownerClient.PostAsync($"/api/integrations/api-configs/{config.Id}/test", content: null);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, test.StatusCode);
|
||||||
|
var testBody = await test.Content.ReadAsStringAsync();
|
||||||
|
Assert.DoesNotContain(SecretKey, testBody);
|
||||||
|
var result = await test.Content.ReadFromJsonAsync<TestResultDto>();
|
||||||
|
Assert.True(result!.Success);
|
||||||
|
|
||||||
|
// A Member cannot manage or even list BYOK configs.
|
||||||
|
var member = await InviteMember(ownerClient, anon, owner.OrganizationId);
|
||||||
|
using var memberClient = Authed(factory, member.Token);
|
||||||
|
|
||||||
|
var memberCreate = await memberClient.PostAsJsonAsync("/api/integrations/api-configs", new
|
||||||
|
{
|
||||||
|
organizationId = owner.OrganizationId,
|
||||||
|
name = "Nope",
|
||||||
|
provider = "stub",
|
||||||
|
model = "x",
|
||||||
|
apiKey = "sk-nope",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, memberCreate.StatusCode);
|
||||||
|
|
||||||
|
var memberList = await memberClient.GetAsync($"/api/integrations/api-configs?organizationId={owner.OrganizationId}");
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, memberList.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<BootstrapResponse> Bootstrap(HttpClient client)
|
||||||
|
{
|
||||||
|
var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new
|
||||||
|
{
|
||||||
|
organizationName = "AliaSaaS",
|
||||||
|
ownerEmail = "owner@alia.test",
|
||||||
|
ownerDisplayName = "Owner",
|
||||||
|
ownerPassword = "Passw0rd!",
|
||||||
|
});
|
||||||
|
var owner = await response.Content.ReadFromJsonAsync<BootstrapResponse>();
|
||||||
|
Assert.NotNull(owner);
|
||||||
|
return owner!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AuthResponse> InviteMember(HttpClient ownerClient, HttpClient anon, Guid organizationId)
|
||||||
|
{
|
||||||
|
var invite = await ownerClient.PostAsJsonAsync("/api/identity/invitations", new
|
||||||
|
{
|
||||||
|
email = "dev@alia.test",
|
||||||
|
scopeType = "Organization",
|
||||||
|
scopeId = organizationId,
|
||||||
|
role = "Member",
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
var inviteResponse = await invite.Content.ReadFromJsonAsync<InviteResponse>();
|
||||||
|
var accept = await anon.PostAsJsonAsync("/api/identity/invitations/accept", new
|
||||||
|
{
|
||||||
|
token = inviteResponse!.Token,
|
||||||
|
displayName = "Dev",
|
||||||
|
password = "Passw0rd!",
|
||||||
|
});
|
||||||
|
var member = await accept.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
|
Assert.NotNull(member);
|
||||||
|
return member!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient Authed(TeamUpWebFactory factory, string token)
|
||||||
|
{
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeamUp.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M3 acceptance: an owner adds a BYOK config, then configures an AI seat ("Aria", gated autonomy,
|
||||||
|
/// a skill, that config) — flipping the seat to AI — without the key ever being exposed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SeatConfigTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||||
|
{
|
||||||
|
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||||
|
|
||||||
|
private sealed record OrganizationResponse(Guid Id, string Name);
|
||||||
|
|
||||||
|
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
||||||
|
|
||||||
|
private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint);
|
||||||
|
|
||||||
|
private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
|
||||||
|
|
||||||
|
private sealed record AgentResponse(
|
||||||
|
Guid Id, Guid SeatId, string Name, string? Monogram, string Autonomy,
|
||||||
|
Guid ApiConfigId, Guid? FallbackApiConfigId, List<string> SkillKeys, List<string> Docs);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Owner_configures_an_ai_seat_with_skills_autonomy_and_byok_config()
|
||||||
|
{
|
||||||
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
||||||
|
using var anon = factory.CreateClient();
|
||||||
|
|
||||||
|
var owner = await Bootstrap(anon);
|
||||||
|
using var client = Authed(factory, owner.Token);
|
||||||
|
|
||||||
|
await PostOk<OrganizationResponse>(client, "/api/orgboard/organizations",
|
||||||
|
new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
|
||||||
|
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams",
|
||||||
|
new { organizationId = owner.OrganizationId, name = "IPNOPS" });
|
||||||
|
|
||||||
|
var config = await PostOk<ApiConfigDto>(client, "/api/integrations/api-configs", new
|
||||||
|
{
|
||||||
|
organizationId = owner.OrganizationId,
|
||||||
|
name = "Vertex-Pro",
|
||||||
|
provider = "stub",
|
||||||
|
model = "gemini-pro",
|
||||||
|
apiKey = "sk-byok-secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an open seat, then configure an AI agent on it.
|
||||||
|
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats",
|
||||||
|
new { teamId = team.Id, roleName = "Product Owner" });
|
||||||
|
Assert.Equal("Open", seat.State);
|
||||||
|
|
||||||
|
var agent = await PostOk<AgentResponse>(client, $"/api/orgboard/seats/{seat.Id}/agent", new
|
||||||
|
{
|
||||||
|
name = "Aria",
|
||||||
|
monogram = "AR",
|
||||||
|
autonomy = "Gated",
|
||||||
|
apiConfigId = config.Id,
|
||||||
|
skillKeys = new[] { "spec-writing", "story-breakdown" },
|
||||||
|
docs = new[] { "product-docs" },
|
||||||
|
});
|
||||||
|
Assert.Equal("Aria", agent.Name);
|
||||||
|
Assert.Equal("Gated", agent.Autonomy);
|
||||||
|
Assert.Equal(config.Id, agent.ApiConfigId);
|
||||||
|
Assert.Contains("spec-writing", agent.SkillKeys);
|
||||||
|
|
||||||
|
// Reading it back returns the same configuration.
|
||||||
|
var fetched = await client.GetFromJsonAsync<AgentResponse>($"/api/orgboard/seats/{seat.Id}/agent");
|
||||||
|
Assert.Equal(agent.Id, fetched!.Id);
|
||||||
|
|
||||||
|
// The seat is now an AI seat pointing at the agent.
|
||||||
|
var seats = await client.GetFromJsonAsync<List<SeatResponse>>($"/api/orgboard/seats?teamId={team.Id}");
|
||||||
|
var aiSeat = seats!.Single(s => s.Id == seat.Id);
|
||||||
|
Assert.Equal("Ai", aiSeat.State);
|
||||||
|
Assert.Equal(agent.Id, aiSeat.AgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<BootstrapResponse> Bootstrap(HttpClient client)
|
||||||
|
{
|
||||||
|
var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new
|
||||||
|
{
|
||||||
|
organizationName = "AliaSaaS",
|
||||||
|
ownerEmail = "owner@alia.test",
|
||||||
|
ownerDisplayName = "Owner",
|
||||||
|
ownerPassword = "Passw0rd!",
|
||||||
|
});
|
||||||
|
var owner = await response.Content.ReadFromJsonAsync<BootstrapResponse>();
|
||||||
|
Assert.NotNull(owner);
|
||||||
|
return owner!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient Authed(TeamUpWebFactory factory, string token)
|
||||||
|
{
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T> PostOk<T>(HttpClient client, string url, object body)
|
||||||
|
{
|
||||||
|
var response = await client.PostAsJsonAsync(url, body);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var value = await response.Content.ReadFromJsonAsync<T>();
|
||||||
|
Assert.NotNull(value);
|
||||||
|
return value!;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user